Compare commits
146 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 |
+20
-19
@@ -3,29 +3,30 @@ updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
groups:
|
||||
gomod:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/packaging/docker/alpine"
|
||||
directories:
|
||||
- "/packaging/docker/alpine"
|
||||
- "/packaging/docker/distroless"
|
||||
- "/packaging/debian"
|
||||
- "/packaging/rpm"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/packaging/docker/distroless"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "packaging/debian"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "packaging/rpm"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "monthly"
|
||||
groups:
|
||||
docker:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "monthly"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -6,6 +6,14 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '.github/workflows/build_binaries.yml'
|
||||
- 'Makefile'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- '**.go'
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
@@ -13,9 +21,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: stable
|
||||
check-latest: true
|
||||
@@ -24,7 +32,8 @@ jobs:
|
||||
CGO_ENABLED: 0
|
||||
run: make build
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v7
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: binaries
|
||||
path: miniflux-*
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Mirror to Codeberg
|
||||
|
||||
@@ -38,22 +38,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
if: matrix.language == 'go'
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.repository_owner == 'miniflux')
|
||||
@@ -18,13 +19,13 @@ jobs:
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
@@ -39,13 +40,13 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
@@ -54,7 +55,7 @@ jobs:
|
||||
- name: Build Debian Packages
|
||||
run: make debian-packages
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packages
|
||||
path: "*.deb"
|
||||
@@ -65,13 +66,13 @@ jobs:
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/docker/**'
|
||||
- '.github/workflows/docker.yml'
|
||||
jobs:
|
||||
docker-images:
|
||||
name: Docker Images
|
||||
@@ -18,13 +19,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate Alpine Docker tags
|
||||
id: docker_alpine_tags
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
@@ -37,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Generate Distroless Docker tags
|
||||
id: docker_distroless_tags
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
@@ -51,50 +52,48 @@ jobs:
|
||||
suffix=-distroless,onlatest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
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@v4
|
||||
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@v4
|
||||
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@v4
|
||||
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@v7
|
||||
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@v7
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/distroless/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_distroless_tags.outputs.tags }}
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Javascript Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Install linters
|
||||
run: |
|
||||
sudo npm install -g jshint@2.13.6 eslint@8.57.0
|
||||
@@ -25,11 +25,11 @@ jobs:
|
||||
name: Golang Linters
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
- uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
|
||||
- name: Run gofmt linter
|
||||
run: gofmt -d -e .
|
||||
|
||||
@@ -38,11 +38,11 @@ jobs:
|
||||
name: Commit Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Validate PR commits
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
@@ -31,13 +31,13 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
run: make rpm
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packages
|
||||
path: "*.rpm"
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Close Stale Pull Requests
|
||||
permissions: read-all
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
days-before-pr-stale: 60
|
||||
days-before-pr-close: 14
|
||||
stale-pr-label: stale
|
||||
stale-pr-message: >
|
||||
This pull request has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs within 14 days.
|
||||
close-pr-message: >
|
||||
This pull request has been automatically closed due to inactivity.
|
||||
Please feel free to reopen it if you would like to continue working on it.
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
@@ -17,9 +17,9 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Run unit tests with coverage and race conditions checking
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:9.5
|
||||
image: postgres:11
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
@@ -44,9 +44,9 @@ jobs:
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install Postgres client
|
||||
|
||||
+2
-2
@@ -35,7 +35,7 @@ When reporting bugs:
|
||||
### Requirements
|
||||
|
||||
- **Git**
|
||||
- **Go >= 1.24**
|
||||
- **Go >= 1.26**
|
||||
- **PostgreSQL**
|
||||
|
||||
### Getting Started
|
||||
@@ -103,7 +103,7 @@ You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE
|
||||
### Cross-Platform Support
|
||||
|
||||
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
|
||||
- Linux (amd64, arm64, armv7, armv6, armv5)
|
||||
- Linux (amd64, arm64, armv7, armv6, armv5, riscv64)
|
||||
- macOS (amd64, arm64)
|
||||
- FreeBSD, OpenBSD, Windows (amd64)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export PGPASSWORD := postgres
|
||||
linux-armv7 \
|
||||
linux-armv6 \
|
||||
linux-armv5 \
|
||||
linux-riscv64 \
|
||||
darwin-amd64 \
|
||||
darwin-arm64 \
|
||||
freebsd-amd64 \
|
||||
@@ -61,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
|
||||
@@ -77,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
|
||||
@@ -135,7 +140,7 @@ docker-image-distroless:
|
||||
|
||||
docker-images:
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6,linux/riscv64 \
|
||||
--file packaging/docker/alpine/Dockerfile \
|
||||
--tag $(DOCKER_IMAGE):$(VERSION) \
|
||||
--push .
|
||||
@@ -162,3 +167,4 @@ debian-packages: clean
|
||||
$(MAKE) debian DOCKER_PLATFORM=amd64
|
||||
$(MAKE) debian DOCKER_PLATFORM=arm64
|
||||
$(MAKE) debian DOCKER_PLATFORM=arm/v7
|
||||
$(MAKE) debian DOCKER_PLATFORM=riscv64
|
||||
|
||||
@@ -103,7 +103,7 @@ Features
|
||||
- Compatible only with modern browsers.
|
||||
- Adheres to the [Twelve-Factor App](https://12factor.net/) methodology.
|
||||
- Provides official Debian/RPM packages and pre-built binaries.
|
||||
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM architecture support.
|
||||
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM and RISC-V architecture support.
|
||||
- Uses a limited amount of third-party go dependencies
|
||||
- Has a comprehensive testsuite, with both unit tests and integration tests.
|
||||
- Only uses a couple of MB of memory and a negligible amount of CPU, even with several hundreds of feeds.
|
||||
|
||||
+73
-2
@@ -888,6 +888,55 @@ func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// EntryIDs returns entry IDs for the current user, optionally filtered by starred status and/or read status.
|
||||
func (c *Client) EntryIDs(filter *EntryIDsFilter) (*EntryIDsResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.EntryIDsContext(ctx, filter)
|
||||
}
|
||||
|
||||
// EntryIDsContext returns entry IDs for the current user, optionally filtered by starred status and/or read status.
|
||||
func (c *Client) EntryIDsContext(ctx context.Context, filter *EntryIDsFilter) (*EntryIDsResultSet, error) {
|
||||
body, err := c.request.Get(ctx, buildEntryIDsFilterQueryString("/v1/entries/ids", filter))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var result EntryIDsResultSet
|
||||
if err := json.NewDecoder(body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func buildEntryIDsFilterQueryString(path string, filter *EntryIDsFilter) string {
|
||||
if filter == nil {
|
||||
return path
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
if filter.Limit > 0 {
|
||||
params.Set("limit", strconv.Itoa(filter.Limit))
|
||||
}
|
||||
if filter.Offset > 0 {
|
||||
params.Set("offset", strconv.Itoa(filter.Offset))
|
||||
}
|
||||
if filter.Starred != nil {
|
||||
params.Set("starred", strconv.FormatBool(*filter.Starred))
|
||||
}
|
||||
if filter.Status != "" {
|
||||
params.Set("status", filter.Status)
|
||||
}
|
||||
|
||||
if len(params) == 0 {
|
||||
return path
|
||||
}
|
||||
|
||||
return path + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// Entries fetches entries using the given filter.
|
||||
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
@@ -981,6 +1030,24 @@ func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, sta
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateEntriesStarred updates the starred state of a list of entries.
|
||||
func (c *Client) UpdateEntriesStarred(entryIDs []int64, starred bool) error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateEntriesStarredContext(ctx, entryIDs, starred)
|
||||
}
|
||||
|
||||
// UpdateEntriesStarredContext updates the starred state of a list of entries.
|
||||
func (c *Client) UpdateEntriesStarredContext(ctx context.Context, entryIDs []int64, starred bool) error {
|
||||
type payload struct {
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
Starred *bool `json:"starred"`
|
||||
}
|
||||
|
||||
_, err := c.request.Put(ctx, "/v1/entries", &payload{EntryIDs: entryIDs, Starred: &starred})
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateEntry updates an entry.
|
||||
func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
@@ -1079,14 +1146,14 @@ func (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FlushHistory changes all entries with the status "read" to "removed".
|
||||
// FlushHistory deletes all entries with the status "read".
|
||||
func (c *Client) FlushHistory() error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FlushHistoryContext(ctx)
|
||||
}
|
||||
|
||||
// FlushHistoryContext changes all entries with the status "read" to "removed".
|
||||
// FlushHistoryContext deletes all entries with the status "read".
|
||||
func (c *Client) FlushHistoryContext(ctx context.Context) error {
|
||||
_, err := c.request.Put(ctx, "/v1/flush-history", nil)
|
||||
return err
|
||||
@@ -1231,6 +1298,10 @@ func buildFilterQueryString(path string, filter *Filter) string {
|
||||
values.Add("status", status)
|
||||
}
|
||||
|
||||
for _, tag := range filter.Tags {
|
||||
values.Add("tags", tag)
|
||||
}
|
||||
|
||||
path = fmt.Sprintf("%s?%s", path, values.Encode())
|
||||
}
|
||||
|
||||
|
||||
@@ -1108,6 +1108,27 @@ func TestUpdateEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntriesStarred(t *testing.T) {
|
||||
starred := true
|
||||
client := NewClientWithOptions(
|
||||
"http://mf",
|
||||
WithHTTPClient(
|
||||
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
|
||||
expectRequest(t, http.MethodPut, "http://mf/v1/entries", nil, req)
|
||||
expectFromJSON(t, req.Body, &struct {
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
Starred *bool `json:"starred"`
|
||||
}{
|
||||
EntryIDs: []int64{1, 2},
|
||||
Starred: &starred,
|
||||
})
|
||||
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
|
||||
})))
|
||||
if err := client.UpdateEntriesStarredContext(t.Context(), []int64{1, 2}, true); err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntry(t *testing.T) {
|
||||
expected := &Entry{
|
||||
ID: 1,
|
||||
@@ -1286,3 +1307,110 @@ func TestUpdateEnclosure(t *testing.T) {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
func TestEntryIDsNoFilter(t *testing.T) {
|
||||
expected := &EntryIDsResultSet{
|
||||
Total: 2,
|
||||
EntryIDs: []int64{1, 2},
|
||||
}
|
||||
client := NewClientWithOptions(
|
||||
"http://mf",
|
||||
WithHTTPClient(
|
||||
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
|
||||
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids", nil, req)
|
||||
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
|
||||
})))
|
||||
res, err := client.EntryIDsContext(t.Context(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(res, expected) {
|
||||
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryIDsWithPaginationFilter(t *testing.T) {
|
||||
expected := &EntryIDsResultSet{
|
||||
Total: 5,
|
||||
EntryIDs: []int64{3},
|
||||
}
|
||||
client := NewClientWithOptions(
|
||||
"http://mf",
|
||||
WithHTTPClient(
|
||||
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
|
||||
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?limit=1&offset=2", nil, req)
|
||||
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
|
||||
})))
|
||||
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Limit: 1, Offset: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(res, expected) {
|
||||
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryIDsWithStarredFilter(t *testing.T) {
|
||||
expected := &EntryIDsResultSet{
|
||||
Total: 1,
|
||||
EntryIDs: []int64{42},
|
||||
}
|
||||
client := NewClientWithOptions(
|
||||
"http://mf",
|
||||
WithHTTPClient(
|
||||
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
|
||||
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?starred=true", nil, req)
|
||||
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
|
||||
})))
|
||||
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(res, expected) {
|
||||
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryIDsWithStatusFilter(t *testing.T) {
|
||||
expected := &EntryIDsResultSet{
|
||||
Total: 10,
|
||||
EntryIDs: []int64{7, 8},
|
||||
}
|
||||
client := NewClientWithOptions(
|
||||
"http://mf",
|
||||
WithHTTPClient(
|
||||
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
|
||||
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?status=unread", nil, req)
|
||||
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
|
||||
})))
|
||||
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Status: EntryStatusUnread})
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(res, expected) {
|
||||
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryIDsWithCombinedFilter(t *testing.T) {
|
||||
expected := &EntryIDsResultSet{
|
||||
Total: 3,
|
||||
EntryIDs: []int64{5},
|
||||
}
|
||||
client := NewClientWithOptions(
|
||||
"http://mf",
|
||||
WithHTTPClient(
|
||||
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
|
||||
expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?limit=2&offset=5&starred=false&status=read", nil, req)
|
||||
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
|
||||
})))
|
||||
res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Limit: 2, Offset: 5, Starred: boolPtr(false), Status: EntryStatusRead})
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(res, expected) {
|
||||
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
|
||||
}
|
||||
}
|
||||
|
||||
+31
-5
@@ -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"`
|
||||
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"`
|
||||
@@ -175,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.
|
||||
@@ -188,6 +196,7 @@ type FeedCreationRequest struct {
|
||||
Crawler bool `json:"crawler"`
|
||||
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
|
||||
Disabled bool `json:"disabled"`
|
||||
NoMediaPlayer bool `json:"no_media_player"`
|
||||
IgnoreHTTPCache bool `json:"ignore_http_cache"`
|
||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
@@ -208,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"`
|
||||
@@ -223,6 +233,7 @@ type FeedModificationRequest struct {
|
||||
Password *string `json:"password"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
NoMediaPlayer *bool `json:"no_media_player"`
|
||||
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
|
||||
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy *bool `json:"fetch_via_proxy"`
|
||||
@@ -321,6 +332,7 @@ type Filter struct {
|
||||
CategoryID int64
|
||||
FeedID int64
|
||||
Statuses []string
|
||||
Tags []string
|
||||
GloballyVisible bool
|
||||
}
|
||||
|
||||
@@ -330,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"`
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
db:
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
- POSTGRES_PASSWORD=secret
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
module miniflux.app/v2
|
||||
|
||||
// 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.12.0
|
||||
github.com/andybalholm/brotli v1.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/go-webauthn/webauthn v0.16.2
|
||||
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.11
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.38.0
|
||||
golang.org/x/net v0.52.0
|
||||
github.com/tdewolff/minify/v2 v2.24.13
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.2.2 // 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
|
||||
)
|
||||
@@ -28,7 +31,7 @@ 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.1 // 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
|
||||
@@ -38,12 +41,10 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.11 // indirect
|
||||
github.com/tinylib/msgp v1.6.3 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.12 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
|
||||
go 1.26.0
|
||||
|
||||
@@ -8,21 +8,21 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
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.16.2 h1:n116UuvIa7nUVGFP2hO9U24gBqhJTcmbU3ph0wgVzFM=
|
||||
github.com/go-webauthn/webauthn v0.16.2/go.mod h1:R2xjJxSPat5PYKg5r6cUmqXgbHtbv4GmF6uGkqFMLNI=
|
||||
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
|
||||
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
|
||||
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=
|
||||
@@ -62,14 +62,15 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tdewolff/minify/v2 v2.24.11 h1:JlANsiWaRBXedoYtsiZgY3YFkdr42oF32vp2SLgQKi4=
|
||||
github.com/tdewolff/minify/v2 v2.24.11/go.mod h1:exq1pjdrh9uAICdfVKQwqz6MsJmWmQahZuTC6pTO6ro=
|
||||
github.com/tdewolff/parse/v2 v2.8.11 h1:SGyjEy3xEqd+W9WVzTlTQ5GkP/en4a1AZNZVJ1cvgm0=
|
||||
github.com/tdewolff/parse/v2 v2.8.11/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
|
||||
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
|
||||
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
|
||||
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
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=
|
||||
@@ -87,10 +88,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
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=
|
||||
@@ -105,8 +106,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
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=
|
||||
@@ -127,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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -138,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.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
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=
|
||||
@@ -149,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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
||||
+2
-1
@@ -54,8 +54,9 @@ func NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {
|
||||
mux.HandleFunc("GET /v1/feeds/{feedID}/entries", handler.getFeedEntriesHandler)
|
||||
mux.HandleFunc("POST /v1/feeds/{feedID}/entries/import", handler.importFeedEntryHandler)
|
||||
mux.HandleFunc("GET /v1/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler)
|
||||
mux.HandleFunc("GET /v1/entries/ids", handler.getEntryIDsHandler)
|
||||
mux.HandleFunc("GET /v1/entries", handler.getEntriesHandler)
|
||||
mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusHandler)
|
||||
mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusAndStarredHandler)
|
||||
mux.HandleFunc("GET /v1/entries/{entryID}", handler.getEntryHandler)
|
||||
mux.HandleFunc("PUT /v1/entries/{entryID}", handler.updateEntryHandler)
|
||||
mux.HandleFunc("PUT /v1/entries/{entryID}/bookmark", handler.toggleStarredHandler)
|
||||
|
||||
@@ -2413,64 +2413,6 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotGetRemovedEntries(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feedEntries.Total == 0 {
|
||||
t.Fatalf(`Expected at least one entry, got none`)
|
||||
}
|
||||
|
||||
if err := regularUserClient.UpdateEntries([]int64{feedEntries.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.Entry(feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
|
||||
t.Fatalf(`Expected entry to be not found, got %v`, err)
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.FeedEntry(feedID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
|
||||
t.Fatalf(`Expected entry to be not found, got %v`, err)
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.CategoryEntry(feedEntries.Entries[0].Feed.Category.ID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
|
||||
t.Fatalf(`Expected entry to be not found, got %v`, err)
|
||||
}
|
||||
|
||||
updatedFeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeedEntries.Total != feedEntries.Total-1 {
|
||||
t.Fatalf(`Expected %d entries, got %d`, feedEntries.Total-1, updatedFeedEntries.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnclosureEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
@@ -2689,6 +2631,303 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntriesStarredEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
entryID := result.Entries[0].ID
|
||||
|
||||
// Star the entry without changing its status.
|
||||
if err := regularUserClient.UpdateEntriesStarred([]int64{entryID}, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err := regularUserClient.Entry(entryID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !entry.Starred {
|
||||
t.Fatalf(`Expected entry to be starred`)
|
||||
}
|
||||
|
||||
if entry.Status != miniflux.EntryStatusUnread {
|
||||
t.Fatalf(`Expected status to remain unread, got %q`, entry.Status)
|
||||
}
|
||||
|
||||
// Unstar the entry.
|
||||
if err := regularUserClient.UpdateEntriesStarred([]int64{entryID}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err = regularUserClient.Entry(entryID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if entry.Starred {
|
||||
t.Fatalf(`Expected entry to no longer be starred`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntryIDsEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
boolPtr := func(b bool) *bool { return &b }
|
||||
|
||||
// A new user should have no entries at all.
|
||||
result, err := regularUserClient.EntryIDs(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.EntryIDs == nil {
|
||||
t.Fatal(`Entry IDs should not be nil`)
|
||||
}
|
||||
|
||||
if len(result.EntryIDs) != 0 {
|
||||
t.Fatalf(`Expected no entry IDs for a new user, got %d`, len(result.EntryIDs))
|
||||
}
|
||||
|
||||
if result.Total != 0 {
|
||||
t.Fatalf(`Expected total to be 0 for a new user, got %d`, result.Total)
|
||||
}
|
||||
|
||||
// Subscribe to a feed so there are entries.
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
allEntries, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(allEntries.Entries) == 0 {
|
||||
t.Fatal(`Expected feed to have entries`)
|
||||
}
|
||||
|
||||
// Without filters, all entries should be returned.
|
||||
result, err = regularUserClient.EntryIDs(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(result.EntryIDs) != allEntries.Total {
|
||||
t.Fatalf(`Expected %d entry IDs, got %d`, allEntries.Total, len(result.EntryIDs))
|
||||
}
|
||||
|
||||
if result.Total != allEntries.Total {
|
||||
t.Fatalf(`Expected total %d, got %d`, allEntries.Total, result.Total)
|
||||
}
|
||||
|
||||
// Filter by status=unread: all entries should be unread initially.
|
||||
unreadResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(unreadResult.EntryIDs) != allEntries.Total {
|
||||
t.Fatalf(`Expected %d unread entry IDs, got %d`, allEntries.Total, len(unreadResult.EntryIDs))
|
||||
}
|
||||
|
||||
// Mark one entry as read and verify status filter results update.
|
||||
firstEntryID := allEntries.Entries[0].ID
|
||||
if err := regularUserClient.UpdateEntries([]int64{firstEntryID}, miniflux.EntryStatusRead); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
unreadResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(unreadResult.EntryIDs) != allEntries.Total-1 {
|
||||
t.Fatalf(`Expected %d unread entry IDs after marking one as read, got %d`, allEntries.Total-1, len(unreadResult.EntryIDs))
|
||||
}
|
||||
|
||||
if unreadResult.Total != allEntries.Total-1 {
|
||||
t.Fatalf(`Expected total %d after marking one as read, got %d`, allEntries.Total-1, unreadResult.Total)
|
||||
}
|
||||
|
||||
for _, id := range unreadResult.EntryIDs {
|
||||
if id == firstEntryID {
|
||||
t.Fatalf(`Entry ID %d should not appear in unread IDs after being marked as read`, firstEntryID)
|
||||
}
|
||||
}
|
||||
|
||||
readResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusRead})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(readResult.EntryIDs) != 1 || readResult.EntryIDs[0] != firstEntryID {
|
||||
t.Fatalf(`Expected only entry %d in read results, got %v`, firstEntryID, readResult.EntryIDs)
|
||||
}
|
||||
|
||||
// Pagination: limit=1 should return 1 entry but total reflects the full unread count.
|
||||
if allEntries.Total >= 2 {
|
||||
pagedResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread, Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(pagedResult.EntryIDs) != 1 {
|
||||
t.Fatalf(`Expected 1 entry ID with limit=1, got %d`, len(pagedResult.EntryIDs))
|
||||
}
|
||||
|
||||
if pagedResult.Total != allEntries.Total-1 {
|
||||
t.Fatalf(`Expected total %d with limit=1, got %d`, allEntries.Total-1, pagedResult.Total)
|
||||
}
|
||||
|
||||
// offset=1 should skip the first entry.
|
||||
offsetResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread, Limit: 1, Offset: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(offsetResult.EntryIDs) != 1 {
|
||||
t.Fatalf(`Expected 1 entry ID with limit=1 offset=1, got %d`, len(offsetResult.EntryIDs))
|
||||
}
|
||||
|
||||
if offsetResult.EntryIDs[0] == pagedResult.EntryIDs[0] {
|
||||
t.Fatalf(`Entry at offset=1 should differ from offset=0, both returned %d`, offsetResult.EntryIDs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by starred=true: initially no starred entries.
|
||||
starredResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(starredResult.EntryIDs) != 0 {
|
||||
t.Fatalf(`Expected no starred entry IDs for a new user, got %d`, len(starredResult.EntryIDs))
|
||||
}
|
||||
|
||||
// Star the first entry and verify it appears in starred results.
|
||||
if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(starredResult.EntryIDs) != 1 {
|
||||
t.Fatalf(`Expected 1 starred entry ID, got %d`, len(starredResult.EntryIDs))
|
||||
}
|
||||
|
||||
if starredResult.Total != 1 {
|
||||
t.Fatalf(`Expected total 1, got %d`, starredResult.Total)
|
||||
}
|
||||
|
||||
if starredResult.EntryIDs[0] != firstEntryID {
|
||||
t.Fatalf(`Expected starred entry ID %d, got %d`, firstEntryID, starredResult.EntryIDs[0])
|
||||
}
|
||||
|
||||
// The read starred entry should appear when filtering by starred=true (read status does not affect it).
|
||||
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(starredResult.EntryIDs) != 1 {
|
||||
t.Fatalf(`Expected starred entry ID to persist after marking as read, got %d result(s)`, len(starredResult.EntryIDs))
|
||||
}
|
||||
|
||||
// starred=false should exclude the starred entry.
|
||||
notStarredResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(false)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, id := range notStarredResult.EntryIDs {
|
||||
if id == firstEntryID {
|
||||
t.Fatalf(`Starred entry %d should not appear in starred=false results`, firstEntryID)
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination with offset past the single starred result should return 0 entries but total 1.
|
||||
pagedStarred, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true), Limit: 0, Offset: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(pagedStarred.EntryIDs) != 0 {
|
||||
t.Fatalf(`Expected 0 entry IDs with offset=1 past the only result, got %d`, len(pagedStarred.EntryIDs))
|
||||
}
|
||||
|
||||
if pagedStarred.Total != 1 {
|
||||
t.Fatalf(`Expected total 1 with offset past results, got %d`, pagedStarred.Total)
|
||||
}
|
||||
|
||||
// Unstarring the entry should remove it from starred=true results.
|
||||
if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(starredResult.EntryIDs) != 0 {
|
||||
t.Fatalf(`Expected no starred entry IDs after unstarring, got %d`, len(starredResult.EntryIDs))
|
||||
}
|
||||
|
||||
if starredResult.Total != 0 {
|
||||
t.Fatalf(`Expected total 0 after unstarring, got %d`, starredResult.Total)
|
||||
}
|
||||
|
||||
// Invalid starred value should return 400.
|
||||
_, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: "maybe"})
|
||||
if err == nil {
|
||||
t.Fatal(`Expected error for invalid status parameter, got nil`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
|
||||
@@ -91,6 +91,91 @@ func TestVersionHandler(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntryIDsHandlerRequiresAuthentication(t *testing.T) {
|
||||
handler := NewHandler(nil, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
if got := w.Code; got != http.StatusUnauthorized {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntryIDsHandlerRejectsInvalidStarredParam(t *testing.T) {
|
||||
handler := NewHandler(nil, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?starred=maybe", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
// Unauthenticated request should be rejected before param validation.
|
||||
if got := w.Code; got != http.StatusUnauthorized {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntryIDsHandlerRejectsInvalidStatusParam(t *testing.T) {
|
||||
handler := NewHandler(nil, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?status=invalid", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
// Unauthenticated request should be rejected before param validation.
|
||||
if got := w.Code; got != http.StatusUnauthorized {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryIDsParamsDefaults(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids", nil)
|
||||
limit, offset := parseEntryIDsParams(r)
|
||||
|
||||
if limit != 10000 {
|
||||
t.Fatalf(`Expected default limit 10000, got %d`, limit)
|
||||
}
|
||||
|
||||
if offset != 0 {
|
||||
t.Fatalf(`Expected default offset 0, got %d`, offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryIDsParamsCustomValues(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=500&offset=100", nil)
|
||||
limit, offset := parseEntryIDsParams(r)
|
||||
|
||||
if limit != 500 {
|
||||
t.Fatalf(`Expected limit 500, got %d`, limit)
|
||||
}
|
||||
|
||||
if offset != 100 {
|
||||
t.Fatalf(`Expected offset 100, got %d`, offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryIDsParamsLimitCappedAtMaximum(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=99999", nil)
|
||||
limit, _ := parseEntryIDsParams(r)
|
||||
|
||||
if limit != 10000 {
|
||||
t.Fatalf(`Expected limit capped at 10000, got %d`, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryIDsParamsZeroLimitUsesDefault(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=0", nil)
|
||||
limit, _ := parseEntryIDsParams(r)
|
||||
|
||||
if limit != 10000 {
|
||||
t.Fatalf(`Expected zero limit to use default 10000, got %d`, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHandlerSupportsBasePathStripping(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
|
||||
@@ -112,10 +112,20 @@ func (h *handler) markCategoryAsReadHandler(w http.ResponseWriter, r *http.Reque
|
||||
func (h *handler) getCategoriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var categories model.Categories
|
||||
var err error
|
||||
includeCounts := request.QueryStringParam(r, "counts", "false")
|
||||
|
||||
if includeCounts == "true" {
|
||||
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))
|
||||
}
|
||||
@@ -158,15 +168,14 @@ func (h *handler) refreshCategoryHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
batchBuilder := h.store.NewBatchBuilder()
|
||||
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithUserID(userID)
|
||||
batchBuilder.WithCategoryID(categoryID)
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
jobs, err := h.store.NewBatchBuilder().
|
||||
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
|
||||
WithoutDisabledFeeds().
|
||||
WithUserID(userID).
|
||||
WithCategoryID(categoryID).
|
||||
WithNextCheckExpired().
|
||||
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
|
||||
FetchJobs()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -22,7 +22,7 @@ func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -33,12 +33,6 @@ func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if enclosure.UserID != userID {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
response.JSON(w, r, enclosure)
|
||||
@@ -62,7 +56,7 @@ func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
enclosure, err := h.store.EnclosureByID(request.UserID(r), enclosureID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -73,12 +67,6 @@ func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if enclosure.UserID != userID {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.MediaProgression = enclosureUpdateRequest.MediaProgression
|
||||
if err := h.store.UpdateEnclosure(enclosure); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
|
||||
+116
-60
@@ -55,10 +55,9 @@ func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithFeedID(feedID).
|
||||
WithEntryIDs(entryID)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -76,10 +75,9 @@ func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithCategoryID(categoryID).
|
||||
WithEntryIDs(entryID)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -91,9 +89,8 @@ func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithEntryIDs(entryID)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -164,26 +161,25 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
|
||||
tags := request.QueryStringParamList(r, "tags")
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithStatuses(statuses)
|
||||
builder.WithSorting(order, direction)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(limit)
|
||||
builder.WithTags(tags)
|
||||
builder.WithEnclosures()
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder := h.store.NewEntryQueryBuilder(userID).
|
||||
WithFeedID(feedID).
|
||||
WithCategoryID(categoryID).
|
||||
WithStatuses(statuses...).
|
||||
WithSorting(order, direction).
|
||||
WithOffset(offset).
|
||||
WithLimit(limit).
|
||||
WithTags(tags...).
|
||||
WithEnclosures()
|
||||
|
||||
if request.HasQueryParam(r, "globally_visible") {
|
||||
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
|
||||
|
||||
if globallyVisible {
|
||||
builder.WithGloballyVisible()
|
||||
builder = builder.WithGloballyVisible()
|
||||
}
|
||||
}
|
||||
|
||||
configureFilters(builder, r)
|
||||
builder = configureFilters(builder, r)
|
||||
|
||||
entries, count, err := builder.GetEntriesWithCount()
|
||||
if err != nil {
|
||||
@@ -193,26 +189,36 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
|
||||
for i := range entries {
|
||||
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entries[i].Content)
|
||||
entries[i].Enclosures.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
}
|
||||
|
||||
response.JSON(w, r, &entriesResponse{Total: count, Entries: entries})
|
||||
}
|
||||
|
||||
func (h *handler) setEntryStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) setEntryStatusAndStarredHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
|
||||
if err := validator.ValidateEntriesStatusAndStarredUpdateRequest(&entriesStatusUpdateRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
if entriesStatusUpdateRequest.Status != "" {
|
||||
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if entriesStatusUpdateRequest.Starred != nil {
|
||||
if err := h.store.SetEntriesStarredState(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, *entriesStatusUpdateRequest.Starred); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.NoContent(w, r)
|
||||
@@ -240,16 +246,14 @@ func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
if !h.store.HasSaveEntry(request.UserID(r)) {
|
||||
response.JSONBadRequest(w, r, errors.New("no third-party integration enabled"))
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
entry, err := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithEntryIDs(entryID).
|
||||
GetEntry()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -290,11 +294,10 @@ func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
loggedUserID := request.UserID(r)
|
||||
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
|
||||
entryBuilder.WithEntryID(entryID)
|
||||
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := entryBuilder.GetEntry()
|
||||
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
|
||||
WithEntryIDs(entryID).
|
||||
GetEntry()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -412,6 +415,10 @@ func (h *handler) importFeedEntryHandler(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -447,11 +454,9 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
|
||||
entryBuilder.WithEntryID(entryID)
|
||||
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := entryBuilder.GetEntry()
|
||||
entry, err := h.store.NewEntryQueryBuilder(loggedUserID).
|
||||
WithEntryIDs(entryID).
|
||||
GetEntry()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -473,9 +478,9 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
|
||||
feedBuilder.WithFeedID(entry.FeedID)
|
||||
feed, err := feedBuilder.GetFeed()
|
||||
feed, err := h.store.NewFeedQueryBuilder(loggedUserID).
|
||||
WithFeedID(entry.FeedID).
|
||||
GetFeed()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -502,57 +507,108 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content), ReadingTime: entry.ReadingTime})
|
||||
}
|
||||
|
||||
func (h *handler) getEntryIDsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if request.HasQueryParam(r, "starred") {
|
||||
starredValue := request.QueryStringParam(r, "starred", "")
|
||||
if starredValue != "true" && starredValue != "false" {
|
||||
response.JSONBadRequest(w, r, errors.New(`invalid starred parameter, must be "true" or "false"`))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if request.HasQueryParam(r, "status") {
|
||||
statusValue := request.QueryStringParam(r, "status", "")
|
||||
if statusValue != model.EntryStatusRead && statusValue != model.EntryStatusUnread {
|
||||
response.JSONBadRequest(w, r, errors.New(`invalid status parameter, must be "read" or "unread"`))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
limit, offset := parseEntryIDsParams(r)
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
|
||||
WithSorting("id", "DESC").
|
||||
WithLimitAndMaximum(limit, model.MaxEntryIDsLimit).
|
||||
WithOffset(offset)
|
||||
|
||||
if request.HasQueryParam(r, "starred") {
|
||||
builder.WithStarred(request.QueryBoolParam(r, "starred", false))
|
||||
}
|
||||
|
||||
if request.HasQueryParam(r, "status") {
|
||||
builder.WithStatuses(request.QueryStringParam(r, "status", ""))
|
||||
}
|
||||
|
||||
entryIDs, total, err := builder.GetEntryIDsWithCount()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entryIDs == nil {
|
||||
entryIDs = []int64{}
|
||||
}
|
||||
|
||||
response.JSON(w, r, entryIDsResponse{Total: total, EntryIDs: entryIDs})
|
||||
}
|
||||
|
||||
func (h *handler) flushHistoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUserID := request.UserID(r)
|
||||
go h.store.FlushHistory(loggedUserID)
|
||||
response.JSONAccepted(w, r)
|
||||
}
|
||||
|
||||
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
|
||||
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) *storage.EntryQueryBuilder {
|
||||
if beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0); beforeEntryID > 0 {
|
||||
builder.BeforeEntryID(beforeEntryID)
|
||||
builder = builder.BeforeEntryID(beforeEntryID)
|
||||
}
|
||||
|
||||
if afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0); afterEntryID > 0 {
|
||||
builder.AfterEntryID(afterEntryID)
|
||||
builder = builder.AfterEntryID(afterEntryID)
|
||||
}
|
||||
|
||||
if beforePublishedTimestamp := request.QueryInt64Param(r, "before", 0); beforePublishedTimestamp > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
|
||||
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if afterPublishedTimestamp := request.QueryInt64Param(r, "after", 0); afterPublishedTimestamp > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
|
||||
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if beforePublishedTimestamp := request.QueryInt64Param(r, "published_before", 0); beforePublishedTimestamp > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
|
||||
builder = builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if afterPublishedTimestamp := request.QueryInt64Param(r, "published_after", 0); afterPublishedTimestamp > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
|
||||
builder = builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
|
||||
}
|
||||
|
||||
if beforeChangedTimestamp := request.QueryInt64Param(r, "changed_before", 0); beforeChangedTimestamp > 0 {
|
||||
builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
|
||||
builder = builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
|
||||
}
|
||||
|
||||
if afterChangedTimestamp := request.QueryInt64Param(r, "changed_after", 0); afterChangedTimestamp > 0 {
|
||||
builder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))
|
||||
}
|
||||
|
||||
if categoryID := request.QueryInt64Param(r, "category_id", 0); categoryID > 0 {
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder = builder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))
|
||||
}
|
||||
|
||||
if request.HasQueryParam(r, "starred") {
|
||||
starred, err := strconv.ParseBool(r.URL.Query().Get("starred"))
|
||||
if err == nil {
|
||||
builder.WithStarred(starred)
|
||||
builder = builder.WithStarred(starred)
|
||||
}
|
||||
}
|
||||
|
||||
if searchQuery := request.QueryStringParam(r, "search", ""); searchQuery != "" {
|
||||
builder.WithSearchQuery(searchQuery)
|
||||
builder = builder.WithSearchQuery(searchQuery)
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
func parseEntryIDsParams(r *http.Request) (limit, offset int) {
|
||||
limit = request.QueryIntParam(r, "limit", model.MaxEntryIDsLimit)
|
||||
if limit <= 0 || limit > model.MaxEntryIDsLimit {
|
||||
limit = model.MaxEntryIDsLimit
|
||||
}
|
||||
offset = request.QueryIntParam(r, "offset", 0)
|
||||
return limit, offset
|
||||
}
|
||||
|
||||
@@ -76,14 +76,13 @@ func (h *handler) refreshFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) refreshAllFeedsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
batchBuilder := h.store.NewBatchBuilder()
|
||||
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithUserID(userID)
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
jobs, err := h.store.NewBatchBuilder().
|
||||
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
|
||||
WithoutDisabledFeeds().
|
||||
WithNextCheckExpired().
|
||||
WithUserID(userID).
|
||||
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
|
||||
FetchJobs()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -116,7 +115,7 @@ func (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
originalFeed, err := h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
response.JSONNotFound(w, r)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ type entryIDResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type entryIDsResponse struct {
|
||||
Total int `json:"total"`
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
}
|
||||
|
||||
type entryContentResponse struct {
|
||||
Content string `json:"content"`
|
||||
ReadingTime int `json:"reading_time"`
|
||||
|
||||
@@ -37,17 +37,17 @@ func (h *handler) discoverSubscriptionsHandler(w http.ResponseWriter, r *http.Re
|
||||
rssbridgeToken = intg.RSSBridgeToken
|
||||
}
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder.WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL)
|
||||
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
|
||||
requestBuilder.UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy)
|
||||
requestBuilder.WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(subscriptionDiscoveryRequest.Cookie)
|
||||
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
|
||||
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
|
||||
WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL).
|
||||
WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL()).
|
||||
UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy).
|
||||
WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent()).
|
||||
WithCookie(subscriptionDiscoveryRequest.Cookie).
|
||||
WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password).
|
||||
IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates).
|
||||
DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
|
||||
|
||||
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
|
||||
subscriptionDiscoveryRequest.URL,
|
||||
|
||||
@@ -6,6 +6,7 @@ package api // import "miniflux.app/v2/internal/api"
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
@@ -21,6 +22,11 @@ func (h *handler) currentUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, r, user)
|
||||
}
|
||||
|
||||
@@ -112,7 +118,13 @@ func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -127,7 +139,13 @@ func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (h *handler) getIntegrationsStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -180,7 +198,7 @@ func (h *handler) userByIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -202,7 +220,7 @@ func (h *handler) userByUsernameHandler(w http.ResponseWriter, r *http.Request)
|
||||
username := request.RouteStringParam(r, "username")
|
||||
user, err := h.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -242,6 +260,13 @@ func (h *handler) removeUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.store.RemoveUserAsync(user.ID)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -25,24 +25,24 @@ func askCredentials() (string, string) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
username, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to read username: %w", err))
|
||||
printfAndExit("unable to read username: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print("Enter Password: ")
|
||||
|
||||
state, err := term.GetState(fd)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to get terminal state: %w", err))
|
||||
printfAndExit("unable to get terminal state: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if restoreErr := term.Restore(fd, state); restoreErr != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to restore terminal state: %w", restoreErr))
|
||||
printfAndExit("unable to restore terminal state: %w", restoreErr)
|
||||
}
|
||||
}()
|
||||
|
||||
bytePassword, err := term.ReadPassword(fd)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to read password: %w", err))
|
||||
printfAndExit("unable to read password: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print("\n")
|
||||
|
||||
@@ -14,12 +14,13 @@ import (
|
||||
)
|
||||
|
||||
func runCleanupTasks(store *storage.Storage) {
|
||||
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsInterval())
|
||||
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsInterval())
|
||||
slog.Info("Sessions cleanup completed",
|
||||
slog.Int64("application_sessions_removed", nbSessions),
|
||||
slog.Int64("user_sessions_removed", nbUserSessions),
|
||||
)
|
||||
if nbWebSessions, err := store.CleanOldWebSessions(config.Opts.CleanupRemoveSessionsInterval()); err != nil {
|
||||
slog.Error("Unable to clean old web sessions", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Sessions cleanup completed",
|
||||
slog.Int64("web_sessions_removed", nbWebSessions),
|
||||
)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
@@ -47,17 +48,11 @@ func runCleanupTasks(store *storage.Storage) {
|
||||
}
|
||||
}
|
||||
|
||||
if enclosuresAffected, err := store.DeleteEnclosuresOfRemovedEntries(); err != nil {
|
||||
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
|
||||
if nbIcons, err := store.CleanupOrphanIcons(); err != nil {
|
||||
slog.Error("Unable to clean orphan icons", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Deleting enclosures from removed entries completed",
|
||||
slog.Int64("removed_entries_enclosures_deleted", enclosuresAffected))
|
||||
}
|
||||
|
||||
if contentAffected, err := store.ClearRemovedEntriesContent(config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
slog.Error("Unable to clear content from removed entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Clearing content from removed entries completed",
|
||||
slog.Int64("removed_entries_content_cleared", contentAffected))
|
||||
slog.Info("Orphan icons cleanup completed",
|
||||
slog.Int64("orphan_icons_removed", nbIcons),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -124,7 +124,7 @@ func Parse() {
|
||||
default:
|
||||
logFileHandler, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to open log file: %v", err))
|
||||
printfAndExit("unable to open log file: %v", err)
|
||||
}
|
||||
defer logFileHandler.(*os.File).Close()
|
||||
}
|
||||
@@ -143,15 +143,15 @@ func Parse() {
|
||||
}
|
||||
|
||||
if err := static.GenerateBinaryBundles(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate binary files bundle: %v", err))
|
||||
printfAndExit("unable to generate binary files bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := static.GenerateStylesheetsBundles(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err))
|
||||
printfAndExit("unable to generate stylesheets bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
|
||||
printfAndExit("unable to generate javascript bundle: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.NewConnectionPool(
|
||||
@@ -161,7 +161,7 @@ func Parse() {
|
||||
config.Opts.DatabaseConnectionLifetime(),
|
||||
)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to connect to database: %v", err))
|
||||
printfAndExit("unable to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
@@ -231,7 +231,7 @@ func Parse() {
|
||||
slog.Info("Initializing proxy rotation", slog.Int("proxies_count", len(config.Opts.HTTPClientProxies())))
|
||||
proxyrotator.ProxyRotatorInstance, err = proxyrotator.NewProxyRotator(config.Opts.HTTPClientProxies())
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to initialize proxy rotator: %v", err))
|
||||
printfAndExit("unable to initialize proxy rotator: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,3 +252,8 @@ func printErrorAndExit(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func printfAndExit(format string, args ...any) {
|
||||
err := fmt.Errorf(format, args...)
|
||||
printErrorAndExit(err)
|
||||
}
|
||||
|
||||
@@ -13,17 +13,17 @@ import (
|
||||
func exportUserFeeds(store *storage.Storage, username string) {
|
||||
user, err := store.UserByUsername(username)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to find user: %w", err))
|
||||
printfAndExit("unable to find user: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
printErrorAndExit(fmt.Errorf("user %q not found", username))
|
||||
printfAndExit("user %q not found", username)
|
||||
}
|
||||
|
||||
opmlHandler := opml.NewHandler(store)
|
||||
opmlExport, err := opmlHandler.Export(user.ID)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to export feeds: %w", err))
|
||||
printfAndExit("unable to export feeds: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(opmlExport)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -22,12 +21,12 @@ func doHealthCheck(healthCheckEndpoint string) {
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get(healthCheckEndpoint)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf(`health check failure: %v`, err))
|
||||
printfAndExit(`health check failure: %v`, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
printErrorAndExit(fmt.Errorf(`health check failed with status code %d`, resp.StatusCode))
|
||||
printfAndExit(`health check failed with status code %d`, resp.StatusCode)
|
||||
}
|
||||
|
||||
slog.Debug(`Health check is passing`)
|
||||
|
||||
@@ -20,14 +20,13 @@ func refreshFeeds(store *storage.Storage) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Generate a batch of feeds for any user that has feeds to refresh.
|
||||
batchBuilder := store.NewBatchBuilder()
|
||||
batchBuilder.WithBatchSize(config.Opts.BatchSize())
|
||||
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
jobs, err := store.NewBatchBuilder().
|
||||
WithBatchSize(config.Opts.BatchSize()).
|
||||
WithErrorLimit(config.Opts.PollingParsingErrorLimit()).
|
||||
WithoutDisabledFeeds().
|
||||
WithNextCheckExpired().
|
||||
WithLimitPerHost(config.Opts.PollingLimitPerHost()).
|
||||
FetchJobs()
|
||||
if err != nil {
|
||||
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
|
||||
return
|
||||
@@ -36,7 +35,7 @@ func refreshFeeds(store *storage.Storage) {
|
||||
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
|
||||
|
||||
nbJobs := len(jobs)
|
||||
var jobQueue = make(chan model.Job, nbJobs)
|
||||
jobQueue := make(chan model.Job, nbJobs)
|
||||
|
||||
slog.Info("Starting a pool of workers",
|
||||
slog.Int("nb_workers", config.Opts.WorkerPoolSize()),
|
||||
|
||||
@@ -33,14 +33,15 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency time.Duration, batchSize, errorLimit, limitPerHost int) {
|
||||
for range time.Tick(frequency) {
|
||||
// Generate a batch of feeds for any user that has feeds to refresh.
|
||||
batchBuilder := store.NewBatchBuilder()
|
||||
batchBuilder.WithBatchSize(batchSize)
|
||||
batchBuilder.WithErrorLimit(errorLimit)
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(limitPerHost)
|
||||
jobs, err := store.NewBatchBuilder().
|
||||
WithBatchSize(batchSize).
|
||||
WithErrorLimit(errorLimit).
|
||||
WithoutDisabledFeeds().
|
||||
WithNextCheckExpired().
|
||||
WithLimitPerHost(limitPerHost).
|
||||
FetchJobs()
|
||||
|
||||
if jobs, err := batchBuilder.FetchJobs(); err != nil {
|
||||
if err != nil {
|
||||
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
|
||||
} else if len(jobs) > 0 {
|
||||
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
|
||||
|
||||
@@ -1799,8 +1799,8 @@ func TestValidateDisableLocalAuthWithOAuth2ButNoUserCreation(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err == nil {
|
||||
t.Fatal("Expected error when local auth is disabled with OAuth2 but without user creation")
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1829,8 +1829,8 @@ func TestValidateDisableLocalAuthWithAuthProxyButNoUserCreation(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("Unexpected parse error: %v", err)
|
||||
}
|
||||
if err := configParser.options.Validate(); err == nil {
|
||||
t.Fatal("Expected error when local auth is disabled with auth proxy but without user creation")
|
||||
if err := configParser.options.Validate(); err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,13 +57,8 @@ func (c *configOptions) Validate() error {
|
||||
}
|
||||
|
||||
if c.DisableLocalAuth() {
|
||||
switch {
|
||||
case c.OAuth2Provider() == "" && c.AuthProxyHeader() == "":
|
||||
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")
|
||||
case c.OAuth2Provider() != "" && !c.IsOAuth2UserCreationAllowed():
|
||||
return errors.New("DISABLE_LOCAL_AUTH is enabled and an OAUTH2_PROVIDER is configured, but OAUTH2_USER_CREATION is not enabled")
|
||||
case c.AuthProxyHeader() != "" && !c.IsAuthProxyUserCreationAllowed():
|
||||
return errors.New("DISABLE_LOCAL_AUTH is enabled and an AUTH_PROXY_HEADER is configured, but AUTH_PROXY_USER_CREATION is not enabled")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package database // import "miniflux.app/v2/internal/database"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
)
|
||||
@@ -352,7 +353,11 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
|
||||
// This migration originally used md5(url), but it was changed to
|
||||
// sha256 because PostgreSQL 18 disables MD5 in FIPS mode, which made
|
||||
// fresh installs fail while replaying this migration. Existing
|
||||
// installs that already ran it are migrated later on.
|
||||
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, encode(sha256(url::bytea), 'hex'))`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
@@ -483,7 +488,7 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
)
|
||||
|
||||
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
break
|
||||
}
|
||||
return err
|
||||
@@ -723,7 +728,12 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
}
|
||||
|
||||
// Create unique index
|
||||
_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, md5(url))`)
|
||||
//
|
||||
// This originally used md5(url), but it was changed to sha256 because
|
||||
// PostgreSQL 18 disables MD5 in FIPS mode, which made fresh installs
|
||||
// fail while replaying this migration. Existing installs that already
|
||||
// ran it are migrated later on.
|
||||
_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, encode(sha256(url::bytea), 'hex'))`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1081,7 +1091,7 @@ var migrations = [...]func(tx *sql.Tx) error{
|
||||
var id int64
|
||||
|
||||
if err := tx.QueryRow(`FETCH NEXT FROM id_cursor`).Scan(&id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
break
|
||||
}
|
||||
return err
|
||||
@@ -1431,4 +1441,108 @@ var migrations = [...]func(tx *sql.Tx) 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
|
||||
},
|
||||
}
|
||||
|
||||
+19
-24
@@ -238,9 +238,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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"):
|
||||
@@ -250,8 +249,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("since_id", sinceID),
|
||||
)
|
||||
builder.AfterEntryID(sinceID)
|
||||
builder.WithSorting("id", "ASC")
|
||||
builder = builder.AfterEntryID(sinceID)
|
||||
builder = builder.WithSorting("id", "ASC")
|
||||
}
|
||||
case request.HasQueryParam(r, "max_id"):
|
||||
maxID := request.QueryInt64Param(r, "max_id", 0)
|
||||
@@ -259,14 +258,14 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Debug("[Fever] Fetching most recent items",
|
||||
slog.Int64("user_id", userID),
|
||||
)
|
||||
builder.WithSorting("id", "DESC")
|
||||
builder = builder.WithSorting("id", "DESC")
|
||||
} else if maxID > 0 {
|
||||
slog.Debug("[Fever] Fetching items before a given item ID",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("max_id", maxID),
|
||||
)
|
||||
builder.BeforeEntryID(maxID)
|
||||
builder.WithSorting("id", "DESC")
|
||||
builder = builder.BeforeEntryID(maxID)
|
||||
builder = builder.WithSorting("id", "DESC")
|
||||
}
|
||||
case request.HasQueryParam(r, "with_ids"):
|
||||
csvItemIDs := request.QueryStringParam(r, "with_ids", "")
|
||||
@@ -279,7 +278,7 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
itemIDs = append(itemIDs, itemID)
|
||||
}
|
||||
|
||||
builder.WithEntryIDs(itemIDs)
|
||||
builder = builder.WithEntryIDs(itemIDs...)
|
||||
}
|
||||
default:
|
||||
slog.Debug("[Fever] Fetching oldest items",
|
||||
@@ -293,9 +292,8 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -344,9 +342,9 @@ func (h *feverHandler) handleUnreadItems(w http.ResponseWriter, r *http.Request)
|
||||
slog.Int64("user_id", userID),
|
||||
)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
rawEntryIDs, err := h.store.NewEntryQueryBuilder(userID).
|
||||
WithStatuses(model.EntryStatusUnread).
|
||||
GetEntryIDs()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -377,10 +375,9 @@ func (h *feverHandler) handleSavedItems(w http.ResponseWriter, r *http.Request)
|
||||
slog.Int64("user_id", userID),
|
||||
)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithStarred(true)
|
||||
|
||||
entryIDs, err := builder.GetEntryIDs()
|
||||
entryIDs, err := h.store.NewEntryQueryBuilder(userID).
|
||||
WithStarred(true).
|
||||
GetEntryIDs()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -412,11 +409,9 @@ func (h *feverHandler) 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 {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -236,26 +236,24 @@ func (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request)
|
||||
slog.Any("tags", tags),
|
||||
)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithEntryIDs(itemIDs)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
entries, err := h.store.NewEntryQueryBuilder(userID).
|
||||
WithEntryIDs(itemIDs...).
|
||||
GetEntries()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
n := 0
|
||||
readEntryIDs := make([]int64, 0)
|
||||
unreadEntryIDs := make([]int64, 0)
|
||||
starredEntryIDs := make([]int64, 0)
|
||||
unstarredEntryIDs := make([]int64, 0)
|
||||
var readEntryIDs []int64
|
||||
var unreadEntryIDs []int64
|
||||
var starredEntryIDs []int64
|
||||
var unstarredEntryIDs []int64
|
||||
for _, entry := range entries {
|
||||
if read, exists := tags[ReadStream]; exists {
|
||||
if read && entry.Status == model.EntryStatusUnread {
|
||||
readEntryIDs = append(readEntryIDs, entry.ID)
|
||||
} else if entry.Status == model.EntryStatusRead {
|
||||
} else if !read && entry.Status == model.EntryStatusRead {
|
||||
unreadEntryIDs = append(unreadEntryIDs, entry.ID)
|
||||
}
|
||||
}
|
||||
@@ -265,7 +263,7 @@ func (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request)
|
||||
// filter the original array
|
||||
entries[n] = entry
|
||||
n++
|
||||
} else if entry.Starred {
|
||||
} else if !starred && entry.Starred {
|
||||
unstarredEntryIDs = append(unstarredEntryIDs, entry.ID)
|
||||
}
|
||||
}
|
||||
@@ -344,9 +342,10 @@ func (h *greaderHandler) quickAddHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder := fetcher.NewRequestBuilder().
|
||||
WithTimeout(config.Opts.HTTPClientTimeout()).
|
||||
WithProxyRotator(proxyrotator.ProxyRotatorInstance).
|
||||
WithUserAgent("", config.Opts.HTTPClientUserAgent())
|
||||
|
||||
var rssBridgeURL string
|
||||
var rssBridgeToken string
|
||||
@@ -650,13 +649,11 @@ func (h *greaderHandler) streamItemContentsHandler(w http.ResponseWriter, r *htt
|
||||
slog.Any("item_ids", itemIDs),
|
||||
)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithEnclosures()
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithEntryIDs(itemIDs)
|
||||
builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
entries, err := h.store.NewEntryQueryBuilder(userID).
|
||||
WithEnclosures().
|
||||
WithEntryIDs(itemIDs...).
|
||||
WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection).
|
||||
GetEntries()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
@@ -1014,11 +1011,15 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID)
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID).
|
||||
WithLimit(rm.Count).
|
||||
WithOffset(rm.Offset).
|
||||
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
|
||||
for _, s := range rm.ExcludeTargets {
|
||||
switch s.Type {
|
||||
case ReadStream:
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder = builder.WithStatuses(model.EntryStatusUnread)
|
||||
default:
|
||||
slog.Warn("[GoogleReader] Unknown ExcludeTargets filter type",
|
||||
slog.String("handler", "handleReadingListStreamHandler"),
|
||||
@@ -1029,15 +1030,12 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
|
||||
}
|
||||
}
|
||||
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithLimit(rm.Count)
|
||||
builder.WithOffset(rm.Offset)
|
||||
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
if rm.StartTime > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
}
|
||||
|
||||
if rm.StopTime > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
@@ -1049,38 +1047,42 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
|
||||
}
|
||||
|
||||
func (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithStarred(true)
|
||||
builder.WithLimit(rm.Count)
|
||||
builder.WithOffset(rm.Offset)
|
||||
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID).
|
||||
WithStarred(true).
|
||||
WithLimit(rm.Count).
|
||||
WithOffset(rm.Offset).
|
||||
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
|
||||
if rm.StartTime > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
}
|
||||
|
||||
if rm.StopTime > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithStatus(model.EntryStatusRead)
|
||||
builder.WithLimit(rm.Count)
|
||||
builder.WithOffset(rm.Offset)
|
||||
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID).
|
||||
WithStatuses(model.EntryStatusRead).
|
||||
WithLimit(rm.Count).
|
||||
WithOffset(rm.Offset).
|
||||
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
|
||||
if rm.StartTime > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
}
|
||||
|
||||
if rm.StopTime > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
@@ -1088,6 +1090,7 @@ func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
@@ -1096,7 +1099,7 @@ func getItemRefsAndContinuation(builder storage.EntryQueryBuilder, rm requestMod
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var itemRefs = make([]itemRef, 0, len(rawEntryIDs))
|
||||
itemRefs := make([]itemRef, 0, len(rawEntryIDs))
|
||||
for _, entryID := range rawEntryIDs {
|
||||
formattedID := strconv.FormatInt(entryID, 10)
|
||||
itemRefs = append(itemRefs, itemRef{ID: formattedID})
|
||||
@@ -1120,33 +1123,32 @@ func (h *greaderHandler) handleFeedStreamHandler(w http.ResponseWriter, r *http.
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithLimit(rm.Count)
|
||||
builder.WithOffset(rm.Offset)
|
||||
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID).
|
||||
WithFeedID(feedID).
|
||||
WithLimit(rm.Count).
|
||||
WithOffset(rm.Offset).
|
||||
WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||
|
||||
if rm.StartTime > 0 {
|
||||
builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
builder = builder.AfterPublishedDate(time.Unix(rm.StartTime, 0))
|
||||
}
|
||||
|
||||
if rm.StopTime > 0 {
|
||||
builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
builder = builder.BeforePublishedDate(time.Unix(rm.StopTime, 0))
|
||||
}
|
||||
|
||||
if len(rm.ExcludeTargets) > 0 {
|
||||
for _, s := range rm.ExcludeTargets {
|
||||
if s.Type == ReadStream {
|
||||
builder.WithoutStatus(model.EntryStatusRead)
|
||||
}
|
||||
for _, s := range rm.ExcludeTargets {
|
||||
if s.Type == ReadStream {
|
||||
builder = builder.WithoutStatus(model.EntryStatusRead)
|
||||
}
|
||||
}
|
||||
|
||||
itemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
|
||||
+148
-144
@@ -28,153 +28,157 @@ func newAuthMiddleware(s *storage.Storage) *authMiddleware {
|
||||
|
||||
func (m *authMiddleware) validateApiKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.ClientIP(r)
|
||||
|
||||
var token string
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
slog.Warn("[GoogleReader] Could not parse request form data",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token = r.Form.Get("T")
|
||||
if token == "" {
|
||||
slog.Warn("[GoogleReader] Post-Form T field is empty",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
|
||||
if authorization == "" {
|
||||
slog.Warn("[GoogleReader] No token provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(authorization)
|
||||
if len(fields) != 2 {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if fields[0] != "GoogleLogin" {
|
||||
slog.Warn("[GoogleReader] Authorization header does not begin with GoogleLogin",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
auths := strings.Split(fields[1], "=")
|
||||
if len(auths) != 2 {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if auths[0] != "auth" {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
token = auths[1]
|
||||
}
|
||||
|
||||
parts := strings.Split(token, "/")
|
||||
if len(parts) != 2 {
|
||||
slog.Warn("[GoogleReader] Auth token does not have the expected structure username/hash",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("token", token),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
var integration *model.Integration
|
||||
var user *model.User
|
||||
var err error
|
||||
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader username",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
|
||||
if !crypto.ConstantTimeCmp(expectedToken, token) {
|
||||
slog.Warn("[GoogleReader] Token does not match",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if user, err = m.store.UserByID(integration.UserID); err != nil {
|
||||
slog.Error("[GoogleReader] Unable to fetch user from database",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
m.store.SetLastLogin(integration.UserID)
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
|
||||
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
|
||||
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
|
||||
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
|
||||
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
|
||||
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
m.serveValidated(w, r, next)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *authMiddleware) serveValidated(w http.ResponseWriter, r *http.Request, next http.Handler) {
|
||||
clientIP := request.ClientIP(r)
|
||||
|
||||
var token string
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
slog.Warn("[GoogleReader] Could not parse request form data",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token = r.Form.Get("T")
|
||||
if token == "" {
|
||||
slog.Warn("[GoogleReader] Post-Form T field is empty",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
|
||||
if authorization == "" {
|
||||
slog.Warn("[GoogleReader] No token provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(authorization)
|
||||
if len(fields) != 2 {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if fields[0] != "GoogleLogin" {
|
||||
slog.Warn("[GoogleReader] Authorization header does not begin with GoogleLogin",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
auths := strings.Split(fields[1], "=")
|
||||
if len(auths) != 2 {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if auths[0] != "auth" {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
token = auths[1]
|
||||
}
|
||||
|
||||
parts := strings.Split(token, "/")
|
||||
if len(parts) != 2 {
|
||||
slog.Warn("[GoogleReader] Auth token does not have the expected structure username/hash",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("token", token),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
var integration *model.Integration
|
||||
var user *model.User
|
||||
var err error
|
||||
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader username",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
|
||||
if !crypto.ConstantTimeCmp(expectedToken, token) {
|
||||
slog.Warn("[GoogleReader] Token does not match",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if user, err = m.store.UserByID(integration.UserID); err != nil {
|
||||
slog.Error("[GoogleReader] Unable to fetch user from database",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
m.store.SetLastLogin(integration.UserID)
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
|
||||
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
|
||||
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
|
||||
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
|
||||
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
|
||||
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
func getAuthToken(username, password string) string {
|
||||
token := hex.EncodeToString(hmac.New(sha256.New, []byte(username+password)).Sum(nil))
|
||||
token = username + "/" + token
|
||||
|
||||
@@ -120,10 +120,10 @@ type contentItemOrigin struct {
|
||||
}
|
||||
|
||||
func sendUnauthorizedResponse(w http.ResponseWriter, r *http.Request) {
|
||||
builder := response.NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusUnauthorized)
|
||||
builder.WithHeader("X-Reader-Google-Bad-Token", "true")
|
||||
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
builder.WithBodyAsString("Unauthorized")
|
||||
builder.Write()
|
||||
response.NewBuilder(w, r).
|
||||
WithStatus(http.StatusUnauthorized).
|
||||
WithHeader("X-Reader-Google-Bad-Token", "true").
|
||||
WithHeader("Content-Type", "text/plain; charset=utf-8").
|
||||
WithBodyAsString("Unauthorized").
|
||||
Write()
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cookie // import "miniflux.app/v2/internal/http/cookie"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
// Cookie names.
|
||||
const (
|
||||
CookieAppSessionID = "MinifluxAppSessionID"
|
||||
CookieUserSessionID = "MinifluxUserSessionID"
|
||||
)
|
||||
|
||||
// New creates a new cookie.
|
||||
func New(name, value string, isHTTPS bool, path string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: basePath(path),
|
||||
Secure: isHTTPS,
|
||||
HttpOnly: true,
|
||||
Expires: time.Now().Add(config.Opts.CleanupRemoveSessionsInterval()),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
// Expired returns an expired cookie.
|
||||
func Expired(name string, isHTTPS bool, path string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: basePath(path),
|
||||
Secure: isHTTPS,
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
func basePath(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -5,8 +5,6 @@ package request // import "miniflux.app/v2/internal/http/request"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
@@ -21,26 +19,16 @@ const (
|
||||
UserTimezoneContextKey
|
||||
IsAdminUserContextKey
|
||||
IsAuthenticatedContextKey
|
||||
UserSessionTokenContextKey
|
||||
UserLanguageContextKey
|
||||
UserThemeContextKey
|
||||
SessionIDContextKey
|
||||
CSRFContextKey
|
||||
OAuth2StateContextKey
|
||||
OAuth2CodeVerifierContextKey
|
||||
FlashMessageContextKey
|
||||
FlashErrorMessageContextKey
|
||||
LastForceRefreshContextKey
|
||||
WebSessionContextKey
|
||||
ClientIPContextKey
|
||||
GoogleReaderTokenKey
|
||||
WebAuthnDataContextKey
|
||||
)
|
||||
|
||||
// WebAuthnSessionData returns WebAuthn session data from the request context, or nil if absent.
|
||||
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
|
||||
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
|
||||
if value, valid := v.(model.WebAuthnSession); valid {
|
||||
return &value
|
||||
// WebSession returns the current web session from the request context, if present.
|
||||
func WebSession(r *http.Request) *model.WebSession {
|
||||
if v := r.Context().Value(WebSessionContextKey); v != nil {
|
||||
if value, valid := v.(*model.WebSession); valid {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -58,12 +46,30 @@ func IsAdminUser(r *http.Request) bool {
|
||||
|
||||
// IsAuthenticated reports whether the user is authenticated.
|
||||
func IsAuthenticated(r *http.Request) bool {
|
||||
return getContextBoolValue(r, IsAuthenticatedContextKey)
|
||||
if getContextBoolValue(r, IsAuthenticatedContextKey) {
|
||||
return true
|
||||
}
|
||||
|
||||
if session := WebSession(r); session != nil {
|
||||
return session.IsAuthenticated()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// UserID returns the logged-in user's ID from the request context.
|
||||
func UserID(r *http.Request) int64 {
|
||||
return getContextInt64Value(r, UserIDContextKey)
|
||||
if userID := getContextInt64Value(r, UserIDContextKey); userID != 0 {
|
||||
return userID
|
||||
}
|
||||
|
||||
if session := WebSession(r); session != nil {
|
||||
if id, ok := session.UserID(); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// UserName returns the logged-in user's username, or "unknown" when unset.
|
||||
@@ -84,69 +90,6 @@ func UserTimezone(r *http.Request) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// UserLanguage returns the user's locale, defaulting to "en_US" when unset.
|
||||
func UserLanguage(r *http.Request) string {
|
||||
language := getContextStringValue(r, UserLanguageContextKey)
|
||||
if language == "" {
|
||||
language = "en_US"
|
||||
}
|
||||
return language
|
||||
}
|
||||
|
||||
// UserTheme returns the user's theme, defaulting to "system_serif" when unset.
|
||||
func UserTheme(r *http.Request) string {
|
||||
theme := getContextStringValue(r, UserThemeContextKey)
|
||||
if theme == "" {
|
||||
theme = "system_serif"
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
// CSRF returns the CSRF token from the request context.
|
||||
func CSRF(r *http.Request) string {
|
||||
return getContextStringValue(r, CSRFContextKey)
|
||||
}
|
||||
|
||||
// SessionID returns the current session ID from the request context.
|
||||
func SessionID(r *http.Request) string {
|
||||
return getContextStringValue(r, SessionIDContextKey)
|
||||
}
|
||||
|
||||
// UserSessionToken returns the current user session token from the request context.
|
||||
func UserSessionToken(r *http.Request) string {
|
||||
return getContextStringValue(r, UserSessionTokenContextKey)
|
||||
}
|
||||
|
||||
// OAuth2State returns the OAuth2 state value from the request context.
|
||||
func OAuth2State(r *http.Request) string {
|
||||
return getContextStringValue(r, OAuth2StateContextKey)
|
||||
}
|
||||
|
||||
// OAuth2CodeVerifier returns the OAuth2 PKCE code verifier from the request context.
|
||||
func OAuth2CodeVerifier(r *http.Request) string {
|
||||
return getContextStringValue(r, OAuth2CodeVerifierContextKey)
|
||||
}
|
||||
|
||||
// FlashMessage returns the flash message from the request context, if any.
|
||||
func FlashMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashMessageContextKey)
|
||||
}
|
||||
|
||||
// FlashErrorMessage returns the flash error message from the request context, if any.
|
||||
func FlashErrorMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashErrorMessageContextKey)
|
||||
}
|
||||
|
||||
// LastForceRefresh returns the last force refresh timestamp from the request context.
|
||||
func LastForceRefresh(r *http.Request) time.Time {
|
||||
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
|
||||
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(timestamp, 0)
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address stored in the request context.
|
||||
func ClientIP(r *http.Request) string {
|
||||
return getContextStringValue(r, ClientIPContextKey)
|
||||
|
||||
@@ -7,11 +7,16 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func newRequestWithWebSession(session *model.WebSession) *http.Request {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
ctx := context.WithValue(r.Context(), WebSessionContextKey, session)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
func TestContextStringValue(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
ctx := r.Context()
|
||||
@@ -171,6 +176,15 @@ func TestIsAuthenticated(t *testing.T) {
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
session := &model.WebSession{}
|
||||
session.SetUser(&model.User{ID: 42})
|
||||
r = newRequestWithWebSession(session)
|
||||
|
||||
result = IsAuthenticated(r)
|
||||
if !result {
|
||||
t.Errorf("Unexpected context value, got %v instead of true", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserID(t *testing.T) {
|
||||
@@ -193,6 +207,17 @@ func TestUserID(t *testing.T) {
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
session := &model.WebSession{}
|
||||
session.SetUser(&model.User{ID: 456})
|
||||
r = newRequestWithWebSession(session)
|
||||
|
||||
result = UserID(r)
|
||||
expected = int64(456)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserName(t *testing.T) {
|
||||
@@ -239,262 +264,21 @@ func TestUserTimezone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserLanguage(t *testing.T) {
|
||||
func TestWebSession(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserLanguage(r)
|
||||
expected := "en_US"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
if result := WebSession(r); result != nil {
|
||||
t.Fatalf("Unexpected context value, got %v instead of nil", result)
|
||||
}
|
||||
|
||||
session := &model.WebSession{ID: "session-id"}
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserLanguageContextKey, "fr_FR")
|
||||
ctx = context.WithValue(ctx, WebSessionContextKey, session)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserLanguage(r)
|
||||
expected = "fr_FR"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTheme(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserTheme(r)
|
||||
expected := "system_serif"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserThemeContextKey, "dark_serif")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserTheme(r)
|
||||
expected = "dark_serif"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRF(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := CSRF(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, CSRFContextKey, "secret")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = CSRF(r)
|
||||
expected = "secret"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionID(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := SessionID(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, SessionIDContextKey, "id")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = SessionID(r)
|
||||
expected = "id"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserSessionToken(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserSessionToken(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserSessionTokenContextKey, "token")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserSessionToken(r)
|
||||
expected = "token"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuth2State(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := OAuth2State(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, OAuth2StateContextKey, "state")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = OAuth2State(r)
|
||||
expected = "state"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuth2CodeVerifier(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := OAuth2CodeVerifier(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, OAuth2CodeVerifierContextKey, "verifier")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = OAuth2CodeVerifier(r)
|
||||
expected = "verifier"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlashMessage(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := FlashMessage(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, FlashMessageContextKey, "message")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = FlashMessage(r)
|
||||
expected = "message"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlashErrorMessage(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := FlashErrorMessage(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, FlashErrorMessageContextKey, "error message")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = FlashErrorMessage(r)
|
||||
expected = "error message"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastForceRefresh(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := LastForceRefresh(r)
|
||||
expected := time.Time{}
|
||||
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "not-a-timestamp")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = LastForceRefresh(r)
|
||||
expected = time.Time{}
|
||||
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
ctx = r.Context()
|
||||
ctx = context.WithValue(ctx, LastForceRefreshContextKey, "1700000000")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = LastForceRefresh(r)
|
||||
expected = time.Unix(1700000000, 0)
|
||||
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnSessionData(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := WebAuthnSessionData(r)
|
||||
if result != nil {
|
||||
t.Errorf("Unexpected context value, got %v instead of nil", result)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, WebAuthnDataContextKey, "invalid")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = WebAuthnSessionData(r)
|
||||
if result != nil {
|
||||
t.Errorf("Unexpected context value, got %v instead of nil", result)
|
||||
}
|
||||
|
||||
session := model.WebAuthnSession{}
|
||||
ctx = r.Context()
|
||||
ctx = context.WithValue(ctx, WebAuthnDataContextKey, session)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = WebAuthnSessionData(r)
|
||||
if result == nil {
|
||||
t.Errorf("Unexpected context value, got nil instead of session")
|
||||
result := WebSession(r)
|
||||
if result == nil || result.ID != "session-id" {
|
||||
t.Fatalf("Unexpected context value, got %#v instead of session-id", result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,14 +25,14 @@ type Builder struct {
|
||||
w http.ResponseWriter
|
||||
r *http.Request
|
||||
statusCode int
|
||||
headers map[string]string
|
||||
headers http.Header
|
||||
enableCompression bool
|
||||
body any
|
||||
}
|
||||
|
||||
// NewBuilder creates a new response builder.
|
||||
func NewBuilder(w http.ResponseWriter, r *http.Request) *Builder {
|
||||
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}
|
||||
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(http.Header), enableCompression: true}
|
||||
}
|
||||
|
||||
// WithStatus uses the given status code to build the response.
|
||||
@@ -40,7 +43,7 @@ func (b *Builder) WithStatus(statusCode int) *Builder {
|
||||
|
||||
// WithHeader adds the given HTTP header to the response.
|
||||
func (b *Builder) WithHeader(key, value string) *Builder {
|
||||
b.headers[key] = value
|
||||
b.headers.Set(key, value)
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -64,7 +67,13 @@ func (b *Builder) WithBodyAsReader(body io.Reader) *Builder {
|
||||
|
||||
// WithAttachment forces the document to be downloaded by the web browser.
|
||||
func (b *Builder) WithAttachment(filename string) *Builder {
|
||||
b.headers["Content-Disposition"] = "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
|
||||
}
|
||||
|
||||
@@ -77,9 +86,11 @@ func (b *Builder) WithoutCompression() *Builder {
|
||||
// WithCaching adds caching headers to the response.
|
||||
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
|
||||
etag = normalizeETag(etag)
|
||||
b.headers["ETag"] = etag
|
||||
b.headers["Cache-Control"] = "public, immutable"
|
||||
b.headers["Expires"] = time.Now().Add(duration).UTC().Format(http.TimeFormat)
|
||||
b.headers.Set("ETag", etag)
|
||||
// max-age is required for the "immutable" directive to take effect: without
|
||||
// it, browsers still revalidate content-hashed assets on every reload.
|
||||
b.headers.Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", int64(duration.Seconds())))
|
||||
b.headers.Set("Expires", time.Now().Add(duration).UTC().Format(http.TimeFormat))
|
||||
|
||||
if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
|
||||
b.statusCode = http.StatusNotModified
|
||||
@@ -113,24 +124,22 @@ func (b *Builder) Write() {
|
||||
}
|
||||
|
||||
func (b *Builder) writeHeaders() {
|
||||
b.headers["X-Content-Type-Options"] = "nosniff"
|
||||
b.headers["X-Frame-Options"] = "DENY"
|
||||
b.headers["Referrer-Policy"] = "no-referrer"
|
||||
b.headers.Set("X-Content-Type-Options", "nosniff")
|
||||
b.headers.Set("X-Frame-Options", "DENY")
|
||||
b.headers.Set("Referrer-Policy", "no-referrer")
|
||||
|
||||
for key, value := range b.headers {
|
||||
b.w.Header().Set(key, value)
|
||||
}
|
||||
maps.Copy(b.w.Header(), b.headers)
|
||||
|
||||
b.w.WriteHeader(b.statusCode)
|
||||
}
|
||||
|
||||
func (b *Builder) compress(data []byte) {
|
||||
if b.enableCompression && len(data) > compressionThreshold {
|
||||
b.headers["Vary"] = "Accept-Encoding"
|
||||
b.headers.Set("Vary", "Accept-Encoding")
|
||||
acceptEncoding := b.r.Header.Get("Accept-Encoding")
|
||||
switch {
|
||||
case strings.Contains(acceptEncoding, "br"):
|
||||
b.headers["Content-Encoding"] = "br"
|
||||
b.headers.Set("Content-Encoding", "br")
|
||||
b.writeHeaders()
|
||||
|
||||
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
|
||||
@@ -138,7 +147,7 @@ func (b *Builder) compress(data []byte) {
|
||||
brotliWriter.Close()
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "gzip"):
|
||||
b.headers["Content-Encoding"] = "gzip"
|
||||
b.headers.Set("Content-Encoding", "gzip")
|
||||
b.writeHeaders()
|
||||
|
||||
gzipWriter := gzip.NewWriter(b.w)
|
||||
@@ -146,7 +155,7 @@ func (b *Builder) compress(data []byte) {
|
||||
gzipWriter.Close()
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "deflate"):
|
||||
b.headers["Content-Encoding"] = "deflate"
|
||||
b.headers.Set("Content-Encoding", "deflate")
|
||||
b.writeHeaders()
|
||||
|
||||
flateWriter, _ := flate.NewWriter(b.w, -1)
|
||||
@@ -181,3 +190,15 @@ func ifNoneMatch(headerValue, etag string) bool {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -105,6 +106,90 @@ func TestBuildResponseWithAttachment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithAttachmentEscapesFilename(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NewBuilder(w, r).WithAttachment(`a";filename="malware.exe`).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
actual := resp.Header.Get("Content-Disposition")
|
||||
mediaType, params, err := mime.ParseMediaType(actual)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parse error for %q: %v", actual, err)
|
||||
}
|
||||
|
||||
if mediaType != "attachment" {
|
||||
t.Fatalf(`Unexpected media type, got %q instead of %q`, mediaType, "attachment")
|
||||
}
|
||||
|
||||
if params["filename"] != `a";filename="malware.exe` {
|
||||
t.Fatalf(`Unexpected filename, got %q instead of %q`, params["filename"], `a";filename="malware.exe`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithInlineEscapesFilename(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NewBuilder(w, r).WithInline(`a";filename="malware.exe`).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
actual := resp.Header.Get("Content-Disposition")
|
||||
mediaType, params, err := mime.ParseMediaType(actual)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parse error for %q: %v", actual, err)
|
||||
}
|
||||
|
||||
if mediaType != "inline" {
|
||||
t.Fatalf(`Unexpected media type, got %q instead of %q`, mediaType, "inline")
|
||||
}
|
||||
|
||||
if params["filename"] != `a";filename="malware.exe` {
|
||||
t.Fatalf(`Unexpected filename, got %q instead of %q`, params["filename"], `a";filename="malware.exe`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatContentDisposition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dispositionType string
|
||||
filename string
|
||||
expected string
|
||||
}{
|
||||
{"empty filename returns bare type", "inline", "", "inline"},
|
||||
{"simple filename", "attachment", "photo.jpg", `attachment; filename=photo.jpg`},
|
||||
{"filename with double quote", "inline", `a";filename="malware.exe`, `inline; filename="a\";filename=\"malware.exe"`},
|
||||
{"filename with spaces", "attachment", "my file.txt", `attachment; filename="my file.txt"`},
|
||||
{"non-ASCII filename", "attachment", "café.png", `attachment; filename*=utf-8''caf%C3%A9.png`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual := formatContentDisposition(tt.dispositionType, tt.filename)
|
||||
if actual != tt.expected {
|
||||
t.Fatalf(`formatContentDisposition(%q, %q) = %q, want %q`, tt.dispositionType, tt.filename, actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithByteBody(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
@@ -155,7 +240,7 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedHeader := "public, immutable"
|
||||
expectedHeader := "public, max-age=60, immutable"
|
||||
actualHeader := resp.Header.Get("Cache-Control")
|
||||
if actualHeader != expectedHeader {
|
||||
t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
|
||||
@@ -212,7 +297,7 @@ func TestBuildResponseWithCachingAndIfNoneMatch(t *testing.T) {
|
||||
t.Fatalf(`Unexpected body, got %q instead of %q`, actual, tt.expectedBody)
|
||||
}
|
||||
|
||||
if resp.Header.Get("Cache-Control") != "public, immutable" {
|
||||
if resp.Header.Get("Cache-Control") != "public, max-age=60, immutable" {
|
||||
t.Fatalf(`Unexpected Cache-Control header: %q`, resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,24 +4,28 @@
|
||||
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)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder := NewBuilder(w, r).
|
||||
WithHeader("Content-Type", "text/html; charset=utf-8").
|
||||
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
|
||||
switch v := any(body).(type) {
|
||||
case []byte:
|
||||
builder.WithBodyAsBytes(v)
|
||||
builder = builder.WithBodyAsBytes(v)
|
||||
case string:
|
||||
builder.WithBodyAsString(v)
|
||||
builder = builder.WithBodyAsString(v)
|
||||
}
|
||||
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -40,13 +44,13 @@ func HTMLServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusInternalServerError)
|
||||
builder.WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent)
|
||||
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBodyAsString(html.EscapeString(err.Error()))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusInternalServerError).
|
||||
WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent).
|
||||
WithHeader("Content-Type", "text/plain; charset=utf-8").
|
||||
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
|
||||
WithBodyAsString(html.EscapeString(err.Error())).
|
||||
Write()
|
||||
}
|
||||
|
||||
// HTMLBadRequest sends a bad request error to the client.
|
||||
@@ -64,13 +68,13 @@ func HTMLBadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusBadRequest)
|
||||
builder.WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent)
|
||||
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBodyAsString(html.EscapeString(err.Error()))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusBadRequest).
|
||||
WithHeader("Content-Security-Policy", ContentSecurityPolicyForUntrustedContent).
|
||||
WithHeader("Content-Type", "text/plain; charset=utf-8").
|
||||
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
|
||||
WithBodyAsString(html.EscapeString(err.Error())).
|
||||
Write()
|
||||
}
|
||||
|
||||
// HTMLForbidden sends a forbidden error to the client.
|
||||
@@ -87,12 +91,12 @@ func HTMLForbidden(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusForbidden)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBodyAsString("Access Forbidden")
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusForbidden).
|
||||
WithHeader("Content-Type", "text/html; charset=utf-8").
|
||||
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
|
||||
WithBodyAsString("Access Forbidden").
|
||||
Write()
|
||||
}
|
||||
|
||||
// HTMLNotFound sends a page not found error to the client.
|
||||
@@ -109,16 +113,20 @@ func HTMLNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusNotFound)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBodyAsString("Page Not Found")
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusNotFound).
|
||||
WithHeader("Content-Type", "text/html; charset=utf-8").
|
||||
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
|
||||
WithBodyAsString("Page Not Found").
|
||||
Write()
|
||||
}
|
||||
|
||||
// HTMLRedirect redirects the user to another location.
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -136,11 +144,11 @@ func HTMLRequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, co
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusRequestedRangeNotSatisfiable)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithHeader("Content-Range", contentRange)
|
||||
builder.WithBodyAsString("Range Not Satisfiable")
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusRequestedRangeNotSatisfiable).
|
||||
WithHeader("Content-Type", "text/html; charset=utf-8").
|
||||
WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store").
|
||||
WithHeader("Content-Range", contentRange).
|
||||
WithBodyAsString("Range Not Satisfiable").
|
||||
Write()
|
||||
}
|
||||
|
||||
@@ -183,6 +183,78 @@ func TestHTMLRedirectResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -22,10 +22,10 @@ func JSON(w http.ResponseWriter, r *http.Request, body any) {
|
||||
return
|
||||
}
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(responseBody)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(responseBody).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONCreated sends a created response to the client.
|
||||
@@ -36,19 +36,19 @@ func JSONCreated(w http.ResponseWriter, r *http.Request, body any) {
|
||||
return
|
||||
}
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusCreated)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(responseBody)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusCreated).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(responseBody).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONAccepted sends an accepted response to the client.
|
||||
func JSONAccepted(w http.ResponseWriter, r *http.Request) {
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusAccepted)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusAccepted).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONServerError sends an internal error to the client.
|
||||
@@ -66,11 +66,11 @@ func JSONServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusInternalServerError)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(generateJSONError(err))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusInternalServerError).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(err)).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONBadRequest sends a bad request error to the client.
|
||||
@@ -88,11 +88,11 @@ func JSONBadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusBadRequest)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(generateJSONError(err))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusBadRequest).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(err)).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONUnauthorized sends a not authorized error to the client.
|
||||
@@ -109,11 +109,11 @@ func JSONUnauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusUnauthorized)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(generateJSONError(errors.New("access unauthorized")))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusUnauthorized).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(errors.New("access unauthorized"))).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONForbidden sends a forbidden error to the client.
|
||||
@@ -130,11 +130,11 @@ func JSONForbidden(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusForbidden)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(generateJSONError(errors.New("access forbidden")))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusForbidden).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(errors.New("access forbidden"))).
|
||||
Write()
|
||||
}
|
||||
|
||||
// JSONNotFound sends a page not found error to the client.
|
||||
@@ -151,11 +151,11 @@ func JSONNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusNotFound)
|
||||
builder.WithHeader("Content-Type", jsonContentTypeHeader)
|
||||
builder.WithBodyAsBytes(generateJSONError(errors.New("resource not found")))
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusNotFound).
|
||||
WithHeader("Content-Type", jsonContentTypeHeader).
|
||||
WithBodyAsBytes(generateJSONError(errors.New("resource not found"))).
|
||||
Write()
|
||||
}
|
||||
|
||||
func generateJSONError(err error) []byte {
|
||||
|
||||
@@ -17,7 +17,7 @@ const ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-actio
|
||||
|
||||
// NoContent sends a no content response to the client.
|
||||
func NoContent(w http.ResponseWriter, r *http.Request) {
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusNoContent)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithStatus(http.StatusNoContent).
|
||||
Write()
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import "net/http"
|
||||
|
||||
// Text writes a standard text response with a status 200 OK.
|
||||
func Text(w http.ResponseWriter, r *http.Request, body string) {
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithHeader("Content-Type", `text/plain; charset=utf-8`)
|
||||
builder.WithBodyAsString(body)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithHeader("Content-Type", `text/plain; charset=utf-8`).
|
||||
WithBodyAsString(body).
|
||||
Write()
|
||||
}
|
||||
|
||||
@@ -7,17 +7,17 @@ import "net/http"
|
||||
|
||||
// XML writes a standard XML response with a status 200 OK.
|
||||
func XML(w http.ResponseWriter, r *http.Request, body string) {
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
|
||||
builder.WithBodyAsString(body)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithHeader("Content-Type", "text/xml; charset=utf-8").
|
||||
WithBodyAsString(body).
|
||||
Write()
|
||||
}
|
||||
|
||||
// XMLAttachment forces the XML document to be downloaded by the web browser.
|
||||
func XMLAttachment(w http.ResponseWriter, r *http.Request, filename string, body string) {
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
|
||||
builder.WithAttachment(filename)
|
||||
builder.WithBodyAsString(body)
|
||||
builder.Write()
|
||||
NewBuilder(w, r).
|
||||
WithHeader("Content-Type", "text/xml; charset=utf-8").
|
||||
WithAttachment(filename).
|
||||
WithBodyAsString(body).
|
||||
Write()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
@@ -54,7 +55,11 @@ func isAllowedToAccessMetricsEndpoint(r *http.Request) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
if username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() {
|
||||
// Both checks have to be run to avoid leaking informations
|
||||
// about the username and the password.
|
||||
usernameCorrect := crypto.ConstantTimeCmp(username, config.Opts.MetricsUsername())
|
||||
passwordCorrect := crypto.ConstantTimeCmp(password, config.Opts.MetricsPassword())
|
||||
if !usernameCorrect || !passwordCorrect {
|
||||
slog.Warn("Metrics endpoint accessed with invalid username or password",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
|
||||
@@ -41,11 +41,12 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
|
||||
for _, t := range targets {
|
||||
srv := &http.Server{
|
||||
Addr: t.address,
|
||||
ReadTimeout: config.Opts.HTTPServerTimeout(),
|
||||
WriteTimeout: config.Opts.HTTPServerTimeout(),
|
||||
IdleTimeout: config.Opts.HTTPServerTimeout(),
|
||||
Handler: newRouter(store, pool),
|
||||
Addr: t.address,
|
||||
ReadTimeout: config.Opts.HTTPServerTimeout(),
|
||||
WriteTimeout: config.Opts.HTTPServerTimeout(),
|
||||
IdleTimeout: config.Opts.HTTPServerTimeout(),
|
||||
ReadHeaderTimeout: config.Opts.HTTPServerTimeout(),
|
||||
Handler: newRouter(store, pool),
|
||||
}
|
||||
|
||||
switch t.mode {
|
||||
|
||||
@@ -72,7 +72,7 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
|
||||
@@ -84,7 +84,7 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
|
||||
@@ -58,7 +58,7 @@ func (c *Client) attachTags(entryID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
tagItems := make([]tagItem, 0)
|
||||
tagItems := make([]tagItem, 0, strings.Count(c.tags, ",")+1)
|
||||
for tag := range strings.SplitSeq(c.tags, ",") {
|
||||
if trimmedTag := strings.TrimSpace(tag); trimmedTag != "" {
|
||||
tagItems = append(tagItems, tagItem{TagName: trimmedTag})
|
||||
|
||||
@@ -88,7 +88,7 @@ func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
|
||||
@@ -94,7 +94,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, ta
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("wallabag: unable to get save entry: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
return fmt.Errorf("wallabag: unable to save entry: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -201,7 +201,7 @@ func TestCreateEntry(t *testing.T) {
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
},
|
||||
errContains: "unable to get save entry",
|
||||
errContains: "unable to save entry",
|
||||
},
|
||||
{
|
||||
name: "failure due to no accessToken",
|
||||
|
||||
@@ -111,6 +111,7 @@ func TestTranslationFilePluralForms(t *testing.T) {
|
||||
"id_ID": 1,
|
||||
"it_IT": 2,
|
||||
"ja_JP": 1,
|
||||
"ko_KR": 1,
|
||||
"nan_Latn_pehoeji": 1,
|
||||
"nl_NL": 2,
|
||||
"pl_PL": 3,
|
||||
|
||||
@@ -74,9 +74,9 @@ func TestLocalizedErrorWrapper_Translate(t *testing.T) {
|
||||
t.Errorf("Expected French translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
// Test with missing language (should use key as fallback with args applied)
|
||||
// Test with missing language (should fall back to the untranslated key)
|
||||
result = wrapper.Translate("invalid_lang")
|
||||
expected = "error.test_key%!(EXTRA string=test message, int=404)"
|
||||
expected = "error.test_key"
|
||||
if result != expected {
|
||||
t.Errorf("Expected fallback translation %q, got %q", expected, result)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func TestLocalizedError_StringWithMissingTranslation(t *testing.T) {
|
||||
localizedErr := NewLocalizedError("error.missing", "arg1")
|
||||
|
||||
result := localizedErr.String()
|
||||
expected := "error.missing%!(EXTRA string=arg1)"
|
||||
expected := "error.missing"
|
||||
if result != expected {
|
||||
t.Errorf("Expected String() result %q, got %q", expected, result)
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func TestLocalizedError_Translate(t *testing.T) {
|
||||
|
||||
// Test with missing language
|
||||
result = localizedErr.Translate("invalid_lang")
|
||||
expected = "error.permission%!(EXTRA string=admin panel)"
|
||||
expected = "error.permission"
|
||||
if result != expected {
|
||||
t.Errorf("Expected fallback translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ var AvailableLanguages = map[string]string{
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"ko_KR": "한국어",
|
||||
"nan_Latn_pehoeji": "Pe̍h-ōe-jī",
|
||||
"nl_NL": "Nederlands",
|
||||
"pl_PL": "Polski",
|
||||
|
||||
@@ -36,7 +36,7 @@ func getPluralForm(lang string, n int) int {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "id_ID", "ja_JP":
|
||||
case "id_ID", "ja_JP", "ko_KR":
|
||||
return 0
|
||||
case "pl_PL":
|
||||
switch {
|
||||
|
||||
@@ -66,6 +66,14 @@ func TestPluralRules(t *testing.T) {
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Korean (ko_KR) - always form 0
|
||||
"ko_KR": {
|
||||
0: 0,
|
||||
1: 0,
|
||||
2: 0,
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Polish (pl_PL) - 3 forms
|
||||
"pl_PL": {
|
||||
1: 0, // n == 1
|
||||
|
||||
@@ -26,7 +26,7 @@ func (p *Printer) Print(key string) string {
|
||||
|
||||
// Printf is like fmt.Printf, but using language-specific formatting.
|
||||
func (p *Printer) Printf(key string, args ...any) string {
|
||||
return fmt.Sprintf(p.Print(key), args...)
|
||||
return formatTranslation(p.Print(key), args...)
|
||||
}
|
||||
|
||||
// Plural returns the translation of the given key by using the language plural form.
|
||||
@@ -39,9 +39,39 @@ func (p *Printer) Plural(key string, n int, args ...any) string {
|
||||
if choices, found := dict.plurals[key]; found {
|
||||
index := getPluralForm(p.language, n)
|
||||
if len(choices) > index {
|
||||
return fmt.Sprintf(choices[index], args...)
|
||||
return formatTranslation(choices[index], args...)
|
||||
}
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// formatTranslation skips extra arguments when the translation references no argument,
|
||||
// so plural forms that omit the count (e.g. the Arabic dual "دقيقتين") don't get
|
||||
// a trailing %!(EXTRA ...) marker. Escaped percents are still processed by fmt.
|
||||
func formatTranslation(format string, args ...any) string {
|
||||
if !hasFormattingDirective(format) {
|
||||
return fmt.Sprintf(format, []any{}...)
|
||||
}
|
||||
return fmt.Sprintf(format, args...)
|
||||
}
|
||||
|
||||
// hasFormattingDirective reports whether the format should be handled with the
|
||||
// supplied arguments. It treats "%%" as a literal percent and lets fmt validate
|
||||
// any other percent sequence, including a dangling "%".
|
||||
func hasFormattingDirective(format string) bool {
|
||||
for index := 0; index < len(format); index++ {
|
||||
if format[index] != '%' {
|
||||
continue
|
||||
}
|
||||
if index+1 >= len(format) {
|
||||
return true
|
||||
}
|
||||
if format[index+1] == '%' {
|
||||
index++ // skip the escaped percent
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -354,3 +354,59 @@ func TestPluralWithVariousLanguageRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralFormWithoutPlaceholder(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"ar_SA": translationDict{
|
||||
plurals: map[string][]string{
|
||||
// The Arabic dual omits the count by design.
|
||||
"minutes": {"%d دقيقة", "دقيقة واحدة", "دقيقتين", "%d دقائق", "%d دقيقة", "%d دقيقة"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
printer := NewPrinter("ar_SA")
|
||||
|
||||
if got := printer.Plural("minutes", 1, 1); got != "دقيقة واحدة" {
|
||||
t.Errorf(`Plural form should not get an EXTRA marker, got %q`, got)
|
||||
}
|
||||
if got := printer.Plural("minutes", 2, 2); got != "دقيقتين" {
|
||||
t.Errorf(`Plural form should not get an EXTRA marker, got %q`, got)
|
||||
}
|
||||
if got := printer.Plural("minutes", 5, 5); got != "5 دقائق" {
|
||||
t.Errorf(`Plural form with placeholder should be formatted, got %q`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintfUnescapesLiteralPercentWithoutArgs(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
singulars: map[string]string{
|
||||
"media.completion": "Mark as read at 90%% completion",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := NewPrinter("en_US").Printf("media.completion")
|
||||
expected := "Mark as read at 90% completion"
|
||||
if got != expected {
|
||||
t.Errorf(`Escaped percent should be unescaped, got %q instead of %q`, got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFormattingDirective(t *testing.T) {
|
||||
tests := map[string]bool{
|
||||
"دقيقتين": false,
|
||||
"%d دقيقة": true,
|
||||
"90%% done": false, // escaped percent consumes no argument
|
||||
"%d of %s": true,
|
||||
"": false,
|
||||
"%": true,
|
||||
}
|
||||
|
||||
for format, expected := range tests {
|
||||
if got := hasFormattingDirective(format); got != expected {
|
||||
t.Errorf(`hasFormattingDirective(%q) = %v, want %v`, format, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
"error.http_bad_gateway": "الموقع غير متاح حالياً بسبب خطأ في البوابة (Bad Gateway). المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
|
||||
"error.http_body_read": "تعذر قراءة محتوى استجابة HTTP: %v.",
|
||||
"error.http_client_error": "خطأ في عميل HTTP: %v.",
|
||||
"error.http_cloudflare_challenge": "هذا الموقع محمي بآلية تحدي Cloudflare (اختبار CAPTCHA أو التحقق عبر JavaScript). لا يستطيع Miniflux حل هذا التحدي تلقائياً.",
|
||||
"error.http_empty_response": "استجابة HTTP فارغة. ربما يستخدم هذا الموقع آلية حماية ضد الروبوتات؟",
|
||||
"error.http_empty_response_body": "محتوى استجابة HTTP فارغ.",
|
||||
"error.http_forbidden": "الوصول إلى هذا الموقع ممنوع. ربما يوجد آلية حماية ضد الروبوتات؟",
|
||||
@@ -355,7 +356,9 @@
|
||||
"form.integration.webhook_secret": "سر Webhooks",
|
||||
"form.integration.webhook_url": "رابط Webhook الافتراضي",
|
||||
"form.prefs.fieldset.application_settings": "إعدادات التطبيق",
|
||||
"form.prefs.fieldset.authentication_settings": "إعدادات المصادقة",
|
||||
"form.prefs.fieldset.authentication_settings": "مصادقة كلمة المرور",
|
||||
"form.prefs.fieldset.google_authentication": "مصادقة Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "مصادقة %s",
|
||||
"form.prefs.fieldset.global_feed_settings": "إعدادات المصادر العامة",
|
||||
"form.prefs.fieldset.reader_settings": "إعدادات القارئ",
|
||||
"form.prefs.help.external_font_hosts": "قائمة مفصولة بمسافات لمضيفي الخطوط الخارجية للسماح بها. مثال: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
@@ -437,7 +440,8 @@
|
||||
"menu.title": "القائمة",
|
||||
"menu.unread": "غير مقروء",
|
||||
"menu.users": "المستخدمون",
|
||||
"page.about.author": "المؤلف:",
|
||||
"page.about.authors_label": "المؤلفون:",
|
||||
"page.about.authors_value": "Frédéric Guillot والمساهمون",
|
||||
"page.about.build_date": "تاريخ البناء:",
|
||||
"page.about.credits": "شكر وتقدير",
|
||||
"page.about.db_usage": "حجم قاعدة البيانات:",
|
||||
@@ -557,7 +561,6 @@
|
||||
"page.login.title": "تسجيل الدخول",
|
||||
"page.login.webauthn_login": "تسجيل الدخول عبر مفتاح مرور (Passkey)",
|
||||
"page.login.webauthn_login.error": "تعذر تسجيل الدخول باستخدام مفتاح المرور",
|
||||
"page.login.webauthn_login.help": "يرجى إدخال اسم المستخدم إذا كنت تستخدم مفتاح أمان. هذا غير مطلوب إذا كنت تستخدم مفتاح مرور (بيانات اعتماد قابلة للاكتشاف).",
|
||||
"page.new_api_key.title": "مفتاح API جديد",
|
||||
"page.new_category.title": "فئة جديدة",
|
||||
"page.new_user.title": "مستخدم جديد",
|
||||
@@ -596,7 +599,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "آخر استخدام",
|
||||
"page.settings.webauthn.passkey_name": "اسم مفتاح المرور",
|
||||
"page.settings.webauthn.passkeys": "مفاتيح المرور",
|
||||
"page.settings.webauthn.passkeys": "مصادقة مفاتيح المرور",
|
||||
"page.settings.webauthn.register": "تسجيل مفتاح مرور",
|
||||
"page.settings.webauthn.register.error": "تعذر تسجيل مفتاح المرور",
|
||||
"page.shared_entries.title": "المقالات المشاركة",
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"error.http_bad_gateway": "Die Webseite ist aufgrund eines Bad-Gateway-Fehlers derzeit nicht verfügbar. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.",
|
||||
"error.http_body_read": "Der HTTP-Inhalt kann nicht gelesen werden: %v",
|
||||
"error.http_client_error": "HTTP-Client-Fehler: %v.",
|
||||
"error.http_cloudflare_challenge": "Diese Webseite ist durch eine Cloudflare-Bot-Abfrage (CAPTCHA oder JavaScript-Verifizierung) geschützt. Miniflux kann diese Abfrage nicht automatisch lösen.",
|
||||
"error.http_empty_response": "Die HTTP-Antwort ist leer. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
|
||||
"error.http_empty_response_body": "Der Inhalt der HTTP-Antwort ist leer.",
|
||||
"error.http_forbidden": "Der Zugriff auf diese Webseite ist verboten. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
|
||||
@@ -175,7 +176,7 @@
|
||||
"form.feed.label.category": "Kategorie",
|
||||
"form.feed.label.cookie": "Cookies setzen",
|
||||
"form.feed.label.crawler": "Originalinhalt herunterladen",
|
||||
"form.feed.label.ignore_entry_updates": "Ignore entry updates",
|
||||
"form.feed.label.ignore_entry_updates": "Updates ignorieren",
|
||||
"form.feed.label.description": "Beschreibung",
|
||||
"form.feed.label.disable_http2": "HTTP/2 deaktivieren, um Fingerprinting zu verhindern",
|
||||
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
|
||||
@@ -185,7 +186,7 @@
|
||||
"form.feed.label.fetch_via_proxy": "Den auf Anwendungsebene konfigurierten Proxy verwenden",
|
||||
"form.feed.label.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
|
||||
"form.feed.label.keep_filter_entry_rules": "Eintrags-Erlaubnisregeln",
|
||||
"form.feed.label.keep_filter_entry_rules": "Erlaubnisregeln",
|
||||
"form.feed.label.keeplist_rules": "Regex-basierte Behalte-Filter",
|
||||
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
|
||||
"form.feed.label.ntfy_activate": "Artikel zu ntfy pushen",
|
||||
@@ -343,7 +344,9 @@
|
||||
"form.integration.webhook_secret": "Webhook-Geheimnis",
|
||||
"form.integration.webhook_url": "Standard-Webhook-URL",
|
||||
"form.prefs.fieldset.application_settings": "Anwendungseinstellungen",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
|
||||
"form.prefs.fieldset.authentication_settings": "Passwort-Authentifizierung",
|
||||
"form.prefs.fieldset.google_authentication": "Google-Authentifizierung",
|
||||
"form.prefs.fieldset.oidc_authentication": "%s-Authentifizierung",
|
||||
"form.prefs.fieldset.global_feed_settings": "Globale Feedeinstellungen",
|
||||
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
|
||||
"form.prefs.help.external_font_hosts": "Per Leerzeichen getrennte Liste externer Schriftarten-Hosts, die erlaubt werden sollen. Beispiel: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "Menü",
|
||||
"menu.unread": "Ungelesen",
|
||||
"menu.users": "Benutzer",
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.authors_label": "Autoren:",
|
||||
"page.about.authors_value": "Frédéric Guillot und Mitwirkende",
|
||||
"page.about.build_date": "Datum der Kompilierung:",
|
||||
"page.about.credits": "Urheberrechte",
|
||||
"page.about.db_usage": "Datenbankgröße:",
|
||||
@@ -533,7 +537,6 @@
|
||||
"page.login.title": "Anmeldung",
|
||||
"page.login.webauthn_login": "Melden Sie sich mit dem Passkey an",
|
||||
"page.login.webauthn_login.error": "Anmeldung mit Passkey nicht möglich",
|
||||
"page.login.webauthn_login.help": "Bitte geben Sie Ihren Benutzernamen ein, sofern Sie einen Sicherheitsschlüssel verwenden. Dies ist nicht nötig, wenn Sie einen Passkey verwenden (auffindbare Anmeldeinformationen).",
|
||||
"page.new_api_key.title": "Neuer API-Schlüssel",
|
||||
"page.new_category.title": "Neue Kategorie",
|
||||
"page.new_user.title": "Neuer Benutzer",
|
||||
@@ -564,7 +567,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Zuletzt genutzt",
|
||||
"page.settings.webauthn.passkey_name": "Name des Passkeys",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.passkeys": "Passkey-Authentifizierung",
|
||||
"page.settings.webauthn.register": "Hauptschlüssel registrieren",
|
||||
"page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden",
|
||||
"page.shared_entries.title": "Geteilte Artikel",
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"error.http_bad_gateway": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος κακής πύλης. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
|
||||
"error.http_body_read": "Δεν είναι δυνατή η ανάγνωση του σώματος HTTP: %v.",
|
||||
"error.http_client_error": "Σφάλμα πελάτη HTTP: %v.",
|
||||
"error.http_cloudflare_challenge": "Αυτός ο ιστότοπος προστατεύεται από πρόκληση bot του Cloudflare (CAPTCHA ή επαλήθευση JavaScript). Το Miniflux δεν μπορεί να επιλύσει αυτήν την πρόκληση αυτόματα.",
|
||||
"error.http_empty_response": "Η απάντηση HTTP είναι κενή. Ίσως αυτός ο ιστότοπος χρησιμοποιεί μηχανισμό προστασίας από bot;",
|
||||
"error.http_empty_response_body": "Το σώμα απάντησης HTTP είναι κενό.",
|
||||
"error.http_forbidden": "Η πρόσβαση σε αυτόν τον ιστότοπο απαγορεύεται. Ίσως αυτός ο ιστότοπος διαθέτει μηχανισμό προστασίας από bot;",
|
||||
@@ -343,7 +344,9 @@
|
||||
"form.integration.webhook_secret": "Μυστικό Webhooks",
|
||||
"form.integration.webhook_url": "Προεπιλεγμένη διεύθυνση URL Webhook",
|
||||
"form.prefs.fieldset.application_settings": "Ρυθμίσεις εφαρμογής",
|
||||
"form.prefs.fieldset.authentication_settings": "Ρυθμίσεις ελέγχου ταυτότητας",
|
||||
"form.prefs.fieldset.authentication_settings": "Έλεγχος ταυτότητας με κωδικό",
|
||||
"form.prefs.fieldset.google_authentication": "Έλεγχος ταυτότητας Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "Έλεγχος ταυτότητας %s",
|
||||
"form.prefs.fieldset.global_feed_settings": "Καθολικές ρυθμίσεις ροής",
|
||||
"form.prefs.fieldset.reader_settings": "Ρυθμίσεις αναγνώστη",
|
||||
"form.prefs.help.external_font_hosts": "Λίστα εξωτερικών κεντρικών υπολογιστών γραμματοσειρών διαχωρισμένων με κενό για να επιτρέπονται. Για παράδειγμα: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "Μενού",
|
||||
"menu.unread": "Μη αναγνωσμένα",
|
||||
"menu.users": "Χρήστες",
|
||||
"page.about.author": "Συγγραφέας:",
|
||||
"page.about.authors_label": "Συγγραφείς:",
|
||||
"page.about.authors_value": "Frédéric Guillot και συνεισφέροντες",
|
||||
"page.about.build_date": "Ημερομηνία Κατασκευής:",
|
||||
"page.about.credits": "Συνεισφέροντες",
|
||||
"page.about.db_usage": "Μέγεθος βάσης δεδομένων:",
|
||||
@@ -533,7 +537,6 @@
|
||||
"page.login.title": "Είσοδος",
|
||||
"page.login.webauthn_login": "Είσοδος με κωδικό πρόσβασης",
|
||||
"page.login.webauthn_login.error": "Δεν είναι δυνατή η σύνδεση με κωδικό πρόσβασης",
|
||||
"page.login.webauthn_login.help": "Παρακαλώ εισαγάγετε το όνομα χρήστη σας εάν χρησιμοποιείτε κλειδί ασφαλείας. Αυτό δεν απαιτείται εάν χρησιμοποιείτε Passkey (ανακαλύψιμα διαπιστευτήρια).",
|
||||
"page.new_api_key.title": "Νέο κλειδί API",
|
||||
"page.new_category.title": "Νέα Κατηγορία",
|
||||
"page.new_user.title": "Νέος Χρήστης",
|
||||
@@ -564,7 +567,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Τελευταία χρήση",
|
||||
"page.settings.webauthn.passkey_name": "Όνομα κωδικού πρόσβασης",
|
||||
"page.settings.webauthn.passkeys": "Κωδικοί πρόσβασης",
|
||||
"page.settings.webauthn.passkeys": "Έλεγχος ταυτότητας με κωδικό πρόσβασης",
|
||||
"page.settings.webauthn.register": "Εγγραφή κωδικού πρόσβασης",
|
||||
"page.settings.webauthn.register.error": "Δεν είναι δυνατή η εγγραφή του κωδικού πρόσβασης",
|
||||
"page.shared_entries.title": "Κοινόχρηστες Καταχωρήσεις",
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_cloudflare_challenge": "This website is protected by a Cloudflare bot challenge (CAPTCHA or JavaScript verification). Miniflux cannot solve this challenge automatically.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
@@ -343,7 +344,9 @@
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Password Authentication",
|
||||
"form.prefs.fieldset.google_authentication": "Google Authentication",
|
||||
"form.prefs.fieldset.oidc_authentication": "%s Authentication",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "Menu",
|
||||
"menu.unread": "Unread",
|
||||
"menu.users": "Users",
|
||||
"page.about.author": "Author:",
|
||||
"page.about.authors_label": "Authors:",
|
||||
"page.about.authors_value": "Frédéric Guillot and contributors",
|
||||
"page.about.build_date": "Build Date:",
|
||||
"page.about.credits": "Credits",
|
||||
"page.about.db_usage": "Database size:",
|
||||
@@ -533,7 +537,6 @@
|
||||
"page.login.title": "Sign In",
|
||||
"page.login.webauthn_login": "Login with passkey",
|
||||
"page.login.webauthn_login.error": "Unable to login with passkey",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.new_api_key.title": "New API Key",
|
||||
"page.new_category.title": "New Category",
|
||||
"page.new_user.title": "New User",
|
||||
@@ -564,7 +567,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.passkeys": "Passkey Authentication",
|
||||
"page.settings.webauthn.register": "Register passkey",
|
||||
"page.settings.webauthn.register.error": "Unable to register passkey",
|
||||
"page.shared_entries.title": "Shared entries",
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"error.http_bad_gateway": "El sitio web no está disponible en este momento debido a un error en la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_body_read": "Imposible leer el cuerpo HTTP: %v.",
|
||||
"error.http_client_error": "Error cliente HTTP: %v.",
|
||||
"error.http_cloudflare_challenge": "Este sitio web está protegido por un desafío de bot de Cloudflare (CAPTCHA o verificación de JavaScript). Miniflux no puede resolver este desafío automáticamente.",
|
||||
"error.http_empty_response": "La respuesta HTTP está vacía. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
|
||||
"error.http_empty_response_body": "El cuerpo de la respuesta HTTP está vacío.",
|
||||
"error.http_forbidden": "El acceso a este sitio web está prohibido. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
|
||||
@@ -343,7 +344,9 @@
|
||||
"form.integration.webhook_secret": "Secreto de Webhooks",
|
||||
"form.integration.webhook_url": "Defecto URL de Webhook",
|
||||
"form.prefs.fieldset.application_settings": "Ajustes de la aplicación",
|
||||
"form.prefs.fieldset.authentication_settings": "Ajustes de la autentificación",
|
||||
"form.prefs.fieldset.authentication_settings": "Autenticación con contraseña",
|
||||
"form.prefs.fieldset.google_authentication": "Autenticación con Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "Autenticación con %s",
|
||||
"form.prefs.fieldset.global_feed_settings": "Ajustes globales del feed",
|
||||
"form.prefs.fieldset.reader_settings": "Ajustes del lector",
|
||||
"form.prefs.help.external_font_hosts": "Lista separada por espacios de hosts de fuentes externas permitidos. Por ejemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "Menú",
|
||||
"menu.unread": "No leídos",
|
||||
"menu.users": "Usuarios",
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.authors_label": "Autores:",
|
||||
"page.about.authors_value": "Frédéric Guillot y colaboradores",
|
||||
"page.about.build_date": "Fecha de compilación:",
|
||||
"page.about.credits": "Créditos",
|
||||
"page.about.db_usage": "Tamaño de la base de datos:",
|
||||
@@ -533,7 +537,6 @@
|
||||
"page.login.title": "Iniciar sesión",
|
||||
"page.login.webauthn_login": "Iniciar sesión con clave de acceso",
|
||||
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de acceso",
|
||||
"page.login.webauthn_login.help": "Por favor, introduce tu nombre de usuario si usas una clave de seguridad. Esto no es necesario si usas una Passkey (credenciales detectables).",
|
||||
"page.new_api_key.title": "Nueva clave API",
|
||||
"page.new_category.title": "Nueva categoría",
|
||||
"page.new_user.title": "Nuevo usuario",
|
||||
@@ -564,7 +567,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Usado por última vez",
|
||||
"page.settings.webauthn.passkey_name": "Nombre de clave de acceso",
|
||||
"page.settings.webauthn.passkeys": "Claves de acceso",
|
||||
"page.settings.webauthn.passkeys": "Autenticación con clave de acceso",
|
||||
"page.settings.webauthn.register": "Registrar clave de acceso",
|
||||
"page.settings.webauthn.register.error": "No se puede registrar la clave de acceso",
|
||||
"page.shared_entries.title": "Artículos compartidos",
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"error.http_bad_gateway": "Verkkosivusto ei ole tällä hetkellä saatavilla huonon yhdyskäytävän virheen vuoksi. Ongelma ei ole Miniflux-puolella. Yritä uudelleen myöhemmin.",
|
||||
"error.http_body_read": "HTTP-rungon lukeminen epäonnistui: %v.",
|
||||
"error.http_client_error": "HTTP-asiakasvirhe: %v.",
|
||||
"error.http_cloudflare_challenge": "Tämä sivusto on suojattu Cloudflaren bottihaasteella (CAPTCHA tai JavaScript-todennus). Miniflux ei voi ratkaista tätä haastetta automaattisesti.",
|
||||
"error.http_empty_response": "HTTP-vastaus on tyhjä. Sivusto saattaa käyttää bottisuojausta?",
|
||||
"error.http_empty_response_body": "HTTP-vastauksen runko on tyhjä.",
|
||||
"error.http_forbidden": "Pääsy tälle sivustolle on kielletty. Sivustolla saattaa olla bottisuojaus?",
|
||||
@@ -343,7 +344,9 @@
|
||||
"form.integration.webhook_secret": "Webhookien salaisuus",
|
||||
"form.integration.webhook_url": "Oletus-webhook-URL",
|
||||
"form.prefs.fieldset.application_settings": "Sovellusasetukset",
|
||||
"form.prefs.fieldset.authentication_settings": "Todennusasetukset",
|
||||
"form.prefs.fieldset.authentication_settings": "Salasanatodennus",
|
||||
"form.prefs.fieldset.google_authentication": "Google-todennus",
|
||||
"form.prefs.fieldset.oidc_authentication": "%s-todennus",
|
||||
"form.prefs.fieldset.global_feed_settings": "Syötteiden yleisasetukset",
|
||||
"form.prefs.fieldset.reader_settings": "Lukija-asetukset",
|
||||
"form.prefs.help.external_font_hosts": "Sallittujen ulkoisten fonttipalvelinten lista välilyönnein eroteltuna. Esimerkiksi: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "Valikko",
|
||||
"menu.unread": "Lukemattomat",
|
||||
"menu.users": "Käyttäjät",
|
||||
"page.about.author": "Tekijä:",
|
||||
"page.about.authors_label": "Tekijät:",
|
||||
"page.about.authors_value": "Frédéric Guillot ja avustajat",
|
||||
"page.about.build_date": "Valmistuspäivä:",
|
||||
"page.about.credits": "Kiitokset",
|
||||
"page.about.db_usage": "Tietokannan koko:",
|
||||
@@ -533,7 +537,6 @@
|
||||
"page.login.title": "Kirjaudu sisään",
|
||||
"page.login.webauthn_login": "Kirjaudu sisään salasanalla",
|
||||
"page.login.webauthn_login.error": "Ei voida kirjautua sisään salasanalla",
|
||||
"page.login.webauthn_login.help": "Jos käytät turva-avainta, kirjoita käyttäjätunnus. Passkeytä käyttäessä tämä ei ole tarpeen.",
|
||||
"page.new_api_key.title": "Uusi API-avain",
|
||||
"page.new_category.title": "Uusi kategoria",
|
||||
"page.new_user.title": "Uusi käyttäjä",
|
||||
@@ -564,7 +567,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Viimeksi käytetty",
|
||||
"page.settings.webauthn.passkey_name": "Passkey-nimi",
|
||||
"page.settings.webauthn.passkeys": "Passkeyt",
|
||||
"page.settings.webauthn.passkeys": "Passkey-todennus",
|
||||
"page.settings.webauthn.register": "Rekisteröi salasana",
|
||||
"page.settings.webauthn.register.error": "Salasanaa ei voi rekisteröidä",
|
||||
"page.shared_entries.title": "Jaetut artikkelit",
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"error.http_bad_gateway": "Le site web n'est pas disponible pour le moment à cause d'une erreur de passerelle réseau. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.",
|
||||
"error.http_body_read": "Impossible de lire le corps de la réponse HTTP : %v.",
|
||||
"error.http_client_error": "Erreur du client HTTP : %v.",
|
||||
"error.http_cloudflare_challenge": "Ce site web est protégé par un défi anti-bot Cloudflare (CAPTCHA ou vérification JavaScript). Miniflux ne peut pas résoudre ce défi automatiquement.",
|
||||
"error.http_empty_response": "La réponse HTTP est vide. Peut-être que ce site web bloque Miniflux avec une protection anti-bot ?",
|
||||
"error.http_empty_response_body": "Le corps de la réponse HTTP est vide.",
|
||||
"error.http_forbidden": "Accès interdit à ce site web. Il se peut que ce site web bloque Miniflux avec une protection anti-bot.",
|
||||
@@ -343,7 +344,9 @@
|
||||
"form.integration.webhook_secret": "Secret du webhook",
|
||||
"form.integration.webhook_url": "URL du webhook",
|
||||
"form.prefs.fieldset.application_settings": "Paramètres de l'application",
|
||||
"form.prefs.fieldset.authentication_settings": "Paramètres d'authentification",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentification par mot de passe",
|
||||
"form.prefs.fieldset.google_authentication": "Authentification Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "Authentification %s",
|
||||
"form.prefs.fieldset.global_feed_settings": "Paramètres globaux des abonnements",
|
||||
"form.prefs.fieldset.reader_settings": "Paramètres du lecteur",
|
||||
"form.prefs.help.external_font_hosts": "Liste de domaine externes autorisés, séparés par des espaces. Par exemple : « fonts.gstatic.com fonts.googleapis.com ».",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "Menu",
|
||||
"menu.unread": "Non lus",
|
||||
"menu.users": "Utilisateurs",
|
||||
"page.about.author": "Auteur :",
|
||||
"page.about.authors_label": "Auteurs :",
|
||||
"page.about.authors_value": "Frédéric Guillot et les contributeurs",
|
||||
"page.about.build_date": "Date de la compilation :",
|
||||
"page.about.credits": "Crédits",
|
||||
"page.about.db_usage": "Taille de la base de données :",
|
||||
@@ -533,7 +537,6 @@
|
||||
"page.login.title": "Connexion",
|
||||
"page.login.webauthn_login": "Se connecter avec une clé d’accès",
|
||||
"page.login.webauthn_login.error": "Impossible de se connecter avec la clé d’accès",
|
||||
"page.login.webauthn_login.help": "Veuillez saisir votre nom d'utilisateur si vous utilisez une clé de sécurité. Cela n'est pas nécessaire si vous utilisez une clé d'accès (Passkey).",
|
||||
"page.new_api_key.title": "Nouvelle clé d'API",
|
||||
"page.new_category.title": "Nouvelle catégorie",
|
||||
"page.new_user.title": "Nouvel Utilisateur",
|
||||
@@ -564,7 +567,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
|
||||
"page.settings.webauthn.passkey_name": "Nom de la clé d’accès",
|
||||
"page.settings.webauthn.passkeys": "Clés d’accès",
|
||||
"page.settings.webauthn.passkeys": "Authentification par clé d’accès",
|
||||
"page.settings.webauthn.register": "Enregistrer une nouvelle clé d’accès",
|
||||
"page.settings.webauthn.register.error": "Impossible d'enregistrer la clé d’accès",
|
||||
"page.shared_entries.title": "Articles partagés",
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"error.http_bad_gateway": "O sitio web non está dispoñible debido a un erro na pasarela. O problema non está en Miniflux. Por favor, inténtao máis tarde.",
|
||||
"error.http_body_read": "Non se pode ler o corpo HTTP: %v.",
|
||||
"error.http_client_error": "Erro HTTP no cliente: %v.",
|
||||
"error.http_cloudflare_challenge": "Este sitio web está protexido por un desafío de bot de Cloudflare (CAPTCHA ou verificación de JavaScript). Miniflux non pode resolver este desafío automaticamente.",
|
||||
"error.http_empty_response": "A resposta HTTP está baleira. Podería o sitio web estar usando unha protección contra robots?",
|
||||
"error.http_empty_response_body": "O corpo da resposta HTTP está baleiro.",
|
||||
"error.http_forbidden": "Esta prohibido o acceso a esta páxina web. Podería estar usando unha protección contra robots?",
|
||||
@@ -169,7 +170,7 @@
|
||||
"form.feed.fieldset.network_settings": "Axustes da rede",
|
||||
"form.feed.fieldset.rules": "Regras",
|
||||
"form.feed.label.allow_self_signed_certificates": "Permitir certificados auto-asinados ou non válidos",
|
||||
"form.feed.label.apprise_service_urls": "Lista separada por comas de URLs do servizo Apprise",
|
||||
"form.feed.label.apprise_service_urls": "Lista de URLs separadas por comas do servizo Apprise",
|
||||
"form.feed.label.block_filter_entry_rules": "Regras de Bloqueo de entradas",
|
||||
"form.feed.label.blocklist_rules": "Filtros de bloqueo baseados en RegEx",
|
||||
"form.feed.label.category": "Categoría",
|
||||
@@ -215,7 +216,7 @@
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.archiveorg_activate": "Enviar entradas a archive.org",
|
||||
"form.integration.apprise_activate": "Enviar entradas a Apprise",
|
||||
"form.integration.apprise_services_url": "Lista separada por comas de URLs do servizo Apprise",
|
||||
"form.integration.apprise_services_url": "Lista de URLs separadas por comas do servizo Apprise",
|
||||
"form.integration.apprise_url": "URL de Apprise API",
|
||||
"form.integration.betula_activate": "Gardar entradas en Betula",
|
||||
"form.integration.betula_token": "Token de Betula",
|
||||
@@ -304,9 +305,9 @@
|
||||
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
|
||||
"form.integration.raindrop_token": "Token (de proba)",
|
||||
"form.integration.readeck_activate": "Gardar entradas en Readeck",
|
||||
"form.integration.readeck_api_key": "Clave da Readeck API",
|
||||
"form.integration.readeck_api_key": "Clave da API de Readeck",
|
||||
"form.integration.readeck_endpoint": "URL de Readeck",
|
||||
"form.integration.readeck_labels": "Etiquetas Readeck",
|
||||
"form.integration.readeck_labels": "Etiquetas para Readeck",
|
||||
"form.integration.readeck_only_url": "Enviar só URL (e non todo o contido)",
|
||||
"form.integration.readeck_push_activate": "Enviar automaticamente todas as entradas a Readeck",
|
||||
"form.integration.readwise_activate": "Gardar entradas en Readwise Reader",
|
||||
@@ -328,25 +329,27 @@
|
||||
"form.integration.telegram_bot_disable_buttons": "Desactivar botóns",
|
||||
"form.integration.telegram_bot_disable_notification": "Desactivar notificación",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disactivar vista previa da páxina",
|
||||
"form.integration.telegram_bot_token": "Toke do Bot",
|
||||
"form.integration.telegram_bot_token": "Token do Bot",
|
||||
"form.integration.telegram_chat_id": "ID da parola",
|
||||
"form.integration.telegram_topic_id": "ID do tema",
|
||||
"form.integration.wallabag_activate": "Gardar entradas en Wallabag",
|
||||
"form.integration.wallabag_client_id": "ID do cliente en Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Clave Secreta en Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Clave secreta en Wallabag",
|
||||
"form.integration.wallabag_endpoint": "URL Base de Wallabag",
|
||||
"form.integration.wallabag_only_url": "Enviar só URL (e non todo o contido)",
|
||||
"form.integration.wallabag_password": "Contrasinal en Wallabag",
|
||||
"form.integration.wallabag_username": "Identificador en Wallabag",
|
||||
"form.integration.wallabag_tags": "Etiquetas Wallabag",
|
||||
"form.integration.wallabag_tags": "Etiquetas para Wallabag",
|
||||
"form.integration.webhook_activate": "Activar Webhooks",
|
||||
"form.integration.webhook_secret": "Clave secreta Webhooks",
|
||||
"form.integration.webhook_url": "URL predeterminada Webhook",
|
||||
"form.prefs.fieldset.application_settings": "Axustes da aplicción",
|
||||
"form.prefs.fieldset.authentication_settings": "Axustes da autenticación",
|
||||
"form.prefs.fieldset.application_settings": "Axustes da aplicación",
|
||||
"form.prefs.fieldset.authentication_settings": "Autenticación con contrasinal",
|
||||
"form.prefs.fieldset.google_authentication": "Autenticación con Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "Autenticación con %s",
|
||||
"form.prefs.fieldset.global_feed_settings": "Axustes da canle global",
|
||||
"form.prefs.fieldset.reader_settings": "Axustes de lectura",
|
||||
"form.prefs.help.external_font_hosts": "Lista separada por espazos de servidores de tipos de letra externos permitidos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.help.external_font_hosts": "Lista de servidores de tipos de letra externos permitidos separados por espazos. Exemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.always_open_external_links": "Ler artigos abrindo ligazóns externas",
|
||||
"form.prefs.label.categories_sorting_order": "Orde para Categorías",
|
||||
"form.prefs.label.cjk_reading_speed": "Velocidade de lectura para chinés, koreano e xaponés (caracteres por minuto)",
|
||||
@@ -364,11 +367,11 @@
|
||||
"form.prefs.label.keyboard_shortcuts": "Activar atallos do teclado",
|
||||
"form.prefs.label.language": "Idioma",
|
||||
"form.prefs.label.mark_read_manually": "Marcar manualmente as entradas como lidas",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Só marcar como lido cando a reprodución acada o 90%",
|
||||
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas ao velas",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar entradas como vistas ao velas. Para son/vídeo, marcar como lido ao chegar ao 90%",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Só marcar como lido cando acada o 90%% da reprodución",
|
||||
"form.prefs.label.mark_read_on_view": "Marcar automaticamente como lidas as entradas ao velas",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Para son/vídeo, marcar como lido ao chegar ao 90%% da reprodución",
|
||||
"form.prefs.label.media_playback_rate": "Velocidade de reprodución do son/vídeo",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Abrir ligazóns externas en nova lapela (engade target=\"_blank\" ás ligazóns)",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "Abrir ligazóns externas en nova pestana (engade target=\"_blank\" ás ligazóns)",
|
||||
"form.prefs.label.show_reading_time": "Mostrar tempo de lectura estimado para as entradas",
|
||||
"form.prefs.label.theme": "Decorado",
|
||||
"form.prefs.label.timezone": "Zona horaria",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "Menú",
|
||||
"menu.unread": "Sen ler",
|
||||
"menu.users": "Usuarias",
|
||||
"page.about.author": "Autoría:",
|
||||
"page.about.authors_label": "Autoría:",
|
||||
"page.about.authors_value": "Frédéric Guillot e colaboradoras",
|
||||
"page.about.build_date": "Data da versión:",
|
||||
"page.about.credits": "Crédito",
|
||||
"page.about.db_usage": "Tamaño da BDD:",
|
||||
@@ -533,7 +537,6 @@
|
||||
"page.login.title": "Acceder",
|
||||
"page.login.webauthn_login": "Acceso con clave de paso",
|
||||
"page.login.webauthn_login.error": "Non se puido acceder coa clave de paso",
|
||||
"page.login.webauthn_login.help": "Por favor escribe o teu identificador se estás a usar unha chave de seguridade. Non se require isto se estás a usar unha «Clave de Paso» (credenciais descubribles).",
|
||||
"page.new_api_key.title": "Nova clave da API",
|
||||
"page.new_category.title": "Nova Categoría",
|
||||
"page.new_user.title": "Nova Usuaria",
|
||||
@@ -564,7 +567,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Último uso",
|
||||
"page.settings.webauthn.passkey_name": "Nome da Clave de Paso",
|
||||
"page.settings.webauthn.passkeys": "Claves de paso",
|
||||
"page.settings.webauthn.passkeys": "Autenticación con chave de paso",
|
||||
"page.settings.webauthn.register": "Rexistrar Clave de paso",
|
||||
"page.settings.webauthn.register.error": "Non se puido rexistrar Clave de paso",
|
||||
"page.shared_entries.title": "Entradas compartidas",
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"error.http_bad_gateway": "खराब गेटवे त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या Miniflux की तरफ नहीं है। कृपया बाद में फिर से कोशिश करें।",
|
||||
"error.http_body_read": "HTTP बॉडी पढ़ने में असमर्थ: %v।",
|
||||
"error.http_client_error": "HTTP क्लाइंट त्रुटि: %v।",
|
||||
"error.http_cloudflare_challenge": "यह वेबसाइट Cloudflare बॉट चैलेंज (CAPTCHA या JavaScript सत्यापन) द्वारा सुरक्षित है। Miniflux इस चैलेंज को स्वचालित रूप से हल नहीं कर सकता।",
|
||||
"error.http_empty_response": "HTTP प्रतिक्रिया खाली है। शायद यह वेबसाइट बॉट सुरक्षा तंत्र का उपयोग कर रही है?",
|
||||
"error.http_empty_response_body": "HTTP प्रतिक्रिया बॉडी खाली है।",
|
||||
"error.http_forbidden": "इस वेबसाइट तक पहुंच वर्जित है। शायद इस वेबसाइट में बॉट सुरक्षा तंत्र है?",
|
||||
@@ -343,7 +344,9 @@
|
||||
"form.integration.webhook_secret": "वेबहुक रहस्य",
|
||||
"form.integration.webhook_url": "डिफ़ॉल्ट वेबहुक URL",
|
||||
"form.prefs.fieldset.application_settings": "एप्लिकेशन सेटिंग्स",
|
||||
"form.prefs.fieldset.authentication_settings": "प्रमाणीकरण सेटिंग्स",
|
||||
"form.prefs.fieldset.authentication_settings": "पासवर्ड प्रमाणीकरण",
|
||||
"form.prefs.fieldset.google_authentication": "Google प्रमाणीकरण",
|
||||
"form.prefs.fieldset.oidc_authentication": "%s प्रमाणीकरण",
|
||||
"form.prefs.fieldset.global_feed_settings": "वैश्विक फ़ीड सेटिंग्स",
|
||||
"form.prefs.fieldset.reader_settings": "रीडर सेटिंग्स",
|
||||
"form.prefs.help.external_font_hosts": "अनुमति प्राप्त बाहरी फ़ॉन्ट होस्ट की सूची (स्पेस से पृथक). उदाहरण: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "मेनू",
|
||||
"menu.unread": "अपठित",
|
||||
"menu.users": "उपयोगकर्ताओं",
|
||||
"page.about.author": "रचयिता:",
|
||||
"page.about.authors_label": "रचयिता:",
|
||||
"page.about.authors_value": "Frédéric Guillot और योगदानकर्ता",
|
||||
"page.about.build_date": "बनाने की तिथि:",
|
||||
"page.about.credits": "आभार सूची",
|
||||
"page.about.db_usage": "डेटाबेस आकार:",
|
||||
@@ -533,7 +537,6 @@
|
||||
"page.login.title": "साइन इन करें",
|
||||
"page.login.webauthn_login": "पासकी से लॉगिन करें",
|
||||
"page.login.webauthn_login.error": "पासकी से लॉगिन करने में असमर्थ",
|
||||
"page.login.webauthn_login.help": "यदि आप सुरक्षा कुंजी का उपयोग कर रहे हैं तो कृपया अपना उपयोगकर्ता नाम दर्ज करें। पासकी (discoverable credentials) के लिए यह आवश्यक नहीं है।",
|
||||
"page.new_api_key.title": "नई एपीआई कुंजी",
|
||||
"page.new_category.title": "नया श्रेणी",
|
||||
"page.new_user.title": "नया उपभोक्ता",
|
||||
@@ -564,7 +567,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "अंतिम उपयोग",
|
||||
"page.settings.webauthn.passkey_name": "पासकी का नाम",
|
||||
"page.settings.webauthn.passkeys": "पासकी",
|
||||
"page.settings.webauthn.passkeys": "पासकी प्रमाणीकरण",
|
||||
"page.settings.webauthn.register": "रजिस्टर पासकी",
|
||||
"page.settings.webauthn.register.error": "पासकी पंजीकृत करने में असमर्थ",
|
||||
"page.shared_entries.title": "साझा किया हुआ प्रविष्टि",
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"error.http_bad_gateway": "Situs ini tidak tersedia saat ini karena kesalahan akses peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
|
||||
"error.http_body_read": "Tidak dapat membaca badan HTTP: %v.",
|
||||
"error.http_client_error": "Galat klien HTTP: %v.",
|
||||
"error.http_cloudflare_challenge": "Situs web ini dilindungi oleh tantangan bot Cloudflare (CAPTCHA atau verifikasi JavaScript). Miniflux tidak dapat menyelesaikan tantangan ini secara otomatis.",
|
||||
"error.http_empty_response": "Balasan HTTP kosong. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?",
|
||||
"error.http_empty_response_body": "Badan balasan HTTP kosong.",
|
||||
"error.http_forbidden": "Akses ke situs ini terlarang. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?",
|
||||
@@ -340,7 +341,9 @@
|
||||
"form.integration.webhook_secret": "Rahasia Webhook",
|
||||
"form.integration.webhook_url": "URL Webhook baku",
|
||||
"form.prefs.fieldset.application_settings": "Pengaturan Aplikasi",
|
||||
"form.prefs.fieldset.authentication_settings": "Pengaturan Autentikasi",
|
||||
"form.prefs.fieldset.authentication_settings": "Autentikasi Kata Sandi",
|
||||
"form.prefs.fieldset.google_authentication": "Autentikasi Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "Autentikasi %s",
|
||||
"form.prefs.fieldset.global_feed_settings": "Pengaturan Umpan Global",
|
||||
"form.prefs.fieldset.reader_settings": "Pengaturan Pembaca",
|
||||
"form.prefs.help.external_font_hosts": "Daftar yang dipisah spasi untuk peladen penyedia fonta eksternal yang diperbolehkan. Seperti: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
@@ -422,7 +425,8 @@
|
||||
"menu.title": "Menu",
|
||||
"menu.unread": "Belum Dibaca",
|
||||
"menu.users": "Pengguna",
|
||||
"page.about.author": "Pengembang:",
|
||||
"page.about.authors_label": "Para Pengembang:",
|
||||
"page.about.authors_value": "Frédéric Guillot dan kontributor",
|
||||
"page.about.build_date": "Tanggal Penyusunan:",
|
||||
"page.about.credits": "Pengembang",
|
||||
"page.about.db_usage": "Ukuran basis data:",
|
||||
@@ -527,7 +531,6 @@
|
||||
"page.login.title": "Masuk",
|
||||
"page.login.webauthn_login": "Masuk menggunakan passkey",
|
||||
"page.login.webauthn_login.error": "Tidak dapat masuk menggunakan passkey",
|
||||
"page.login.webauthn_login.help": "Mohon untuk memasukkan nama pengguna Anda jika Anda menggunakan kunci keamanan. Tidak diperlukan jika anda menggunakan Passkey (kredensial dapat ditemukan).",
|
||||
"page.new_api_key.title": "Kunci API Baru",
|
||||
"page.new_category.title": "Kategori Baru",
|
||||
"page.new_user.title": "Pengguna Baru",
|
||||
@@ -556,7 +559,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Terakhir Digunakan",
|
||||
"page.settings.webauthn.passkey_name": "Nama Passkey",
|
||||
"page.settings.webauthn.passkeys": "Passkey",
|
||||
"page.settings.webauthn.passkeys": "Autentikasi Passkey",
|
||||
"page.settings.webauthn.register": "Daftar passkey",
|
||||
"page.settings.webauthn.register.error": "Tidak dapat mendaftarkan passkey",
|
||||
"page.shared_entries.title": "Entri yang Dibagikan",
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"error.http_bad_gateway": "Il sito web non è disponibile al momento a causa di un errore di gateway. Il problema non è dal lato di Miniflux. Per favore, riprova più tardi.",
|
||||
"error.http_body_read": "Impossibile leggere il corpo HTTP: %v.",
|
||||
"error.http_client_error": "Errore del client HTTP: %v.",
|
||||
"error.http_cloudflare_challenge": "Questo sito web è protetto da una sfida bot di Cloudflare (CAPTCHA o verifica JavaScript). Miniflux non può risolvere questa sfida automaticamente.",
|
||||
"error.http_empty_response": "La risposta HTTP è vuota. Forse questo sito web utilizza un meccanismo di protezione dai bot?",
|
||||
"error.http_empty_response_body": "Il corpo della risposta HTTP è vuoto.",
|
||||
"error.http_forbidden": "L'accesso a questo sito web è vietato. Forse questo sito web ha un meccanismo di protezione dai bot?",
|
||||
@@ -343,7 +344,9 @@
|
||||
"form.integration.webhook_secret": "Segreto dei webhook",
|
||||
"form.integration.webhook_url": "URL webhook predefinito",
|
||||
"form.prefs.fieldset.application_settings": "Impostazioni applicazione",
|
||||
"form.prefs.fieldset.authentication_settings": "Impostazioni di autenticazione",
|
||||
"form.prefs.fieldset.authentication_settings": "Autenticazione con password",
|
||||
"form.prefs.fieldset.google_authentication": "Autenticazione Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "Autenticazione %s",
|
||||
"form.prefs.fieldset.global_feed_settings": "Impostazioni globali dei feed",
|
||||
"form.prefs.fieldset.reader_settings": "Impostazioni del lettore",
|
||||
"form.prefs.help.external_font_hosts": "Elenco, separato da spazi, degli host di font esterni consentiti. Ad esempio: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "Menù",
|
||||
"menu.unread": "Da leggere",
|
||||
"menu.users": "Utenti",
|
||||
"page.about.author": "Autore:",
|
||||
"page.about.authors_label": "Autori:",
|
||||
"page.about.authors_value": "Frédéric Guillot e collaboratori",
|
||||
"page.about.build_date": "Data della build:",
|
||||
"page.about.credits": "Crediti",
|
||||
"page.about.db_usage": "Dimensione del database:",
|
||||
@@ -533,7 +537,6 @@
|
||||
"page.login.title": "Accedi",
|
||||
"page.login.webauthn_login": "Accedi con passkey",
|
||||
"page.login.webauthn_login.error": "Impossibile accedere con passkey",
|
||||
"page.login.webauthn_login.help": "Inserisci il tuo nome utente se stai usando una chiave di sicurezza. Non è necessario con una Passkey (credenziali rilevabili).",
|
||||
"page.new_api_key.title": "Nuova chiave API",
|
||||
"page.new_category.title": "Nuova categoria",
|
||||
"page.new_user.title": "Nuovo utente",
|
||||
@@ -564,7 +567,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Ultimo uso",
|
||||
"page.settings.webauthn.passkey_name": "Nome passkey",
|
||||
"page.settings.webauthn.passkeys": "Passkey",
|
||||
"page.settings.webauthn.passkeys": "Autenticazione con passkey",
|
||||
"page.settings.webauthn.register": "Registra la chiave di accesso",
|
||||
"page.settings.webauthn.register.error": "Impossibile registrare la passkey",
|
||||
"page.shared_entries.title": "Voci condivise",
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"error.http_bad_gateway": "ウェブサイトは、不正なゲートウェイエラーのため現在利用できません。問題はMiniflux側にはありません。後でもう一度お試しください。",
|
||||
"error.http_body_read": "HTTP本文を読み取れません: %v。",
|
||||
"error.http_client_error": "HTTPクライアントエラー: %v。",
|
||||
"error.http_cloudflare_challenge": "このウェブサイトは Cloudflare のボットチャレンジ(CAPTCHA または JavaScript 検証)によって保護されています。Miniflux はこのチャレンジを自動的に解くことができません。",
|
||||
"error.http_empty_response": "HTTP応答が空です。おそらく、このウェブサイトはボット保護メカニズムを使用していますか?",
|
||||
"error.http_empty_response_body": "HTTP応答本文が空です。",
|
||||
"error.http_forbidden": "このウェブサイトへのアクセスは禁止されています。おそらく、このウェブサイトはボット保護メカニズムを持っていますか?",
|
||||
@@ -340,7 +341,9 @@
|
||||
"form.integration.webhook_secret": "Webhook シークレット",
|
||||
"form.integration.webhook_url": "デフォルトの Webhook URL",
|
||||
"form.prefs.fieldset.application_settings": "アプリケーション設定",
|
||||
"form.prefs.fieldset.authentication_settings": "認証設定",
|
||||
"form.prefs.fieldset.authentication_settings": "パスワード認証",
|
||||
"form.prefs.fieldset.google_authentication": "Google 認証",
|
||||
"form.prefs.fieldset.oidc_authentication": "%s 認証",
|
||||
"form.prefs.fieldset.global_feed_settings": "グローバルフィード設定",
|
||||
"form.prefs.fieldset.reader_settings": "リーダー設定",
|
||||
"form.prefs.help.external_font_hosts": "許可する外部フォントホストをスペース区切りで指定します。例: \"fonts.gstatic.com fonts.googleapis.com\"",
|
||||
@@ -422,7 +425,8 @@
|
||||
"menu.title": "メニュー",
|
||||
"menu.unread": "未読",
|
||||
"menu.users": "ユーザー一覧",
|
||||
"page.about.author": "作者:",
|
||||
"page.about.authors_label": "作者:",
|
||||
"page.about.authors_value": "Frédéric Guillot と貢献者",
|
||||
"page.about.build_date": "ビルド日時:",
|
||||
"page.about.credits": "著作権表示",
|
||||
"page.about.db_usage": "データベースサイズ:",
|
||||
@@ -527,7 +531,6 @@
|
||||
"page.login.title": "ログイン",
|
||||
"page.login.webauthn_login": "パスキーでログイン",
|
||||
"page.login.webauthn_login.error": "パスキーでログインできない",
|
||||
"page.login.webauthn_login.help": "セキュリティキーを使用する場合はユーザー名を入力してください。パスキー(検出可能な認証情報)の場合は不要です。",
|
||||
"page.new_api_key.title": "新しい API キー",
|
||||
"page.new_category.title": "新規カテゴリ",
|
||||
"page.new_user.title": "新規ユーザー",
|
||||
@@ -556,7 +559,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "最終使用日",
|
||||
"page.settings.webauthn.passkey_name": "パスキー名",
|
||||
"page.settings.webauthn.passkeys": "パスキー",
|
||||
"page.settings.webauthn.passkeys": "パスキー認証",
|
||||
"page.settings.webauthn.register": "パスキーを登録する",
|
||||
"page.settings.webauthn.register.error": "パスキーを登録できません",
|
||||
"page.shared_entries.title": "共有エントリ",
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
{
|
||||
"action.cancel": "취소",
|
||||
"action.download": "다운로드",
|
||||
"action.edit": "편집",
|
||||
"action.home_screen": "홈 화면에 추가",
|
||||
"action.import": "가져오기",
|
||||
"action.login": "로그인",
|
||||
"action.or": "또는",
|
||||
"action.remove": "삭제",
|
||||
"action.remove_feed": "이 피드 삭제",
|
||||
"action.save": "저장",
|
||||
"action.subscribe": "피드 구독",
|
||||
"action.update": "업데이트",
|
||||
"alert.account_linked": "외부 계정과 연동되었습니다!",
|
||||
"alert.account_unlinked": "외부 계정과의 연동이 해제되었습니다!",
|
||||
"alert.background_feed_refresh": "모든 피드를 백그라운드에서 새로 고치는 중입니다. 이 작업 중에도 Miniflux를 계속 사용할 수 있습니다.",
|
||||
"alert.feed_error": "이 피드에 문제가 있습니다.",
|
||||
"alert.no_starred": "현재 즐겨찾기 표시된 게시물이 없습니다.",
|
||||
"alert.no_category": "카테고리가 없습니다.",
|
||||
"alert.no_category_entry": "이 카테고리에는 게시물이 없습니다.",
|
||||
"alert.no_feed": "구독 중인 피드가 없습니다.",
|
||||
"alert.no_feed_entry": "이 피드에는 게시물이 없습니다.",
|
||||
"alert.no_feed_in_category": "이 카테고리에는 구독 중인 피드가 없습니다.",
|
||||
"alert.no_history": "현재 기록이 없습니다.",
|
||||
"alert.no_search_result": "검색 결과가 없습니다.",
|
||||
"alert.no_shared_entry": "공유된 게시물이 없습니다.",
|
||||
"alert.no_tag_entry": "이 태그와 일치하는 게시물이 없습니다.",
|
||||
"alert.no_unread_entry": "읽지 않은 게시물이 없습니다.",
|
||||
"alert.no_user": "당신이 유일한 사용자입니다.",
|
||||
"alert.prefs_saved": "설정이 정상적으로 저장되었습니다!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"피드 새로고침 요청이 너무 많습니다. %d분 후 다시 시도해 주세요."
|
||||
],
|
||||
"confirm.loading": "실행 중…",
|
||||
"confirm.no": "아니요",
|
||||
"confirm.question": "진행하시겠습니까?",
|
||||
"confirm.question.refresh": "강제로 새로 고치시겠습니까?",
|
||||
"confirm.yes": "예",
|
||||
"enclosure_media_controls.seek": "탐색:",
|
||||
"enclosure_media_controls.seek.title": "%s초 이동",
|
||||
"enclosure_media_controls.speed": "속도:",
|
||||
"enclosure_media_controls.speed.faster": "빠르게",
|
||||
"enclosure_media_controls.speed.faster.title": "%sx 빠르게",
|
||||
"enclosure_media_controls.speed.reset": "초기화",
|
||||
"enclosure_media_controls.speed.reset.title": "속도를 1x로 초기화",
|
||||
"enclosure_media_controls.speed.slower": "느리게",
|
||||
"enclosure_media_controls.speed.slower.title": "%sx 느리게",
|
||||
"entry.starred.toast.off": "즐겨찾기를 해제했습니다",
|
||||
"entry.starred.toast.on": "즐겨찾기로 설정했습니다",
|
||||
"entry.starred.toggle.off": "즐겨찾기 해제",
|
||||
"entry.starred.toggle.on": "즐겨찾기 설정",
|
||||
"entry.comments.label": "댓글",
|
||||
"entry.comments.title": "댓글 보기",
|
||||
"entry.estimated_reading_time": [
|
||||
"%d분 소요"
|
||||
],
|
||||
"entry.external_link.label": "외부 링크",
|
||||
"entry.save.completed": "완료!",
|
||||
"entry.save.label": "저장",
|
||||
"entry.save.title": "이 게시물 저장",
|
||||
"entry.save.toast.completed": "게시물이 저장되었습니다",
|
||||
"entry.scraper.completed": "완료!",
|
||||
"entry.scraper.label": "다운로드",
|
||||
"entry.scraper.title": "원본 내용 가져오기",
|
||||
"entry.share.label": "공유",
|
||||
"entry.share.title": "이 게시물 공유",
|
||||
"entry.shared_entry.label": "공유하기",
|
||||
"entry.shared_entry.title": "공개 링크 열기",
|
||||
"entry.state.loading": "불러오는 중…",
|
||||
"entry.state.saving": "저장 중…",
|
||||
"entry.status.mark_as_read": "읽음으로 표시",
|
||||
"entry.status.mark_as_unread": "읽지 않음으로 표시",
|
||||
"entry.status.title": "게시물 상태 변경",
|
||||
"entry.status.toast.read": "읽음으로 표시했습니다",
|
||||
"entry.status.toast.unread": "읽지 않음으로 표시했습니다",
|
||||
"entry.tags.label": "태그:",
|
||||
"entry.tags.more_tags_label": [
|
||||
"태그 %d개"
|
||||
],
|
||||
"entry.unshare.label": "공유 해제",
|
||||
"error.api_key_already_exists": "이 API 키는 이미 존재합니다.",
|
||||
"error.bad_credentials": "사용자명 또는 비밀번호가 잘못되었습니다.",
|
||||
"error.category_already_exists": "이 카테고리는 이미 존재합니다.",
|
||||
"error.category_not_found": "이 카테고리는 존재하지 않거나 이 사용자의 것이 아닙니다.",
|
||||
"error.database_error": "데이터베이스 오류: %v.",
|
||||
"error.different_passwords": "비밀번호가 일치하지 않습니다.",
|
||||
"error.duplicate_fever_username": "같은 Fever 사용자명이 이미 사용 중입니다!",
|
||||
"error.duplicate_googlereader_username": "같은 Google Reader 사용자명이 이미 사용 중입니다!",
|
||||
"error.duplicate_linked_account": "다른 사용자가 이미 이 서비스의 동일한 사용자와 연동되어 있습니다.",
|
||||
"error.duplicated_feed": "이 피드는 이미 존재합니다.",
|
||||
"error.empty_file": "이 파일은 비어 있습니다.",
|
||||
"error.entries_per_page_invalid": "페이지당 게시물 수가 유효하지 않습니다.",
|
||||
"error.feed_already_exists": "이 피드는 이미 존재합니다.",
|
||||
"error.feed_category_not_found": "이 카테고리는 존재하지 않거나 이 사용자의 것이 아닙니다.",
|
||||
"error.feed_format_not_detected": "피드 형식을 감지할 수 없습니다: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "차단 목록 규칙이 유효하지 않습니다.",
|
||||
"error.feed_invalid_keeplist_rule": "허용 목록 규칙이 유효하지 않습니다.",
|
||||
"error.feed_mandatory_fields": "URL과 카테고리가 필요합니다.",
|
||||
"error.feed_not_found": "이 피드는 존재하지 않거나 이 사용자의 것이 아닙니다.",
|
||||
"error.feed_title_not_empty": "피드 제목은 비워 둘 수 없습니다.",
|
||||
"error.feed_url_not_empty": "피드 URL은 비워 둘 수 없습니다.",
|
||||
"error.fields_mandatory": "모든 항목을 입력해주세요.",
|
||||
"error.http_bad_gateway": "잘못된 게이트웨이 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 나중에 다시 시도해 주세요.",
|
||||
"error.http_body_read": "HTTP 본문을 읽을 수 없습니다: %v.",
|
||||
"error.http_client_error": "HTTP 클라이언트 오류: %v.",
|
||||
"error.http_cloudflare_challenge": "이 웹사이트는 Cloudflare 봇 챌린지(CAPTCHA 또는 JavaScript 검증)로 보호되어 있습니다. Miniflux는 이 챌린지를 자동으로 해결할 수 없습니다.",
|
||||
"error.http_empty_response": "HTTP 응답이 비어 있습니다. 이 웹사이트가 봇 보호 메커니즘을 사용하고 있을 수 있습니다.",
|
||||
"error.http_empty_response_body": "HTTP 응답 본문이 비어 있습니다.",
|
||||
"error.http_forbidden": "이 웹사이트에 대한 접근이 금지되어 있습니다. 이 웹사이트가 봇 보호 메커니즘을 사용하고 있을 수 있습니다.",
|
||||
"error.http_gateway_timeout": "게이트웨이 시간 초과로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
|
||||
"error.http_internal_server_error": "서버 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
|
||||
"error.http_not_authorized": "이 웹사이트에 대한 접근이 허용되지 않았습니다. 사용자명 또는 비밀번호가 올바르지 않을 수 있습니다.",
|
||||
"error.http_resource_not_found": "요청한 리소스를 찾을 수 없습니다. URL을 확인해 주세요.",
|
||||
"error.http_response_too_large": "HTTP 응답이 너무 큽니다. 전역 설정에서 HTTP 응답 크기 제한을 늘릴 수 있습니다. (서버 재시작 필요)",
|
||||
"error.http_service_unavailable": "내부 서버 오류로 인해 현재 이 웹사이트를 사용할 수 없습니다. 문제는 Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
|
||||
"error.http_too_many_requests": "Miniflux가 이 웹사이트에 너무 많은 요청을 보냈습니다. 잠시 기다리거나 애플리케이션 설정을 변경해 주세요.",
|
||||
"error.http_unexpected_status_code": "예상치 못한 HTTP 상태 코드(%d)로 인해 현재 이 웹사이트를 사용할 수 없습니다. Miniflux 측의 문제가 아닙니다. 잠시 후 다시 시도해 주세요.",
|
||||
"error.invalid_categories_sorting_order": "카테고리 표시 순서가 유효하지 않습니다.",
|
||||
"error.invalid_default_home_page": "기본 시작 페이지가 유효하지 않습니다",
|
||||
"error.invalid_display_mode": "웹 앱 표시 모드가 유효하지 않습니다.",
|
||||
"error.invalid_entry_direction": "게시물 표시 방향이 유효하지 않습니다.",
|
||||
"error.invalid_entry_order": "게시물 표시 순서가 유효하지 않습니다.",
|
||||
"error.invalid_feed_proxy_url": "프록시 URL이 유효하지 않습니다.",
|
||||
"error.invalid_feed_url": "피드 URL이 유효하지 않습니다.",
|
||||
"error.invalid_gesture_nav": "제스처 내비게이션이 유효하지 않습니다.",
|
||||
"error.invalid_language": "언어가 유효하지 않습니다.",
|
||||
"error.invalid_site_url": "사이트 URL이 유효하지 않습니다.",
|
||||
"error.invalid_theme": "테마가 유효하지 않습니다.",
|
||||
"error.invalid_timezone": "시간대가 유효하지 않습니다.",
|
||||
"error.network_operation": "네트워크 오류로 인해 Miniflux가 이 웹사이트에 도달할 수 없습니다: %v.",
|
||||
"error.network_timeout": "이 웹사이트의 응답이 너무 느려 시간 초과되었습니다: %v",
|
||||
"error.password_min_length": "비밀번호는 6자 이상이어야 합니다.",
|
||||
"error.proxy_url_not_empty": "프록시 URL은 비워 둘 수 없습니다.",
|
||||
"error.settings_block_rule_fieldname_invalid": "차단 규칙이 유효하지 않습니다: 규칙 #%d에 유효한 필드 이름이 없습니다 (옵션: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "차단 규칙이 유효하지 않습니다: 규칙 #%d의 패턴이 정규식으로 유효하지 않습니다",
|
||||
"error.settings_block_rule_regex_required": "차단 규칙이 유효하지 않습니다: 규칙 #%d에 패턴이 지정되지 않았습니다",
|
||||
"error.settings_block_rule_separator_required": "차단 규칙이 유효하지 않습니다: 규칙 #%d의 패턴은 '='로 구분해야 합니다",
|
||||
"error.settings_invalid_domain_list": "도메인 목록이 유효하지 않습니다. 도메인은 공백으로 구분해 지정해 주세요.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "보존 규칙이 유효하지 않습니다: 규칙 #%d에 유효한 필드 이름이 없습니다 (옵션: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "보존 규칙이 유효하지 않습니다: 규칙 #%d의 패턴이 정규식으로 유효하지 않습니다",
|
||||
"error.settings_keep_rule_regex_required": "보존 규칙이 유효하지 않습니다: 규칙 #%d에 패턴이 지정되지 않았습니다",
|
||||
"error.settings_keep_rule_separator_required": "보존 규칙이 유효하지 않습니다: 규칙 #%d의 패턴은 '='로 구분해야 합니다",
|
||||
"error.settings_mandatory_fields": "사용자명, 테마, 언어, 시간대가 모두 필요합니다.",
|
||||
"error.settings_media_playback_rate_range": "재생 속도가 범위를 벗어났습니다",
|
||||
"error.settings_reading_speed_is_positive": "읽기 속도는 양의 정수여야 합니다.",
|
||||
"error.site_url_not_empty": "사이트 URL은 비워 둘 수 없습니다.",
|
||||
"error.subscription_not_found": "피드를 찾을 수 없습니다.",
|
||||
"error.title_required": "제목이 필요합니다.",
|
||||
"error.tls_error": "TLS 오류: %q. 필요한 경우 피드 설정에서 TLS 검증을 비활성화할 수 있습니다.",
|
||||
"error.unable_to_create_api_key": "이 API 키를 만들 수 없습니다.",
|
||||
"error.unable_to_create_category": "이 카테고리를 만들 수 없습니다.",
|
||||
"error.unable_to_create_user": "이 사용자를 만들 수 없습니다.",
|
||||
"error.unable_to_detect_rssbridge": "RSS-Bridge를 사용해 피드를 감지할 수 없습니다: %v.",
|
||||
"error.unable_to_parse_feed": "이 피드를 파싱할 수 없습니다: %v.",
|
||||
"error.unable_to_update_category": "이 카테고리를 업데이트할 수 없습니다.",
|
||||
"error.unable_to_update_feed": "이 피드를 업데이트할 수 없습니다.",
|
||||
"error.unable_to_update_user": "이 사용자를 업데이트할 수 없습니다.",
|
||||
"error.unlink_account_without_password": "비밀번호를 설정하지 않으면 다시 로그인할 수 없습니다.",
|
||||
"error.user_already_exists": "이 사용자는 이미 존재합니다.",
|
||||
"error.user_mandatory_fields": "사용자명이 필요합니다.",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API 토큰과 조직 슬러그가 필요합니다",
|
||||
"form.api_key.label.description": "API키 설명",
|
||||
"form.category.hide_globally": "읽지 않음 목록에 게시물을 표시하지 않음",
|
||||
"form.category.label.title": "제목",
|
||||
"form.feed.fieldset.general": "일반",
|
||||
"form.feed.fieldset.integration": "서드파티 서비스",
|
||||
"form.feed.fieldset.network_settings": "네트워크 설정",
|
||||
"form.feed.fieldset.rules": "규칙",
|
||||
"form.feed.label.allow_self_signed_certificates": "자체 서명 인증서 또는 유효하지 않은 인증서 허용",
|
||||
"form.feed.label.apprise_service_urls": "Apprise 서비스 URL의 쉼표로 구분된 목록",
|
||||
"form.feed.label.block_filter_entry_rules": "게시물 차단 규칙",
|
||||
"form.feed.label.blocklist_rules": "정규식 기반 차단 필터",
|
||||
"form.feed.label.category": "카테고리",
|
||||
"form.feed.label.cookie": "Cookie 설정",
|
||||
"form.feed.label.crawler": "게시물 본문도 함께 다운로드",
|
||||
"form.feed.label.ignore_entry_updates": "업데이트된 게시물 무시",
|
||||
"form.feed.label.description": "설명",
|
||||
"form.feed.label.disable_http2": "핑거프린팅 회피를 위해 HTTP/2 비활성화",
|
||||
"form.feed.label.disabled": "이 피드를 업데이트하지 않음",
|
||||
"form.feed.label.feed_password": "피드 비밀번호",
|
||||
"form.feed.label.feed_url": "피드 URL",
|
||||
"form.feed.label.feed_username": "피드 사용자명",
|
||||
"form.feed.label.fetch_via_proxy": "애플리케이션 수준에서 설정된 프록시 사용",
|
||||
"form.feed.label.hide_globally": "읽지 않음 목록에 게시물을 표시하지 않음",
|
||||
"form.feed.label.ignore_http_cache": "HTTP 캐시 무시",
|
||||
"form.feed.label.keep_filter_entry_rules": "게시물 허용 규칙",
|
||||
"form.feed.label.keeplist_rules": "정규식 기반 보존 필터",
|
||||
"form.feed.label.no_media_player": "미디어 기능 비활성화 (오디오/비디오)",
|
||||
"form.feed.label.ntfy_activate": "게시물을 ntfy로 전송",
|
||||
"form.feed.label.ntfy_default_priority": "ntfy 기본 우선순위",
|
||||
"form.feed.label.ntfy_high_priority": "ntfy 높은 우선순위",
|
||||
"form.feed.label.ntfy_low_priority": "ntfy 낮은 우선순위",
|
||||
"form.feed.label.ntfy_max_priority": "ntfy 최대 우선순위",
|
||||
"form.feed.label.ntfy_min_priority": "ntfy 최소 우선순위",
|
||||
"form.feed.label.ntfy_priority": "ntfy 우선순위",
|
||||
"form.feed.label.ntfy_topic": "ntfy 토픽(선택 사항)",
|
||||
"form.feed.label.proxy_url": "프록시 URL",
|
||||
"form.feed.label.pushover_activate": "게시물을 pushover.net으로 전송",
|
||||
"form.feed.label.pushover_default_priority": "Pushover 기본 우선순위",
|
||||
"form.feed.label.pushover_high_priority": "Pushover 높은 우선순위",
|
||||
"form.feed.label.pushover_low_priority": "Pushover 낮은 우선순위",
|
||||
"form.feed.label.pushover_max_priority": "Pushover 최대 우선순위",
|
||||
"form.feed.label.pushover_min_priority": "Pushover 최소 우선순위",
|
||||
"form.feed.label.pushover_priority": "Pushover 메시지 우선순위",
|
||||
"form.feed.label.rewrite_rules": "본문 재작성 규칙",
|
||||
"form.feed.label.scraper_rules": "본문 추출 규칙",
|
||||
"form.feed.label.site_url": "사이트 URL",
|
||||
"form.feed.label.title": "제목",
|
||||
"form.feed.label.urlrewrite_rules": "URL 재작성 규칙",
|
||||
"form.feed.label.user_agent": "기본 User Agent 덮어쓰기",
|
||||
"form.feed.label.webhook_url": "Webhook URL 덮어쓰기",
|
||||
"form.import.label.file": "OPML 파일",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.archiveorg_activate": "게시물을 archive.org로 푸시",
|
||||
"form.integration.apprise_activate": "게시물을 Apprise로 전송",
|
||||
"form.integration.apprise_services_url": "Apprise 서비스 URL의 쉼표로 구분된 목록",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.betula_activate": "게시물을 Betula에 저장",
|
||||
"form.integration.betula_token": "Betula 토큰",
|
||||
"form.integration.betula_url": "Betula 서버 URL",
|
||||
"form.integration.cubox_activate": "게시물을 Cubox에 저장",
|
||||
"form.integration.cubox_api_link": "Cubox API 링크",
|
||||
"form.integration.discord_activate": "게시물을 Discord로 전송",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook 링크",
|
||||
"form.integration.espial_activate": "Espial에 게시물 저장",
|
||||
"form.integration.espial_api_key": "Espial API 키",
|
||||
"form.integration.espial_endpoint": "Espial API 엔드포인트",
|
||||
"form.integration.espial_tags": "Espial 태그",
|
||||
"form.integration.fever_activate": "Fever API 활성화",
|
||||
"form.integration.fever_endpoint": "Fever API 엔드포인트:",
|
||||
"form.integration.fever_password": "Fever 비밀번호",
|
||||
"form.integration.fever_username": "Fever 사용자명",
|
||||
"form.integration.googlereader_activate": "Google Reader API 활성화",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API 엔드포인트:",
|
||||
"form.integration.googlereader_password": "Google Reader 비밀번호",
|
||||
"form.integration.googlereader_username": "Google Reader 사용자명",
|
||||
"form.integration.instapaper_activate": "Instapaper에 게시물 저장",
|
||||
"form.integration.instapaper_password": "Instapaper 비밀번호",
|
||||
"form.integration.instapaper_username": "Instapaper 사용자명",
|
||||
"form.integration.karakeep_activate": "Karakeep에 게시물 저장",
|
||||
"form.integration.karakeep_api_key": "Karakeep API 키",
|
||||
"form.integration.karakeep_url": "Karakeep API 엔드포인트",
|
||||
"form.integration.karakeep_tags": "Karakeep 태그",
|
||||
"form.integration.linkace_activate": "게시물을 LinkAce에 저장",
|
||||
"form.integration.linkace_api_key": "LinkAce API 키",
|
||||
"form.integration.linkace_check_disabled": "링크 확인 비활성화",
|
||||
"form.integration.linkace_endpoint": "LinkAce API 엔드포인트",
|
||||
"form.integration.linkace_is_private": "링크를 비공개로 설정",
|
||||
"form.integration.linkace_tags": "LinkAce 태그",
|
||||
"form.integration.linkding_activate": "Linkding에 게시물 저장",
|
||||
"form.integration.linkding_api_key": "Linkding API 키",
|
||||
"form.integration.linkding_bookmark": "북마크를 읽지 않음으로 표시",
|
||||
"form.integration.linkding_endpoint": "Linkding API 엔드포인트",
|
||||
"form.integration.linkding_tags": "Linkding 태그",
|
||||
"form.integration.linktaco_activate": "LinkTaco에 게시물 저장",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API 토큰",
|
||||
"form.integration.linktaco_api_token_hint": "개인용 액세스 토큰 받기",
|
||||
"form.integration.linktaco_org_slug": "조직 슬러그",
|
||||
"form.integration.linktaco_tags": "태그(최대 10개, 쉼표로 구분)",
|
||||
"form.integration.linktaco_tags_hint": "최대 10개의 태그, 쉼표로 구분",
|
||||
"form.integration.linktaco_visibility": "공개 설정",
|
||||
"form.integration.linktaco_visibility_public": "공개",
|
||||
"form.integration.linktaco_visibility_private": "비공개",
|
||||
"form.integration.linktaco_visibility_hint": "비공개 설정에는 유료 LinkTaco 계정이 필요합니다",
|
||||
"form.integration.linkwarden_activate": "Linkwarden에 게시물 저장",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API 키",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden 기본 URL",
|
||||
"form.integration.linkwarden_collection_id": "Linkwarden 컬렉션 ID",
|
||||
"form.integration.matrix_bot_activate": "새 게시물을 Matrix로 전달",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 룸 ID",
|
||||
"form.integration.matrix_bot_password": "Matrix 사용자 비밀번호",
|
||||
"form.integration.matrix_bot_url": "Matrix 서버 URL",
|
||||
"form.integration.matrix_bot_user": "Matrix 사용자명",
|
||||
"form.integration.notion_activate": "게시물을 Notion에 저장",
|
||||
"form.integration.notion_page_id": "Notion 페이지 ID",
|
||||
"form.integration.notion_token": "Notion 시크릿 토큰",
|
||||
"form.integration.ntfy_activate": "게시물을 ntfy로 전송",
|
||||
"form.integration.ntfy_api_token": "ntfy API 토큰(선택 사항)",
|
||||
"form.integration.ntfy_icon_url": "ntfy 아이콘 URL(선택 사항)",
|
||||
"form.integration.ntfy_internal_links": "클릭 시 내부 링크 사용(선택 사항)",
|
||||
"form.integration.ntfy_password": "ntfy 비밀번호(선택 사항)",
|
||||
"form.integration.ntfy_topic": "ntfy 토픽(피드에 설정되어 있지 않으면 기본값)",
|
||||
"form.integration.ntfy_url": "ntfy URL(선택 사항, 기본값 ntfy.sh)",
|
||||
"form.integration.ntfy_username": "ntfy 사용자명(선택 사항)",
|
||||
"form.integration.nunux_keeper_activate": "Nunux Keeper에 게시물 저장",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 키",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 엔드포인트",
|
||||
"form.integration.omnivore_activate": "Omnivore에 게시물 저장",
|
||||
"form.integration.omnivore_api_key": "Omnivore API 키",
|
||||
"form.integration.omnivore_url": "Omnivore API 엔드포인트",
|
||||
"form.integration.pinboard_activate": "Pinboard에 게시물 저장",
|
||||
"form.integration.pinboard_bookmark": "북마크를 읽지 않음으로 표시",
|
||||
"form.integration.pinboard_tags": "Pinboard 태그",
|
||||
"form.integration.pinboard_token": "Pinboard API 토큰",
|
||||
"form.integration.pushover_activate": "게시물을 Pushover로 전송",
|
||||
"form.integration.pushover_device": "Pushover 기기(선택 사항)",
|
||||
"form.integration.pushover_prefix": "Pushover URL 접두사(선택 사항)",
|
||||
"form.integration.pushover_token": "Pushover 앱 API 토큰",
|
||||
"form.integration.pushover_user": "Pushover 사용자 키",
|
||||
"form.integration.raindrop_activate": "게시물을 Raindrop에 저장",
|
||||
"form.integration.raindrop_collection_id": "컬렉션 ID",
|
||||
"form.integration.raindrop_tags": "태그(쉼표로 구분)",
|
||||
"form.integration.raindrop_token": "(테스트) 토큰",
|
||||
"form.integration.readeck_activate": "Readeck에 게시물 저장",
|
||||
"form.integration.readeck_api_key": "Readeck API 키",
|
||||
"form.integration.readeck_endpoint": "Readeck API 엔드포인트",
|
||||
"form.integration.readeck_labels": "Readeck 라벨",
|
||||
"form.integration.readeck_only_url": "URL만 전송(전체 콘텐츠가 아님)",
|
||||
"form.integration.readeck_push_activate": "새 게시물을 자동으로 Readeck에 전송",
|
||||
"form.integration.readwise_activate": "Readwise Reader에 게시물 저장",
|
||||
"form.integration.readwise_api_key": "Readwise Reader 액세스 토큰",
|
||||
"form.integration.readwise_api_key_link": "Readwise 액세스 토큰 받기",
|
||||
"form.integration.rssbridge_activate": "구독 추가 시 RSS-Bridge 확인",
|
||||
"form.integration.rssbridge_token": "RSS-Bridge 인증 토큰",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge 서버 URL",
|
||||
"form.integration.shaarli_activate": "게시물을 Shaarli에 저장",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API 시크릿",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shiori_activate": "게시물을 Shiori에 저장",
|
||||
"form.integration.shiori_endpoint": "Shiori API 엔드포인트",
|
||||
"form.integration.shiori_password": "Shiori 비밀번호",
|
||||
"form.integration.shiori_username": "Shiori 사용자명",
|
||||
"form.integration.slack_activate": "게시물을 Slack으로 전송",
|
||||
"form.integration.slack_webhook_link": "Slack Webhook 링크",
|
||||
"form.integration.telegram_bot_activate": "새 게시물을 Telegram 채팅으로 푸시",
|
||||
"form.integration.telegram_bot_disable_buttons": "버튼 비활성화",
|
||||
"form.integration.telegram_bot_disable_notification": "알림 비활성화",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "웹 페이지 미리보기 비활성화",
|
||||
"form.integration.telegram_bot_token": "봇 토큰",
|
||||
"form.integration.telegram_chat_id": "채팅 ID",
|
||||
"form.integration.telegram_topic_id": "토픽 ID",
|
||||
"form.integration.wallabag_activate": "Wallabag에 게시물 저장",
|
||||
"form.integration.wallabag_client_id": "Wallabag 클라이언트 ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag 클라이언트 시크릿",
|
||||
"form.integration.wallabag_endpoint": "Wallabag 기본 URL",
|
||||
"form.integration.wallabag_only_url": "URL만 전송(전체 콘텐츠가 아님)",
|
||||
"form.integration.wallabag_password": "Wallabag 비밀번호",
|
||||
"form.integration.wallabag_username": "Wallabag 사용자명",
|
||||
"form.integration.wallabag_tags": "Wallabag 태그",
|
||||
"form.integration.webhook_activate": "Webhook 활성화",
|
||||
"form.integration.webhook_secret": "Webhook 시크릿",
|
||||
"form.integration.webhook_url": "기본 Webhook URL",
|
||||
"form.prefs.fieldset.application_settings": "애플리케이션 설정",
|
||||
"form.prefs.fieldset.authentication_settings": "비밀번호 인증",
|
||||
"form.prefs.fieldset.google_authentication": "Google 인증",
|
||||
"form.prefs.fieldset.oidc_authentication": "%s 인증",
|
||||
"form.prefs.fieldset.global_feed_settings": "전역 피드 설정",
|
||||
"form.prefs.fieldset.reader_settings": "리더 설정",
|
||||
"form.prefs.help.external_font_hosts": "허용할 외부 폰트 호스트를 공백으로 구분해 지정합니다. 예: \"fonts.gstatic.com fonts.googleapis.com\"",
|
||||
"form.prefs.label.always_open_external_links": "외부 링크를 열어 게시물 읽기",
|
||||
"form.prefs.label.categories_sorting_order": "카테고리 표시 순서",
|
||||
"form.prefs.label.cjk_reading_speed": "한국어, 일본어, 중국어 읽기 속도 (문자/분)",
|
||||
"form.prefs.label.custom_css": "사용자 지정 CSS",
|
||||
"form.prefs.label.custom_js": "사용자 지정 JavaScript",
|
||||
"form.prefs.label.default_home_page": "기본 시작 페이지",
|
||||
"form.prefs.label.default_reading_speed": "다른 언어의 읽기 속도(단어/분)",
|
||||
"form.prefs.label.display_mode": "프로그레시브 웹 앱(PWA) 표시 모드",
|
||||
"form.prefs.label.entries_per_page": "페이지당 게시물 수",
|
||||
"form.prefs.label.entry_order": "게시물 표시 순서 기준",
|
||||
"form.prefs.label.entry_sorting": "게시물 표시 순서",
|
||||
"form.prefs.label.entry_swipe": "터치스크린에서 스와이프 입력 활성화",
|
||||
"form.prefs.label.external_font_hosts": "외부 폰트 호스트",
|
||||
"form.prefs.label.gesture_nav": "게시물 간 이동 제스처",
|
||||
"form.prefs.label.keyboard_shortcuts": "키보드 단축키 활성화",
|
||||
"form.prefs.label.language": "언어",
|
||||
"form.prefs.label.mark_read_manually": "수동으로 읽음 처리",
|
||||
"form.prefs.label.mark_read_on_media_completion": "오디오/비디오 재생이 90%%에 도달하면 읽음 처리",
|
||||
"form.prefs.label.mark_read_on_view": "표시할 때 게시물을 자동으로 읽음으로 표시",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "표시할 때 읽음 처리. 오디오/비디오는 90%% 재생 시 읽음 처리",
|
||||
"form.prefs.label.media_playback_rate": "오디오/비디오 재생 속도",
|
||||
"form.prefs.label.open_external_links_in_new_tab": "외부 링크를 새 탭에서 열기(링크에 target=\"_blank\" 추가)",
|
||||
"form.prefs.label.show_reading_time": "게시물 예상 읽기 시간 표시",
|
||||
"form.prefs.label.theme": "테마",
|
||||
"form.prefs.label.timezone": "시간대",
|
||||
"form.prefs.select.alphabetical": "알파벳순",
|
||||
"form.prefs.select.browser": "브라우저형",
|
||||
"form.prefs.select.created_time": "게시물 가져온 시각",
|
||||
"form.prefs.select.fullscreen": "전체 화면",
|
||||
"form.prefs.select.minimal_ui": "미니멀 UI",
|
||||
"form.prefs.select.none": "없음",
|
||||
"form.prefs.select.older_first": "오래된 게시물 먼저",
|
||||
"form.prefs.select.publish_time": "게시물 공개 시각",
|
||||
"form.prefs.select.recent_first": "새 게시물 먼저",
|
||||
"form.prefs.select.standalone": "독립형",
|
||||
"form.prefs.select.swipe": "스와이프",
|
||||
"form.prefs.select.tap": "더블 탭",
|
||||
"form.prefs.select.unread_count": "읽지 않은 항목 수",
|
||||
"form.submit.loading": "불러오는 중…",
|
||||
"form.submit.saving": "저장 중…",
|
||||
"form.user.label.admin": "관리자",
|
||||
"form.user.label.confirmation": "비밀번호 확인",
|
||||
"form.user.label.password": "비밀번호",
|
||||
"form.user.label.username": "사용자명",
|
||||
"menu.about": "소프트웨어 정보",
|
||||
"menu.add_feed": "피드 구독",
|
||||
"menu.add_user": "사용자 추가",
|
||||
"menu.api_keys": "API 키",
|
||||
"menu.categories": "카테고리",
|
||||
"menu.create_api_key": "새 API 키 만들기",
|
||||
"menu.create_category": "카테고리 만들기",
|
||||
"menu.edit_category": "편집",
|
||||
"menu.edit_feed": "편집",
|
||||
"menu.export": "내보내기",
|
||||
"menu.feed_entries": "게시물 목록",
|
||||
"menu.feeds": "피드 목록",
|
||||
"menu.flush_history": "기록 지우기",
|
||||
"menu.history": "기록",
|
||||
"menu.home_page": "홈페이지",
|
||||
"menu.import": "가져오기",
|
||||
"menu.integrations": "연동",
|
||||
"menu.logout": "로그아웃",
|
||||
"menu.mark_all_as_read": "모두 읽음으로 표시",
|
||||
"menu.mark_page_as_read": "이 페이지를 읽음으로 표시",
|
||||
"menu.preferences": "설정 정보",
|
||||
"menu.refresh_all_feeds": "모든 피드를 백그라운드에서 새로고침",
|
||||
"menu.refresh_feed": "새로고침",
|
||||
"menu.search": "검색",
|
||||
"menu.sessions": "세션",
|
||||
"menu.settings": "설정",
|
||||
"menu.shared_entries": "공유 게시물",
|
||||
"menu.show_all_entries": "모든 게시물 표시",
|
||||
"menu.show_only_starred_entries": "즐겨찾기만 표시",
|
||||
"menu.show_only_unread_entries": "읽지 않은 게시물만 표시",
|
||||
"menu.starred": "즐겨찾기",
|
||||
"menu.title": "메뉴",
|
||||
"menu.unread": "읽지 않음",
|
||||
"menu.users": "사용자 목록",
|
||||
"page.about.authors_label": "작성자:",
|
||||
"page.about.authors_value": "Frédéric Guillot 및 기여자",
|
||||
"page.about.build_date": "빌드 일시:",
|
||||
"page.about.credits": "저작권 표시",
|
||||
"page.about.db_usage": "데이터베이스 크기:",
|
||||
"page.about.git_commit": "Git 커밋:",
|
||||
"page.about.global_config_options": "전역 설정 옵션",
|
||||
"page.about.go_version": "Go 버전:",
|
||||
"page.about.license": "라이선스:",
|
||||
"page.about.postgres_version": "Postgres 버전:",
|
||||
"page.about.title": "소프트웨어 정보",
|
||||
"page.about.version": "버전:",
|
||||
"page.add_feed.choose_feed": "피드 선택",
|
||||
"page.add_feed.label.url": "피드 URL",
|
||||
"page.add_feed.legend.advanced_options": "고급 설정",
|
||||
"page.add_feed.no_category": "카테고리가 없습니다. 카테고리가 최소 1개 필요합니다.",
|
||||
"page.add_feed.submit": "피드 탐색 및 추가",
|
||||
"page.add_feed.title": "새 피드",
|
||||
"page.api_keys.never_used": "사용된 적 없음",
|
||||
"page.api_keys.table.actions": "액션",
|
||||
"page.api_keys.table.created_at": "생성일",
|
||||
"page.api_keys.table.description": "설명",
|
||||
"page.api_keys.table.last_used_at": "마지막 사용",
|
||||
"page.api_keys.table.token": "토큰",
|
||||
"page.api_keys.title": "API 키",
|
||||
"page.categories.entries": "게시물 목록",
|
||||
"page.categories.feed_count": [
|
||||
"피드가 %d개 있습니다."
|
||||
],
|
||||
"page.categories.feeds": "피드 목록",
|
||||
"page.categories.no_feed": "피드가 없습니다.",
|
||||
"page.categories.title": "카테고리",
|
||||
"page.categories_count": [
|
||||
"카테고리 %d개"
|
||||
],
|
||||
"page.category_label": "카테고리: %s",
|
||||
"page.edit_category.title": "카테고리 편집: %s",
|
||||
"page.edit_feed.etag_header": "ETag 헤더:",
|
||||
"page.edit_feed.last_check": "마지막 확인:",
|
||||
"page.edit_feed.last_modified_header": "Last-Modified 헤더:",
|
||||
"page.edit_feed.last_parsing_error": "최근 파싱 오류",
|
||||
"page.edit_feed.no_header": "없음",
|
||||
"page.edit_feed.title": "피드 편집: %s",
|
||||
"page.edit_user.title": "사용자 편집: %s",
|
||||
"page.entry.attachments": "첨부 파일",
|
||||
"page.feeds.error_count": [
|
||||
"오류 %d개"
|
||||
],
|
||||
"page.feeds.last_check": "마지막 확인:",
|
||||
"page.feeds.next_check": "다음 확인:",
|
||||
"page.feeds.read_counter": "읽은 게시물 수",
|
||||
"page.feeds.title": "피드 목록",
|
||||
"page.footer.elevator": "페이지 맨 위로 올라가기",
|
||||
"page.history.title": "기록",
|
||||
"page.import.title": "가져오기",
|
||||
"page.integration.bookmarklet": "북마크릿",
|
||||
"page.integration.bookmarklet.help": "이 특별한 링크를 사용하면 브라우저에서 직접 웹사이트의 피드를 구독할 수 있습니다.",
|
||||
"page.integration.bookmarklet.instructions": "이 링크를 브라우저 북마크로 드래그하세요.",
|
||||
"page.integration.bookmarklet.name": "Miniflux에 추가",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api_endpoint": "API 엔드포인트",
|
||||
"page.integration.miniflux_api_password": "비밀번호",
|
||||
"page.integration.miniflux_api_password_value": "계정 비밀번호",
|
||||
"page.integration.miniflux_api_username": "사용자명",
|
||||
"page.integrations.title": "연동",
|
||||
"page.keyboard_shortcuts.close_modal": "모달 대화상자 닫기",
|
||||
"page.keyboard_shortcuts.download_content": "원본 내용 다운로드",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "가장 아래 게시물로 이동",
|
||||
"page.keyboard_shortcuts.go_to_categories": "카테고리",
|
||||
"page.keyboard_shortcuts.go_to_feed": "피드",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "피드 목록",
|
||||
"page.keyboard_shortcuts.go_to_history": "기록",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "다음 게시물",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "다음 페이지",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "이전 게시물",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "이전 페이지",
|
||||
"page.keyboard_shortcuts.go_to_search": "검색 폼으로 이동",
|
||||
"page.keyboard_shortcuts.go_to_settings": "설정",
|
||||
"page.keyboard_shortcuts.go_to_starred": "즐겨찾기",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "맨 위 게시물로 이동",
|
||||
"page.keyboard_shortcuts.go_to_unread": "읽지 않음",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "현재 페이지의 게시물을 모두 읽음으로 표시",
|
||||
"page.keyboard_shortcuts.open_comments": "댓글 링크 열기",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "현재 탭에서 댓글 링크 열기",
|
||||
"page.keyboard_shortcuts.open_item": "선택한 게시물 열기",
|
||||
"page.keyboard_shortcuts.open_original": "원본 링크 열기",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "현재 탭에서 원본 링크 열기",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "모든 피드를 백그라운드에서 새로고침",
|
||||
"page.keyboard_shortcuts.remove_feed": "이 피드 삭제",
|
||||
"page.keyboard_shortcuts.save_article": "게시물 저장",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "게시물이 상단에 오도록 스크롤",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "키보드 단축키 표시",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "작업",
|
||||
"page.keyboard_shortcuts.subtitle.items": "게시물 간 이동",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "페이지 간 이동",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "섹션 이동",
|
||||
"page.keyboard_shortcuts.title": "키보드 단축키",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "즐겨찾기 표시/해제",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "첨부 파일 열기/닫기",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "읽음/읽지 않음 전환 후 다음 게시물로 이동",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "읽음/읽지 않음 전환 후 이전 게시물로 이동",
|
||||
"page.login.google_signin": "Google 계정으로 로그인",
|
||||
"page.login.oidc_signin": "%s 계정으로 로그인",
|
||||
"page.login.title": "로그인",
|
||||
"page.login.webauthn_login": "패스키로 로그인",
|
||||
"page.login.webauthn_login.error": "패스키로 로그인할 수 없음",
|
||||
"page.new_api_key.title": "새 API 키",
|
||||
"page.new_category.title": "새 카테고리",
|
||||
"page.new_user.title": "새 사용자",
|
||||
"page.offline.message": "오프라인입니다",
|
||||
"page.offline.refresh_page": "페이지를 새로 고쳐 보세요",
|
||||
"page.offline.title": "오프라인 모드",
|
||||
"page.read_entry_count": [
|
||||
"읽은 게시물 %d개"
|
||||
],
|
||||
"page.search.title": "검색 결과",
|
||||
"page.sessions.table.actions": "작업",
|
||||
"page.sessions.table.current_session": "현재 세션",
|
||||
"page.sessions.table.date": "날짜",
|
||||
"page.sessions.table.ip": "IP 주소",
|
||||
"page.sessions.table.user_agent": "User Agent",
|
||||
"page.sessions.title": "세션",
|
||||
"page.settings.link_google_account": "Google 계정과 연동",
|
||||
"page.settings.link_oidc_account": "%s 계정과 연동",
|
||||
"page.settings.title": "설정",
|
||||
"page.settings.unlink_google_account": "Google 계정과 연동 해제",
|
||||
"page.settings.unlink_oidc_account": "%s 계정과 연동 해제",
|
||||
"page.settings.webauthn.actions": "작업",
|
||||
"page.settings.webauthn.added_on": "추가일",
|
||||
"page.settings.webauthn.delete": [
|
||||
"패스키 %d개 삭제"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "마지막 사용일",
|
||||
"page.settings.webauthn.passkey_name": "패스키 이름",
|
||||
"page.settings.webauthn.passkeys": "패스키 인증",
|
||||
"page.settings.webauthn.register": "패스키 등록",
|
||||
"page.settings.webauthn.register.error": "패스키를 등록할 수 없습니다",
|
||||
"page.shared_entries.title": "공유 게시물",
|
||||
"page.shared_entries_count": [
|
||||
"공유 게시물 %d개"
|
||||
],
|
||||
"page.starred.title": "즐겨찾기",
|
||||
"page.starred_entry_count": [
|
||||
"즐겨찾기 표시된 게시물 %d개"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"총 게시물 %d개"
|
||||
],
|
||||
"page.unread.title": "읽지 않음",
|
||||
"page.unread_entry_count": [
|
||||
"읽지 않은 게시물 %d개"
|
||||
],
|
||||
"page.users.actions": "작업",
|
||||
"page.users.admin.no": "아니오",
|
||||
"page.users.admin.yes": "예",
|
||||
"page.users.is_admin": "관리자",
|
||||
"page.users.last_login": "마지막 로그인",
|
||||
"page.users.never_logged": "로그인 기록 없음",
|
||||
"page.users.title": "사용자 목록",
|
||||
"page.users.username": "사용자명",
|
||||
"page.webauthn_rename.title": "패스키 이름 변경",
|
||||
"pagination.first": "처음",
|
||||
"pagination.last": "마지막",
|
||||
"pagination.next": "다음",
|
||||
"pagination.previous": "이전",
|
||||
"search.label": "검색",
|
||||
"search.placeholder": "… 검색",
|
||||
"search.submit": "검색",
|
||||
"skip_to_content": "콘텐츠로 건너뛰기",
|
||||
"time_elapsed.days": [
|
||||
"%d일 전"
|
||||
],
|
||||
"time_elapsed.hours": [
|
||||
"%d시간 전"
|
||||
],
|
||||
"time_elapsed.minutes": [
|
||||
"%d분 전"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"%d개월 전"
|
||||
],
|
||||
"time_elapsed.not_yet": "미래",
|
||||
"time_elapsed.now": "지금",
|
||||
"time_elapsed.weeks": [
|
||||
"%d주 전"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
"%d년 전"
|
||||
],
|
||||
"time_elapsed.yesterday": "어제",
|
||||
"tooltip.keyboard_shortcuts": "키보드 단축키: %s",
|
||||
"tooltip.logged_user": "%s로 로그인 중"
|
||||
}
|
||||
@@ -103,6 +103,7 @@
|
||||
"error.http_bad_gateway": "Chit ê bāng-chām chit-má in-ūi gateway ū būn-tôe bô-hoat-tō͘ iōng, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
|
||||
"error.http_body_read": "Bô-hoat-tō͘ tha̍k HTTP body lōe-iông: %v。",
|
||||
"error.http_client_error": "HTTP kheh-hō͘ thâu ū m̄-tio̍h: %v.",
|
||||
"error.http_cloudflare_challenge": "Chit ê bāng-chām hō͘ Cloudflare ê bot thiau-chiàn (CAPTCHA ah-sī JavaScript giām-chèng) pó-hō͘. Miniflux bô-hoat-tō͘ chū-tōng kái-koat chit ê thiau-chiàn.",
|
||||
"error.http_empty_response": "HTTP hôe-èng lōe-iông sī khang--ê, ū khó-lêng sī hit ê bāng-chām ū pó-hō͘ ki-chè.",
|
||||
"error.http_empty_response_body": "HTTP hôe-èng body sī khang--ê.",
|
||||
"error.http_forbidden": "Hō͘ kū-choa̍t chûn-chhú chit ê bāng-chām, ū khó-lêng chit ê bāng-chām ū pó-hō͘ ki-chè.",
|
||||
@@ -340,7 +341,9 @@
|
||||
"form.integration.webhook_secret": "Webhooks bí-miâ",
|
||||
"form.integration.webhook_url": "Koán-tē Webhook bāng-chí",
|
||||
"form.prefs.fieldset.application_settings": "Èng-iōng thêng-sek siat-tēng",
|
||||
"form.prefs.fieldset.authentication_settings": "Sú-iōng-lâng giām-chèng siat-tēng",
|
||||
"form.prefs.fieldset.authentication_settings": "Bi̍t-bé giām-chèng",
|
||||
"form.prefs.fieldset.google_authentication": "Google giām-chèng",
|
||||
"form.prefs.fieldset.oidc_authentication": "%s giām-chèng",
|
||||
"form.prefs.fieldset.global_feed_settings": "Choân-he̍k siau-sit lâi-goân siat-tēng",
|
||||
"form.prefs.fieldset.reader_settings": "Ia̍t-tha̍k khì siat-tēng",
|
||||
"form.prefs.help.external_font_hosts": "Iōng khang-keh keh khui ún-chún ê gōa-pō͘ lī-hêng lâi-goân. Phì-lû \"fonts.gstatic.com fonts.googleapis.com\"",
|
||||
@@ -422,7 +425,8 @@
|
||||
"menu.title": "Tō-lám",
|
||||
"menu.unread": "Ah-bōe tha̍k",
|
||||
"menu.users": "Sú-iōng-lâng",
|
||||
"page.about.author": "Chok-chiá: ",
|
||||
"page.about.authors_label": "Chok-chiá: ",
|
||||
"page.about.authors_value": "Frédéric Guillot kap kòng-hiàn-chiá",
|
||||
"page.about.build_date": "Kiàn-tì li̍t-kî:",
|
||||
"page.about.credits": "Pán-koân",
|
||||
"page.about.db_usage": "Database chhài-chhiú:",
|
||||
@@ -527,7 +531,6 @@
|
||||
"page.login.title": "teng-lo̍k",
|
||||
"page.login.webauthn_login": "Sú-iōng bi̍t-bé teng-lo̍k",
|
||||
"page.login.webauthn_login.error": "Bô-hoat-tō͘ iōng bi̍t-bé teng-lo̍k",
|
||||
"page.login.webauthn_login.help": "Sú-iōng an-choân só-sî teng-lo̍k ê sî-chūn, chhiáⁿ su-li̍p kháu-chō miâ. Nā-sī iōng thang chhiau-chhē ê Passkey (discoverable credentials) tio̍h bián.",
|
||||
"page.new_api_key.title": "Sin ê API só-sî",
|
||||
"page.new_category.title": "Sin lūi-pia̍t",
|
||||
"page.new_user.title": "Sin sú-iōng-lâng",
|
||||
@@ -556,7 +559,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Siōng-bóe pái sú-iōng sî-kan",
|
||||
"page.settings.webauthn.passkey_name": "Passkey miâ",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.passkeys": "Passkey giām-chèng",
|
||||
"page.settings.webauthn.register": "Chù-chheh Passkey",
|
||||
"page.settings.webauthn.register.error": "Bô-hoat-tō͘ chù-chheh Passkey",
|
||||
"page.shared_entries.title": "Hun-hióng kè ê siau-sit",
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"error.http_bad_gateway": "De website is momenteel niet beschikbaar vanwege een slechte-gateway-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_body_read": "Kan de HTTP-body niet lezen: %v.",
|
||||
"error.http_client_error": "HTTP-client-fout: %v.",
|
||||
"error.http_cloudflare_challenge": "Deze website wordt beschermd door een Cloudflare-botuitdaging (CAPTCHA of JavaScript-verificatie). Miniflux kan deze uitdaging niet automatisch oplossen.",
|
||||
"error.http_empty_response": "De HTTP-respons is leeg. Misschien gebruikt deze website een botbeveiligingsmechanisme?",
|
||||
"error.http_empty_response_body": "De HTTP-respons body is leeg.",
|
||||
"error.http_forbidden": "Toegang tot deze website is verboden. Misschien heeft deze website een botbeveiligingsmechanisme?",
|
||||
@@ -343,7 +344,9 @@
|
||||
"form.integration.webhook_secret": "Webhooks geheim",
|
||||
"form.integration.webhook_url": "Standaard Webhook-URL",
|
||||
"form.prefs.fieldset.application_settings": "Applicatie Instellingen",
|
||||
"form.prefs.fieldset.authentication_settings": "Authenticatie Instellingen",
|
||||
"form.prefs.fieldset.authentication_settings": "Wachtwoordauthenticatie",
|
||||
"form.prefs.fieldset.google_authentication": "Google-authenticatie",
|
||||
"form.prefs.fieldset.oidc_authentication": "%s-authenticatie",
|
||||
"form.prefs.fieldset.global_feed_settings": "Globale Feed Instellingen",
|
||||
"form.prefs.fieldset.reader_settings": "Lees Instellingen",
|
||||
"form.prefs.help.external_font_hosts": "Spatiegescheiden lijst van externe font-hosts die zijn toegestaan. Bijvoorbeeld: 'fonts.gstatic.com fonts.googleapis.com'.",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "Menu",
|
||||
"menu.unread": "Ongelezen",
|
||||
"menu.users": "Gebruikers",
|
||||
"page.about.author": "Auteur:",
|
||||
"page.about.authors_label": "Auteurs:",
|
||||
"page.about.authors_value": "Frédéric Guillot en bijdragers",
|
||||
"page.about.build_date": "Compilatiedatum:",
|
||||
"page.about.credits": "Credits",
|
||||
"page.about.db_usage": "Databasegrootte:",
|
||||
@@ -533,7 +537,6 @@
|
||||
"page.login.title": "Inloggen",
|
||||
"page.login.webauthn_login": "Inloggen met passkey",
|
||||
"page.login.webauthn_login.error": "Kan niet inloggen met passkey",
|
||||
"page.login.webauthn_login.help": "Voer je gebruikersnaam in als je een beveiligingssleutel gebruikt. Dit is niet nodig als je een Passkey (ontdekkingsbare referenties) gebruikt.",
|
||||
"page.new_api_key.title": "Nieuwe API-sleutel",
|
||||
"page.new_category.title": "Nieuwe categorie",
|
||||
"page.new_user.title": "Nieuwe gebruiker",
|
||||
@@ -564,7 +567,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Laatst gebruikt",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Naam",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.passkeys": "Passkey-authenticatie",
|
||||
"page.settings.webauthn.register": "Passkey registreren",
|
||||
"page.settings.webauthn.register.error": "Kan passkey niet registreren",
|
||||
"page.shared_entries.title": "Gedeelde artikelen",
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
"error.http_bad_gateway": "Strona jest w tej chwili niedostępna z powodu błędu nieprawidłowej bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
|
||||
"error.http_body_read": "Nie można odczytać treści HTTP: %v.",
|
||||
"error.http_client_error": "Błąd klienta HTTP: %v.",
|
||||
"error.http_cloudflare_challenge": "Ta strona jest chroniona przez wyzwanie botowe Cloudflare (CAPTCHA lub weryfikacja JavaScript). Miniflux nie może rozwiązać tego wyzwania automatycznie.",
|
||||
"error.http_empty_response": "Odpowiedź HTTP jest pusta. Być może ta witryna korzysta z mechanizmu ochrony przed botami?",
|
||||
"error.http_empty_response_body": "Treść odpowiedzi HTTP jest pusta.",
|
||||
"error.http_forbidden": "Dostęp do tej strony jest zabroniony. Być może ta strona ma mechanizm zabezpieczający przed botami?",
|
||||
@@ -346,7 +347,9 @@
|
||||
"form.integration.webhook_secret": "Tajny klucz do webhooków",
|
||||
"form.integration.webhook_url": "Domyślny adres URL webhooka",
|
||||
"form.prefs.fieldset.application_settings": "Ustawienia aplikacji",
|
||||
"form.prefs.fieldset.authentication_settings": "Ustawienia uwierzytelniania",
|
||||
"form.prefs.fieldset.authentication_settings": "Uwierzytelnianie hasłem",
|
||||
"form.prefs.fieldset.google_authentication": "Uwierzytelnianie Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "Uwierzytelnianie %s",
|
||||
"form.prefs.fieldset.global_feed_settings": "Globalne ustawienia kanałów",
|
||||
"form.prefs.fieldset.reader_settings": "Ustawienia czytnika",
|
||||
"form.prefs.help.external_font_hosts": "Lista hostów zewnętrznych czcionek, na które należy zezwolić, rozdzielona spacjami. Na przykład: „fonts.gstatic.com fonts.googleapis.com”.",
|
||||
@@ -428,7 +431,8 @@
|
||||
"menu.title": "Menu",
|
||||
"menu.unread": "Nieprzeczytane",
|
||||
"menu.users": "Użytkownicy",
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.authors_label": "Autorzy:",
|
||||
"page.about.authors_value": "Frédéric Guillot i współtwórcy",
|
||||
"page.about.build_date": "Data opracowania:",
|
||||
"page.about.credits": "Prawa autorskie",
|
||||
"page.about.db_usage": "Rozmiar bazy danych:",
|
||||
@@ -539,7 +543,6 @@
|
||||
"page.login.title": "Zaloguj się",
|
||||
"page.login.webauthn_login": "Zaloguj się przez klucz dostępu",
|
||||
"page.login.webauthn_login.error": "Nie można zalogować się za pomocą klucza dostępu",
|
||||
"page.login.webauthn_login.help": "Wpisz swoją nazwę użytkownika, jeśli używasz klucza bezpieczeństwa. Nie jest to wymagane, jeśli używasz klucza dostępu (wykrywalnych danych uwierzytelniających).",
|
||||
"page.new_api_key.title": "Nowy klucz API",
|
||||
"page.new_category.title": "Nowa kategoria",
|
||||
"page.new_user.title": "Nowy użytkownik",
|
||||
@@ -572,7 +575,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Ostatnio użyte",
|
||||
"page.settings.webauthn.passkey_name": "Nazwa klucza dostępu",
|
||||
"page.settings.webauthn.passkeys": "Klucze dostępu",
|
||||
"page.settings.webauthn.passkeys": "Uwierzytelnianie kluczem dostępu",
|
||||
"page.settings.webauthn.register": "Zarejestruj klucz dostępu",
|
||||
"page.settings.webauthn.register.error": "Nie można zarejestrować klucza dostępu",
|
||||
"page.shared_entries.title": "Udostępnione wpisy",
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"error.http_bad_gateway": "O site não está disponível no momento devido a um erro de gateway. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
|
||||
"error.http_body_read": "Não foi possível ler o corpo HTTP: %v.",
|
||||
"error.http_client_error": "Erro do cliente HTTP: %v.",
|
||||
"error.http_cloudflare_challenge": "Este site é protegido por um desafio de bot do Cloudflare (CAPTCHA ou verificação JavaScript). O Miniflux não consegue resolver este desafio automaticamente.",
|
||||
"error.http_empty_response": "A resposta HTTP está vazia. Talvez este site esteja usando um mecanismo de proteção contra bots?",
|
||||
"error.http_empty_response_body": "O corpo da resposta HTTP está vazio.",
|
||||
"error.http_forbidden": "O acesso a este site está proibido. Talvez este site tenha um mecanismo de proteção contra bots?",
|
||||
@@ -343,7 +344,9 @@
|
||||
"form.integration.webhook_secret": "Segredo dos Webhooks",
|
||||
"form.integration.webhook_url": "URL padrão do Webhook",
|
||||
"form.prefs.fieldset.application_settings": "Configurações do aplicativo",
|
||||
"form.prefs.fieldset.authentication_settings": "Configurações de autenticação",
|
||||
"form.prefs.fieldset.authentication_settings": "Autenticação por senha",
|
||||
"form.prefs.fieldset.google_authentication": "Autenticação Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "Autenticação %s",
|
||||
"form.prefs.fieldset.global_feed_settings": "Configurações globais de fontes",
|
||||
"form.prefs.fieldset.reader_settings": "Configurações do leitor",
|
||||
"form.prefs.help.external_font_hosts": "Lista separada por espaço de hosts de fontes externas permitidos. Por exemplo: 'fonts.gstatic.com fonts.googleapis.com'.",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "Menu",
|
||||
"menu.unread": "Não lido",
|
||||
"menu.users": "Usuários",
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.authors_label": "Autores:",
|
||||
"page.about.authors_value": "Frédéric Guillot e contribuidores",
|
||||
"page.about.build_date": "Compilado em:",
|
||||
"page.about.credits": "Créditos",
|
||||
"page.about.db_usage": "Tamanho do banco de dados:",
|
||||
@@ -533,7 +537,6 @@
|
||||
"page.login.title": "Iniciar Sessão",
|
||||
"page.login.webauthn_login": "Entrar com senha",
|
||||
"page.login.webauthn_login.error": "Não é possível fazer login com senha",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.new_api_key.title": "Nova chave de API",
|
||||
"page.new_category.title": "Nova categoria",
|
||||
"page.new_user.title": "Novo usuário",
|
||||
@@ -564,7 +567,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Último uso",
|
||||
"page.settings.webauthn.passkey_name": "Nome da senha",
|
||||
"page.settings.webauthn.passkeys": "Senhas",
|
||||
"page.settings.webauthn.passkeys": "Autenticação por chave de acesso",
|
||||
"page.settings.webauthn.register": "Registrar senha",
|
||||
"page.settings.webauthn.register.error": "Não foi possível registrar a senha",
|
||||
"page.shared_entries.title": "Itens compartilhados",
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
"error.http_bad_gateway": "Acest site web nu este disponibil momentan din cauza unei erori generată de gateway. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
|
||||
"error.http_body_read": "Nu pot citi corpul HTTP: %v.",
|
||||
"error.http_client_error": "Eroare client HTTP: %v.",
|
||||
"error.http_cloudflare_challenge": "Acest site web este protejat de o provocare bot Cloudflare (CAPTCHA sau verificare JavaScript). Miniflux nu poate rezolva această provocare în mod automat.",
|
||||
"error.http_empty_response": "Răspunsul HTTP este gol. Poate acest site web utilizează un mecanism împotriva boților?",
|
||||
"error.http_empty_response_body": "Corpul răspunsului HTTP este gol.",
|
||||
"error.http_forbidden": "Accesul la acest site web este interzis. Poate acesta utilizează un mecanism împotriva boților?",
|
||||
@@ -346,7 +347,9 @@
|
||||
"form.integration.webhook_secret": "Secret Webhook",
|
||||
"form.integration.webhook_url": "URL Webhook",
|
||||
"form.prefs.fieldset.application_settings": "Setări Aplicație",
|
||||
"form.prefs.fieldset.authentication_settings": "Setări Autentificare",
|
||||
"form.prefs.fieldset.authentication_settings": "Autentificare cu parolă",
|
||||
"form.prefs.fieldset.google_authentication": "Autentificare Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "Autentificare %s",
|
||||
"form.prefs.fieldset.global_feed_settings": "Setări Globale pt. Flux",
|
||||
"form.prefs.fieldset.reader_settings": "Setări Citire",
|
||||
"form.prefs.help.external_font_hosts": "Lista fonturilor de pe gazdă separate de virgulă care poate fi utilizate. De exemplu: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
@@ -428,7 +431,8 @@
|
||||
"menu.title": "Meniu",
|
||||
"menu.unread": "Necitit",
|
||||
"menu.users": "Utilizatori",
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.authors_label": "Autori:",
|
||||
"page.about.authors_value": "Frédéric Guillot și contribuitorii",
|
||||
"page.about.build_date": "Dată Build:",
|
||||
"page.about.credits": "Credit",
|
||||
"page.about.db_usage": "Utilizare Bază de Date",
|
||||
@@ -539,7 +543,6 @@
|
||||
"page.login.title": "Conectare",
|
||||
"page.login.webauthn_login": "Conectare cu cheia de acces",
|
||||
"page.login.webauthn_login.error": "Eroare la conectarea cu cheia de acces",
|
||||
"page.login.webauthn_login.help": "Vă rog să introduceți numele utilizatorului dacă utilizați o cheie. Nu este necesară dacă utilizați o cheie de acces (credențiale descoperibile).",
|
||||
"page.new_api_key.title": "Cheie API Nouă",
|
||||
"page.new_category.title": "Categorie Nouă",
|
||||
"page.new_user.title": "Utilizator Nou",
|
||||
@@ -572,7 +575,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Utilizat ultima dată",
|
||||
"page.settings.webauthn.passkey_name": "Nume cheie acces",
|
||||
"page.settings.webauthn.passkeys": "Chei Acces",
|
||||
"page.settings.webauthn.passkeys": "Autentificare cu cheie de acces",
|
||||
"page.settings.webauthn.register": "Înregistrare cheie acces",
|
||||
"page.settings.webauthn.register.error": "Eroare la înregistrarea cheii de acces",
|
||||
"page.shared_entries.title": "Înregistrări partajate",
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
"error.http_bad_gateway": "В данный момент сайт недоступен из-за ошибки шлюза. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
|
||||
"error.http_body_read": "Невозможно прочитать тело HTTP-сообщения: %v.",
|
||||
"error.http_client_error": "Ошибка HTTP-клиента: %v.",
|
||||
"error.http_cloudflare_challenge": "Этот сайт защищён проверкой Cloudflare на ботов (CAPTCHA или проверка JavaScript). Miniflux не может пройти эту проверку автоматически.",
|
||||
"error.http_empty_response": "Пустой ответ HTTP. Возможно этот сайт использует защиту от ботов?",
|
||||
"error.http_empty_response_body": "Пустое тело HTTP-ответа.",
|
||||
"error.http_forbidden": "Доступ к сайту запрещён. Возможно этот сайт использует защиту от ботов?",
|
||||
@@ -346,7 +347,9 @@
|
||||
"form.integration.webhook_secret": "Секретный ключ для вебхуков",
|
||||
"form.integration.webhook_url": "Адрес вебхуков",
|
||||
"form.prefs.fieldset.application_settings": "Настройки приложения",
|
||||
"form.prefs.fieldset.authentication_settings": "Настройки аутентификации",
|
||||
"form.prefs.fieldset.authentication_settings": "Аутентификация по паролю",
|
||||
"form.prefs.fieldset.google_authentication": "Аутентификация Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "Аутентификация %s",
|
||||
"form.prefs.fieldset.global_feed_settings": "Глобальные настройки подписок",
|
||||
"form.prefs.fieldset.reader_settings": "Настройки чтения",
|
||||
"form.prefs.help.external_font_hosts": "Список разрешённых внешних хостов для шрифтов, разделенных пробелами. Например: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
@@ -428,7 +431,8 @@
|
||||
"menu.title": "Меню",
|
||||
"menu.unread": "Непрочитанное",
|
||||
"menu.users": "Пользователи",
|
||||
"page.about.author": "Автор:",
|
||||
"page.about.authors_label": "Авторы:",
|
||||
"page.about.authors_value": "Frédéric Guillot и участники",
|
||||
"page.about.build_date": "Дата сборки:",
|
||||
"page.about.credits": "Авторы",
|
||||
"page.about.db_usage": "Размер базы данных:",
|
||||
@@ -539,7 +543,6 @@
|
||||
"page.login.title": "Войти",
|
||||
"page.login.webauthn_login": "Войти с паролем",
|
||||
"page.login.webauthn_login.error": "Невозможно войти с паролем",
|
||||
"page.login.webauthn_login.help": "Пожалуйста, введите имя пользователя, если вы используете ключ безопасности. Это не требуется при использовании Passkey (обнаруживаемые учетные данные).",
|
||||
"page.new_api_key.title": "Новый API-ключ",
|
||||
"page.new_category.title": "Новая категория",
|
||||
"page.new_user.title": "Новый пользователь",
|
||||
@@ -572,7 +575,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Последнее использование",
|
||||
"page.settings.webauthn.passkey_name": "Название ключа доступа",
|
||||
"page.settings.webauthn.passkeys": "Ключи доступа",
|
||||
"page.settings.webauthn.passkeys": "Аутентификация по ключу доступа",
|
||||
"page.settings.webauthn.register": "Зарегистрировать пароль",
|
||||
"page.settings.webauthn.register.error": "Не удается зарегистрировать пароль",
|
||||
"page.shared_entries.title": "Общедоступные статьи",
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"error.http_bad_gateway": "Kötü ağ geçidi hatası nedeniyle bu website şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
|
||||
"error.http_body_read": "HTTP gövdesi okunamıyor: %v.",
|
||||
"error.http_client_error": "HTTP istemci hatası: %v.",
|
||||
"error.http_cloudflare_challenge": "Bu web sitesi bir Cloudflare bot doğrulaması (CAPTCHA veya JavaScript doğrulaması) ile korunmaktadır. Miniflux bu doğrulamayı otomatik olarak çözemez.",
|
||||
"error.http_empty_response": "HTTP yanıtı boş. Belki bu web sitesi bir bot koruma mekanizması kullanıyordur?",
|
||||
"error.http_empty_response_body": "HTTP yanıt gövdesi boş.",
|
||||
"error.http_forbidden": "Bu siteye erişim yasak. Belki bu web sitesinin bir bot koruma mekanizması vardır?",
|
||||
@@ -343,7 +344,9 @@
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook URL",
|
||||
"form.prefs.fieldset.application_settings": "Uygulama Ayarları",
|
||||
"form.prefs.fieldset.authentication_settings": "Kimlik Doğrulama Ayarları",
|
||||
"form.prefs.fieldset.authentication_settings": "Parola ile Kimlik Doğrulama",
|
||||
"form.prefs.fieldset.google_authentication": "Google ile Kimlik Doğrulama",
|
||||
"form.prefs.fieldset.oidc_authentication": "%s ile Kimlik Doğrulama",
|
||||
"form.prefs.fieldset.global_feed_settings": "Genel Besleme Ayarları",
|
||||
"form.prefs.fieldset.reader_settings": "Okuyucu Ayarları",
|
||||
"form.prefs.help.external_font_hosts": "İzin verilecek harici font sunucularının boşlukla ayrılmış listesi. Örneğin: 'fonts.gstatic.com fonts.googleapis.com'.",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "Menü",
|
||||
"menu.unread": "Okunmadı",
|
||||
"menu.users": "Kullanıcılar",
|
||||
"page.about.author": "Yazar:",
|
||||
"page.about.authors_label": "Yazarlar:",
|
||||
"page.about.authors_value": "Frédéric Guillot ve katkıda bulunanlar",
|
||||
"page.about.build_date": "Oluşturulma Tarihi:",
|
||||
"page.about.credits": "Katkıda Bulunanlar",
|
||||
"page.about.db_usage": "Veritabanı boyutu:",
|
||||
@@ -533,7 +537,6 @@
|
||||
"page.login.title": "Oturum aç",
|
||||
"page.login.webauthn_login": "Passkey ile giriş yap",
|
||||
"page.login.webauthn_login.error": "Passkey ile giriş yapılamıyor",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.new_api_key.title": "Yeni API Anahtarı",
|
||||
"page.new_category.title": "Yeni Kategori",
|
||||
"page.new_user.title": "Yeni Kullanıcı",
|
||||
@@ -564,7 +567,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Son Kullanım",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Adı",
|
||||
"page.settings.webauthn.passkeys": "Passkeyler",
|
||||
"page.settings.webauthn.passkeys": "Geçiş Anahtarı ile Kimlik Doğrulama",
|
||||
"page.settings.webauthn.register": "Passkey'i kaydet",
|
||||
"page.settings.webauthn.register.error": "Passkey kaydedilemiyor",
|
||||
"page.shared_entries.title": "Paylaşılan makaleler",
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
"error.http_bad_gateway": "Сайт наразі недоступний через помилку шлюзу. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
|
||||
"error.http_body_read": "Не вдалося прочитати HTTP-вміст: %v.",
|
||||
"error.http_client_error": "Помилка HTTP-клієнта: %v.",
|
||||
"error.http_cloudflare_challenge": "Цей сайт захищено перевіркою Cloudflare на ботів (CAPTCHA або перевірка JavaScript). Miniflux не може пройти цю перевірку автоматично.",
|
||||
"error.http_empty_response": "Відповідь HTTP порожня. Можливо, цей сайт використовує захист від ботів?",
|
||||
"error.http_empty_response_body": "Тіло відповіді HTTP порожнє.",
|
||||
"error.http_forbidden": "Доступ до цього сайту заборонено. Можливо, сайт має захист від ботів?",
|
||||
@@ -346,7 +347,9 @@
|
||||
"form.integration.webhook_secret": "Секрет вебхуків",
|
||||
"form.integration.webhook_url": "URL вебхука за замовчуванням",
|
||||
"form.prefs.fieldset.application_settings": "Налаштування застосунку",
|
||||
"form.prefs.fieldset.authentication_settings": "Налаштування автентифікації",
|
||||
"form.prefs.fieldset.authentication_settings": "Автентифікація паролем",
|
||||
"form.prefs.fieldset.google_authentication": "Автентифікація Google",
|
||||
"form.prefs.fieldset.oidc_authentication": "Автентифікація %s",
|
||||
"form.prefs.fieldset.global_feed_settings": "Глобальні налаштування стрічок",
|
||||
"form.prefs.fieldset.reader_settings": "Налаштування читача",
|
||||
"form.prefs.help.external_font_hosts": "Список дозволених зовнішніх хостів шрифтів, розділених пробілами. Наприклад: 'fonts.gstatic.com fonts.googleapis.com'.",
|
||||
@@ -428,7 +431,8 @@
|
||||
"menu.title": "Меню",
|
||||
"menu.unread": "Непрочитане",
|
||||
"menu.users": "Користувачі",
|
||||
"page.about.author": "Автор:",
|
||||
"page.about.authors_label": "Автори:",
|
||||
"page.about.authors_value": "Frédéric Guillot та учасники",
|
||||
"page.about.build_date": "Дата побудови:",
|
||||
"page.about.credits": "Титри",
|
||||
"page.about.db_usage": "Розмір бази даних:",
|
||||
@@ -539,7 +543,6 @@
|
||||
"page.login.title": "Вхід",
|
||||
"page.login.webauthn_login": "Увійти за допомогою пароля",
|
||||
"page.login.webauthn_login.error": "Неможливо ввійти за допомогою ключа доступу",
|
||||
"page.login.webauthn_login.help": "Якщо використовуєте ключ безпеки, введіть ім'я користувача. Для паролю-паскі це не потрібно.",
|
||||
"page.new_api_key.title": "Створити ключ API",
|
||||
"page.new_category.title": "Нова категорія",
|
||||
"page.new_user.title": "Новий користувач",
|
||||
@@ -572,7 +575,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Востаннє використано",
|
||||
"page.settings.webauthn.passkey_name": "Назва паскі",
|
||||
"page.settings.webauthn.passkeys": "Паскі",
|
||||
"page.settings.webauthn.passkeys": "Автентифікація паскі",
|
||||
"page.settings.webauthn.register": "Зареєструвати пароль",
|
||||
"page.settings.webauthn.register.error": "Не вдалося зареєструвати ключ доступу",
|
||||
"page.shared_entries.title": "Спільні записи",
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"error.http_bad_gateway": "由于网关错误,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
|
||||
"error.http_body_read": "无法读取 HTTP 正文:%v。",
|
||||
"error.http_client_error": "HTTP 客户端错误:%v。",
|
||||
"error.http_cloudflare_challenge": "此网站受 Cloudflare 机器人验证(CAPTCHA 或 JavaScript 验证)保护。Miniflux 无法自动通过此验证。",
|
||||
"error.http_empty_response": "HTTP 响应为空,该网站可能使用了反爬虫机制。",
|
||||
"error.http_empty_response_body": "HTTP 响应正文为空。",
|
||||
"error.http_forbidden": "禁止访问该网站。可能该网站使用了反爬虫机制?",
|
||||
@@ -340,7 +341,9 @@
|
||||
"form.integration.webhook_secret": "Webhooks 密钥",
|
||||
"form.integration.webhook_url": "默认 Webhook URL",
|
||||
"form.prefs.fieldset.application_settings": "应用设置",
|
||||
"form.prefs.fieldset.authentication_settings": "认证设置",
|
||||
"form.prefs.fieldset.authentication_settings": "密码认证",
|
||||
"form.prefs.fieldset.google_authentication": "Google 认证",
|
||||
"form.prefs.fieldset.oidc_authentication": "%s 认证",
|
||||
"form.prefs.fieldset.global_feed_settings": "全局订阅源设置",
|
||||
"form.prefs.fieldset.reader_settings": "阅读器设置",
|
||||
"form.prefs.help.external_font_hosts": "允许外部字体托管的空格分隔列表。例如:\"fonts.gstatic.com fonts.googleapis.com\"。",
|
||||
@@ -422,7 +425,8 @@
|
||||
"menu.title": "菜单",
|
||||
"menu.unread": "未读",
|
||||
"menu.users": "用户",
|
||||
"page.about.author": "作者:",
|
||||
"page.about.authors_label": "作者:",
|
||||
"page.about.authors_value": "Frédéric Guillot 及贡献者",
|
||||
"page.about.build_date": "构建日期:",
|
||||
"page.about.credits": "鸣谢",
|
||||
"page.about.db_usage": "数据库大小:",
|
||||
@@ -527,7 +531,6 @@
|
||||
"page.login.title": "登录",
|
||||
"page.login.webauthn_login": "使用通行密钥登录",
|
||||
"page.login.webauthn_login.error": "无法使用通行密钥登录",
|
||||
"page.login.webauthn_login.help": "如果您正在使用安全密钥,请输入您的用户名。如果您正在使用通行密钥(可发现凭证),则无需输入。",
|
||||
"page.new_api_key.title": "新的 API 密钥",
|
||||
"page.new_category.title": "新建分类",
|
||||
"page.new_user.title": "新建用户",
|
||||
@@ -556,7 +559,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "最后使用",
|
||||
"page.settings.webauthn.passkey_name": "通行密钥名称",
|
||||
"page.settings.webauthn.passkeys": "通行密钥",
|
||||
"page.settings.webauthn.passkeys": "通行密钥认证",
|
||||
"page.settings.webauthn.register": "注册通行密钥",
|
||||
"page.settings.webauthn.register.error": "无法注册通行密钥",
|
||||
"page.shared_entries.title": "已共享的条目",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"action.subscribe": "訂閱",
|
||||
"action.update": "更新",
|
||||
"alert.account_linked": "您的外部帳號已成功關聯!",
|
||||
"alert.account_unlinked": "您的外部帳戶已解除關聯!",
|
||||
"alert.account_unlinked": "您的外部帳號已解除關聯!",
|
||||
"alert.background_feed_refresh": "所有 Feed 正在背景中更新,您可以繼續使用 Miniflux。",
|
||||
"alert.feed_error": "該 Feed 存在問題",
|
||||
"alert.no_starred": "目前沒有收藏",
|
||||
@@ -102,7 +102,8 @@
|
||||
"error.fields_mandatory": "必須填寫全部資訊",
|
||||
"error.http_bad_gateway": "此網站目前因閘道錯誤無法使用,問題不在 Miniflux,請稍後重試。",
|
||||
"error.http_body_read": "無法讀取 HTTP 本體內容:%v。",
|
||||
"error.http_client_error": "HTTP 客戶端錯誤:%v。",
|
||||
"error.http_client_error": "HTTP 用戶端錯誤:%v。",
|
||||
"error.http_cloudflare_challenge": "此網站受 Cloudflare 機器人驗證(CAPTCHA 或 JavaScript 驗證)保護。Miniflux 無法自動通過此驗證。",
|
||||
"error.http_empty_response": "HTTP 回應內容為空,可能該網站有防護機制。",
|
||||
"error.http_empty_response_body": "HTTP 回應本體為空。",
|
||||
"error.http_forbidden": "拒絕存取此網站,可能該網站有防護機制。",
|
||||
@@ -131,13 +132,13 @@
|
||||
"error.password_min_length": "請至少輸入 6 個字元",
|
||||
"error.proxy_url_not_empty": "代理伺服器網址不能為空。",
|
||||
"error.settings_block_rule_fieldname_invalid": "無效的封鎖規則:規則 #%d 缺少有效的欄位名稱 (可用選項:%s)",
|
||||
"error.settings_block_rule_invalid_regex": "無效的封鎖規則:規則 #%d 的模式不是合法的正規表示式",
|
||||
"error.settings_block_rule_regex_required": "無效的封鎖規則:規則 #%d 沒有提供正規表示式",
|
||||
"error.settings_block_rule_invalid_regex": "無效的封鎖規則:規則 #%d 的模式不是合法的正規表達式",
|
||||
"error.settings_block_rule_regex_required": "無效的封鎖規則:規則 #%d 沒有提供正規表達式",
|
||||
"error.settings_block_rule_separator_required": "無效的封鎖規則:規則 #%d 的模式必須用 '=' 分隔",
|
||||
"error.settings_invalid_domain_list": "網域清單無效。請以空白分隔多個網域。",
|
||||
"error.settings_keep_rule_fieldname_invalid": "無效的保留規則:規則 #%d 缺少有效的欄位名稱 (可用選項:%s)",
|
||||
"error.settings_keep_rule_invalid_regex": "無效的保留規則:規則 #%d 的模式不是合法的正規表示式",
|
||||
"error.settings_keep_rule_regex_required": "無效的保留規則:規則 #%d 沒有提供正規表示式",
|
||||
"error.settings_keep_rule_invalid_regex": "無效的保留規則:規則 #%d 的模式不是合法的正規表達式",
|
||||
"error.settings_keep_rule_regex_required": "無效的保留規則:規則 #%d 沒有提供正規表達式",
|
||||
"error.settings_keep_rule_separator_required": "無效的保留規則:規則 #%d 的模式必須用 '=' 分隔",
|
||||
"error.settings_mandatory_fields": "必須填寫使用者名稱、主題、語言以及時區",
|
||||
"error.settings_media_playback_rate_range": "播放速度超出範圍",
|
||||
@@ -157,18 +158,18 @@
|
||||
"error.unlink_account_without_password": "您必須設定密碼,否則您將無法再次登入。",
|
||||
"error.user_already_exists": "使用者已存在",
|
||||
"error.user_mandatory_fields": "必須填寫使用者名稱",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API Token 和 Organization Slug 是必需的",
|
||||
"error.linktaco_missing_required_fields": "LinkTaco API 權杖和 Organization Slug 是必需的",
|
||||
"form.api_key.label.description": "API 金鑰標籤",
|
||||
"form.category.hide_globally": "在全域未讀列表中隱藏文章",
|
||||
"form.category.hide_globally": "在全域未讀清單中隱藏文章",
|
||||
"form.category.label.title": "標題",
|
||||
"form.feed.fieldset.general": "通用",
|
||||
"form.feed.fieldset.integration": "第三方服務",
|
||||
"form.feed.fieldset.network_settings": "網路設定",
|
||||
"form.feed.fieldset.rules": "規則",
|
||||
"form.feed.label.allow_self_signed_certificates": "允許自簽或無效的憑證",
|
||||
"form.feed.label.apprise_service_urls": "使用逗號分隔的 Apprise 服務網址列表",
|
||||
"form.feed.label.apprise_service_urls": "使用逗號分隔的 Apprise 服務網址清單",
|
||||
"form.feed.label.block_filter_entry_rules": "條目封鎖規則",
|
||||
"form.feed.label.blocklist_rules": "基於正則表達式的封鎖過濾器",
|
||||
"form.feed.label.blocklist_rules": "基於正規表達式的封鎖過濾器",
|
||||
"form.feed.label.category": "類別",
|
||||
"form.feed.label.cookie": "設定 Cookies",
|
||||
"form.feed.label.crawler": "下載原文內容",
|
||||
@@ -180,10 +181,10 @@
|
||||
"form.feed.label.feed_url": "Feed 網址",
|
||||
"form.feed.label.feed_username": "Feed 使用者名稱",
|
||||
"form.feed.label.fetch_via_proxy": "使用應用程式層級設定的代理",
|
||||
"form.feed.label.hide_globally": "在全域未讀列表中隱藏文章",
|
||||
"form.feed.label.hide_globally": "在全域未讀清單中隱藏文章",
|
||||
"form.feed.label.ignore_http_cache": "忽略 HTTP 快取",
|
||||
"form.feed.label.keep_filter_entry_rules": "條目允許規則",
|
||||
"form.feed.label.keeplist_rules": "基於正則表達式的保留過濾器",
|
||||
"form.feed.label.keeplist_rules": "基於正規表達式的保留過濾器",
|
||||
"form.feed.label.no_media_player": "無媒體播放器 (音訊/視訊)",
|
||||
"form.feed.label.ntfy_activate": "推送文章到 ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy 預設優先順序",
|
||||
@@ -193,7 +194,7 @@
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy 最低優先順序",
|
||||
"form.feed.label.ntfy_priority": "Ntfy 優先順序",
|
||||
"form.feed.label.ntfy_topic": "Ntfy topic (選填)",
|
||||
"form.feed.label.proxy_url": "代理URL",
|
||||
"form.feed.label.proxy_url": "代理 URL",
|
||||
"form.feed.label.pushover_activate": "推送文章到 Pushover",
|
||||
"form.feed.label.pushover_default_priority": "Pushover 預設優先順序",
|
||||
"form.feed.label.pushover_high_priority": "Pushover 高優先順序",
|
||||
@@ -206,16 +207,16 @@
|
||||
"form.feed.label.site_url": "網站網址",
|
||||
"form.feed.label.title": "標題",
|
||||
"form.feed.label.urlrewrite_rules": "網址重寫規則",
|
||||
"form.feed.label.user_agent": "覆蓋預設的使用者代理",
|
||||
"form.feed.label.webhook_url": "覆蓋webhook URL",
|
||||
"form.feed.label.user_agent": "覆寫預設的使用者代理",
|
||||
"form.feed.label.webhook_url": "覆寫 webhook URL",
|
||||
"form.import.label.file": "OPML 檔案",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.archiveorg_activate": "推送文章到 archive.org",
|
||||
"form.integration.apprise_activate": "推送文章到 Apprise",
|
||||
"form.integration.apprise_services_url": "使用逗號分隔的 Apprise 服務網址列表",
|
||||
"form.integration.apprise_services_url": "使用逗號分隔的 Apprise 服務網址清單",
|
||||
"form.integration.apprise_url": "Apprise API 網址",
|
||||
"form.integration.betula_activate": "儲存文章到 Betula",
|
||||
"form.integration.betula_token": "Betula令牌",
|
||||
"form.integration.betula_token": "Betula 權杖",
|
||||
"form.integration.betula_url": "Betula 伺服器網址",
|
||||
"form.integration.cubox_activate": "儲存文章到 Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API 連結",
|
||||
@@ -252,7 +253,7 @@
|
||||
"form.integration.linkding_endpoint": "Linkding API 端點",
|
||||
"form.integration.linkding_tags": "Linkding 標籤",
|
||||
"form.integration.linktaco_activate": "儲存文章到 LinkTaco",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API Token",
|
||||
"form.integration.linktaco_api_token": "LinkTaco API 權杖",
|
||||
"form.integration.linktaco_api_token_hint": "在此取得您的個人存取權杖",
|
||||
"form.integration.linktaco_org_slug": "組織代稱",
|
||||
"form.integration.linktaco_tags": "標籤(最多10個,逗號分隔)",
|
||||
@@ -260,7 +261,7 @@
|
||||
"form.integration.linktaco_visibility": "可見性",
|
||||
"form.integration.linktaco_visibility_public": "公開",
|
||||
"form.integration.linktaco_visibility_private": "私人",
|
||||
"form.integration.linktaco_visibility_hint": "私人可見性需要付費的 LinkTaco 帳戶",
|
||||
"form.integration.linktaco_visibility_hint": "私人可見性需要付費的 LinkTaco 帳號",
|
||||
"form.integration.linkwarden_activate": "儲存文章到 Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API 金鑰",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
|
||||
@@ -290,7 +291,7 @@
|
||||
"form.integration.pinboard_activate": "儲存文章到 Pinboard",
|
||||
"form.integration.pinboard_bookmark": "標記為未讀",
|
||||
"form.integration.pinboard_tags": "Pinboard 標籤",
|
||||
"form.integration.pinboard_token": "Pinboard API Token",
|
||||
"form.integration.pinboard_token": "Pinboard API 權杖",
|
||||
"form.integration.pushover_activate": "推送文章到 Pushover",
|
||||
"form.integration.pushover_device": "Pushover 裝置(選填)",
|
||||
"form.integration.pushover_prefix": "Pushover URL 前綴(選填)",
|
||||
@@ -325,12 +326,12 @@
|
||||
"form.integration.telegram_bot_disable_buttons": "不顯示按鈕",
|
||||
"form.integration.telegram_bot_disable_notification": "停用通知",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "停用網頁預覽",
|
||||
"form.integration.telegram_bot_token": "Bot Token",
|
||||
"form.integration.telegram_bot_token": "機器人權杖",
|
||||
"form.integration.telegram_chat_id": "Chat ID",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.wallabag_activate": "儲存文章到 Wallabag",
|
||||
"form.integration.wallabag_client_id": "Wallabag 客戶端 ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag 客戶端金鑰",
|
||||
"form.integration.wallabag_client_id": "Wallabag 用戶端 ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag 用戶端金鑰",
|
||||
"form.integration.wallabag_endpoint": "Wallabag 基本網址",
|
||||
"form.integration.wallabag_only_url": "僅傳送網址(而不是完整內容)",
|
||||
"form.integration.wallabag_password": "Wallabag 密碼",
|
||||
@@ -338,9 +339,11 @@
|
||||
"form.integration.wallabag_tags": "Wallabag Tags",
|
||||
"form.integration.webhook_activate": "啟用 Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook 網址",
|
||||
"form.integration.webhook_url": "預設 Webhook 網址",
|
||||
"form.prefs.fieldset.application_settings": "應用程式設定",
|
||||
"form.prefs.fieldset.authentication_settings": "使用者認證設定",
|
||||
"form.prefs.fieldset.authentication_settings": "密碼認證",
|
||||
"form.prefs.fieldset.google_authentication": "Google 認證",
|
||||
"form.prefs.fieldset.oidc_authentication": "%s 認證",
|
||||
"form.prefs.fieldset.global_feed_settings": "全域 Feed 設定",
|
||||
"form.prefs.fieldset.reader_settings": "閱讀器設定",
|
||||
"form.prefs.help.external_font_hosts": "以空白分隔允許的外部字型來源。例如:「fonts.gstatic.com fonts.googleapis.com」。",
|
||||
@@ -358,7 +361,7 @@
|
||||
"form.prefs.label.entry_swipe": "在觸控式螢幕上啟用文章滑動",
|
||||
"form.prefs.label.external_font_hosts": "外部字型來源",
|
||||
"form.prefs.label.gesture_nav": "在文章之間導覽的手勢",
|
||||
"form.prefs.label.keyboard_shortcuts": "啟用鍵盤快捷鍵",
|
||||
"form.prefs.label.keyboard_shortcuts": "啟用鍵盤快速鍵",
|
||||
"form.prefs.label.language": "語言",
|
||||
"form.prefs.label.mark_read_manually": "僅手動標記為已讀",
|
||||
"form.prefs.label.mark_read_on_media_completion": "僅在音訊/視訊播放達 90% 時標記為已讀",
|
||||
@@ -422,7 +425,8 @@
|
||||
"menu.title": "導覽",
|
||||
"menu.unread": "未讀",
|
||||
"menu.users": "使用者",
|
||||
"page.about.author": "作者:",
|
||||
"page.about.authors_label": "作者:",
|
||||
"page.about.authors_value": "Frédéric Guillot 及貢獻者",
|
||||
"page.about.build_date": "建構日期:",
|
||||
"page.about.credits": "版權",
|
||||
"page.about.db_usage": "資料庫大小:",
|
||||
@@ -460,7 +464,7 @@
|
||||
"page.edit_category.title": "編輯分類 : %s",
|
||||
"page.edit_feed.etag_header": "ETag 標頭:",
|
||||
"page.edit_feed.last_check": "最後檢查時間:",
|
||||
"page.edit_feed.last_modified_header": "最後修改的 Header:",
|
||||
"page.edit_feed.last_modified_header": "最後修改的標頭:",
|
||||
"page.edit_feed.last_parsing_error": "最後一次解析錯誤",
|
||||
"page.edit_feed.no_header": "無",
|
||||
"page.edit_feed.title": "編輯 Feed : %s",
|
||||
@@ -512,12 +516,12 @@
|
||||
"page.keyboard_shortcuts.remove_feed": "刪除此 Feed",
|
||||
"page.keyboard_shortcuts.save_article": "儲存文章",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "捲動到頂端",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "顯示快捷鍵幫助",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "顯示鍵盤快速鍵",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "操作",
|
||||
"page.keyboard_shortcuts.subtitle.items": "文章導覽",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "頁面導覽",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "分欄導覽",
|
||||
"page.keyboard_shortcuts.title": "快捷鍵",
|
||||
"page.keyboard_shortcuts.title": "快速鍵",
|
||||
"page.keyboard_shortcuts.toggle_star_status": "切換收藏狀態",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "展開/折疊文章附件",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "切換已讀/未讀狀態,並聚焦到下一個",
|
||||
@@ -527,7 +531,6 @@
|
||||
"page.login.title": "登入",
|
||||
"page.login.webauthn_login": "使用密碼登入",
|
||||
"page.login.webauthn_login.error": "無法使用密碼登入",
|
||||
"page.login.webauthn_login.help": "使用安全金鑰登入時,請輸入使用者名稱。若使用可探索式 Passkey 則無需輸入。",
|
||||
"page.new_api_key.title": "新的 API 金鑰",
|
||||
"page.new_category.title": "新分類",
|
||||
"page.new_user.title": "新使用者",
|
||||
@@ -556,7 +559,7 @@
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "最後使用時間",
|
||||
"page.settings.webauthn.passkey_name": "Passkey 名稱",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.passkeys": "Passkey 認證",
|
||||
"page.settings.webauthn.register": "註冊 Passkey",
|
||||
"page.settings.webauthn.register.error": "無法註冊 Passkey",
|
||||
"page.shared_entries.title": "已分享的文章",
|
||||
@@ -612,6 +615,6 @@
|
||||
"%d 年前"
|
||||
],
|
||||
"time_elapsed.yesterday": "昨天",
|
||||
"tooltip.keyboard_shortcuts": "快捷鍵:%s",
|
||||
"tooltip.keyboard_shortcuts": "快速鍵:%s",
|
||||
"tooltip.logged_user": "目前登入 %s"
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package model // import "miniflux.app/v2/internal/model"
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SessionData represents the data attached to the session.
|
||||
type SessionData struct {
|
||||
CSRF string `json:"csrf"`
|
||||
OAuth2State string `json:"oauth2_state"`
|
||||
OAuth2CodeVerifier string `json:"oauth2_code_verifier"`
|
||||
FlashMessage string `json:"flash_message"`
|
||||
FlashErrorMessage string `json:"flash_error_message"`
|
||||
Language string `json:"language"`
|
||||
Theme string `json:"theme"`
|
||||
LastForceRefresh string `json:"last_force_refresh"`
|
||||
WebAuthnSessionData WebAuthnSession `json:"webauthn_session_data"`
|
||||
}
|
||||
|
||||
func (s *SessionData) String() string {
|
||||
return fmt.Sprintf(`CSRF=%q, OAuth2State=%q, OAuth2CodeVerifier=%q, FlashMsg=%q, FlashErrMsg=%q, Lang=%q, Theme=%q, LastForceRefresh=%s, WebAuthnSession=%q`,
|
||||
s.CSRF,
|
||||
s.OAuth2State,
|
||||
s.OAuth2CodeVerifier,
|
||||
s.FlashMessage,
|
||||
s.FlashErrorMessage,
|
||||
s.Language,
|
||||
s.Theme,
|
||||
s.LastForceRefresh,
|
||||
s.WebAuthnSessionData,
|
||||
)
|
||||
}
|
||||
|
||||
// Value converts the session data to JSON.
|
||||
func (s *SessionData) Value() (driver.Value, error) {
|
||||
j, err := json.Marshal(s)
|
||||
return j, err
|
||||
}
|
||||
|
||||
// Scan converts raw JSON data.
|
||||
func (s *SessionData) Scan(src any) error {
|
||||
source, ok := src.([]byte)
|
||||
if !ok {
|
||||
return errors.New("session: unable to assert type of src")
|
||||
}
|
||||
|
||||
err := json.Unmarshal(source, s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("session: %v", err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Session represents a session in the system.
|
||||
type Session struct {
|
||||
ID string
|
||||
Data *SessionData
|
||||
}
|
||||
|
||||
func (s *Session) String() string {
|
||||
return fmt.Sprintf(`ID=%q, Data={%v}`, s.ID, s.Data)
|
||||
}
|
||||
@@ -11,11 +11,18 @@ import (
|
||||
const (
|
||||
EntryStatusUnread = "unread"
|
||||
EntryStatusRead = "read"
|
||||
EntryStatusRemoved = "removed"
|
||||
DefaultSortingOrder = "published_at"
|
||||
DefaultSortingDirection = "asc"
|
||||
)
|
||||
|
||||
// MaxEntryLimit is the maximum allowed value for the "limit" query parameter
|
||||
// and for the user "entries_per_page" preference.
|
||||
const MaxEntryLimit = 1000
|
||||
|
||||
// MaxEntryIDsLimit is the maximum allowed value for the "limit" query parameter
|
||||
// for the entry ID list endpoints.
|
||||
const MaxEntryIDsLimit = 10000
|
||||
|
||||
// Entry represents a feed item in the system.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -73,6 +80,7 @@ type Entries []*Entry
|
||||
type EntriesStatusUpdateRequest struct {
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
Status string `json:"status"`
|
||||
Starred *bool `json:"starred"`
|
||||
}
|
||||
|
||||
// EntryUpdateRequest represents a request to update an entry.
|
||||
|
||||
@@ -66,8 +66,6 @@ type UserModificationRequest struct {
|
||||
Stylesheet *string `json:"stylesheet"`
|
||||
CustomJS *string `json:"custom_js"`
|
||||
ExternalFontHosts *string `json:"external_font_hosts"`
|
||||
GoogleID *string `json:"google_id"`
|
||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||
EntriesPerPage *int `json:"entries_per_page"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
||||
@@ -134,14 +132,6 @@ func (u *UserModificationRequest) Patch(user *User) {
|
||||
user.ExternalFontHosts = *u.ExternalFontHosts
|
||||
}
|
||||
|
||||
if u.GoogleID != nil {
|
||||
user.GoogleID = *u.GoogleID
|
||||
}
|
||||
|
||||
if u.OpenIDConnectID != nil {
|
||||
user.OpenIDConnectID = *u.OpenIDConnectID
|
||||
}
|
||||
|
||||
if u.EntriesPerPage != nil {
|
||||
user.EntriesPerPage = *u.EntriesPerPage
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package model // import "miniflux.app/v2/internal/model"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/timezone"
|
||||
)
|
||||
|
||||
// UserSession represents a user session in the system.
|
||||
type UserSession struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Token string
|
||||
CreatedAt time.Time
|
||||
UserAgent string
|
||||
IP string
|
||||
}
|
||||
|
||||
func (u *UserSession) String() string {
|
||||
return fmt.Sprintf(`ID=%d, UserID=%d, IP=%q, Token=%q`, u.ID, u.UserID, u.IP, u.Token)
|
||||
}
|
||||
|
||||
// UseTimezone converts creation date to the given timezone.
|
||||
func (u *UserSession) UseTimezone(tz string) {
|
||||
u.CreatedAt = timezone.Convert(tz, u.CreatedAt)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package model // import "miniflux.app/v2/internal/model"
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"miniflux.app/v2/internal/timezone"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSessionLanguage = "en_US"
|
||||
defaultSessionTheme = "system_serif"
|
||||
)
|
||||
|
||||
// WebSession represents a browser session persisted in the web_sessions table.
|
||||
type WebSession struct {
|
||||
ID string
|
||||
SecretHash []byte
|
||||
CreatedAt time.Time
|
||||
UserAgent string
|
||||
IP string
|
||||
userID *int64
|
||||
state webSessionState
|
||||
dirty bool
|
||||
}
|
||||
|
||||
// webSessionState stores transient browser session state as a JSON blob.
|
||||
type webSessionState struct {
|
||||
CSRF string `json:"csrf,omitempty"`
|
||||
SuccessMessage string `json:"success_message,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
OAuth2 *WebSessionOAuth2 `json:"oauth2,omitempty"`
|
||||
WebAuthn *webauthn.SessionData `json:"webauthn,omitempty"`
|
||||
LastForceRefreshAt *time.Time `json:"last_force_refresh_at,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Theme string `json:"theme,omitempty"`
|
||||
}
|
||||
|
||||
// WebSessionOAuth2 stores transient OAuth2 flow state.
|
||||
type WebSessionOAuth2 struct {
|
||||
State string `json:"state,omitempty"`
|
||||
CodeVerifier string `json:"code_verifier,omitempty"`
|
||||
}
|
||||
|
||||
// NewWebSession builds an unauthenticated browser session with a fresh
|
||||
// identity and returns it along with the raw session secret.
|
||||
func NewWebSession(userAgent, ip string) (*WebSession, string) {
|
||||
secret := rand.Text()
|
||||
session := &WebSession{
|
||||
ID: rand.Text(),
|
||||
SecretHash: hashWebSessionSecret(secret),
|
||||
UserAgent: userAgent,
|
||||
IP: ip,
|
||||
}
|
||||
session.state.CSRF = rand.Text()
|
||||
return session, secret
|
||||
}
|
||||
|
||||
// Rotate assigns a new ID and secret in place, returning the previous ID
|
||||
// and the new raw secret. Rotating on authentication prevents session fixation.
|
||||
func (s *WebSession) Rotate() (oldID, newSecret string) {
|
||||
oldID = s.ID
|
||||
newSecret = rand.Text()
|
||||
s.ID = rand.Text()
|
||||
s.SecretHash = hashWebSessionSecret(newSecret)
|
||||
return oldID, newSecret
|
||||
}
|
||||
|
||||
// VerifySecret reports whether the given raw secret matches the stored hash.
|
||||
func (s *WebSession) VerifySecret(secret string) bool {
|
||||
if secret == "" || len(s.SecretHash) == 0 {
|
||||
return false
|
||||
}
|
||||
actual := hashWebSessionSecret(secret)
|
||||
return subtle.ConstantTimeCompare(actual, s.SecretHash) == 1
|
||||
}
|
||||
|
||||
func hashWebSessionSecret(secret string) []byte {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// IsDirty reports whether the session has been modified since it was loaded.
|
||||
func (s *WebSession) IsDirty() bool {
|
||||
return s.dirty
|
||||
}
|
||||
|
||||
// IsAuthenticated reports whether the session is bound to a user.
|
||||
func (s *WebSession) IsAuthenticated() bool {
|
||||
return s.userID != nil
|
||||
}
|
||||
|
||||
// UserID returns the authenticated user ID and whether the session is bound to a user.
|
||||
func (s *WebSession) UserID() (int64, bool) {
|
||||
if s.userID == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *s.userID, true
|
||||
}
|
||||
|
||||
// NullUserID returns the session user ID as a sql.NullInt64 for storage writes.
|
||||
func (s *WebSession) NullUserID() sql.NullInt64 {
|
||||
if s.userID == nil {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: *s.userID, Valid: true}
|
||||
}
|
||||
|
||||
// ScanUserID sets the session user ID from a sql.NullInt64 loaded from storage.
|
||||
func (s *WebSession) ScanUserID(v sql.NullInt64) {
|
||||
if !v.Valid {
|
||||
s.userID = nil
|
||||
return
|
||||
}
|
||||
id := v.Int64
|
||||
s.userID = &id
|
||||
}
|
||||
|
||||
// UseTimezone converts creation date to the given timezone.
|
||||
func (s *WebSession) UseTimezone(tz string) {
|
||||
s.CreatedAt = timezone.Convert(tz, s.CreatedAt)
|
||||
}
|
||||
|
||||
// CSRF returns the CSRF token for this session.
|
||||
func (s *WebSession) CSRF() string {
|
||||
return s.state.CSRF
|
||||
}
|
||||
|
||||
// Language returns the session language, or a default when unset.
|
||||
func (s *WebSession) Language() string {
|
||||
if s.state.Language != "" {
|
||||
return s.state.Language
|
||||
}
|
||||
return defaultSessionLanguage
|
||||
}
|
||||
|
||||
// Theme returns the session theme, or a default when unset.
|
||||
func (s *WebSession) Theme() string {
|
||||
if s.state.Theme != "" {
|
||||
return s.state.Theme
|
||||
}
|
||||
return defaultSessionTheme
|
||||
}
|
||||
|
||||
// OAuth2State returns the OAuth2 state parameter, or empty if not in an OAuth2 flow.
|
||||
func (s *WebSession) OAuth2State() string {
|
||||
if s.state.OAuth2 != nil {
|
||||
return s.state.OAuth2.State
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// OAuth2CodeVerifier returns the PKCE code verifier, or empty if not in an OAuth2 flow.
|
||||
func (s *WebSession) OAuth2CodeVerifier() string {
|
||||
if s.state.OAuth2 != nil {
|
||||
return s.state.OAuth2.CodeVerifier
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ConsumeWebAuthnSession returns and clears the pending WebAuthn session data.
|
||||
func (s *WebSession) ConsumeWebAuthnSession() *webauthn.SessionData {
|
||||
data := s.state.WebAuthn
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.dirty = true
|
||||
s.state.WebAuthn = nil
|
||||
return data
|
||||
}
|
||||
|
||||
// LastForceRefresh returns the last force refresh timestamp, or zero time if unset.
|
||||
func (s *WebSession) LastForceRefresh() time.Time {
|
||||
if s.state.LastForceRefreshAt != nil {
|
||||
return *s.state.LastForceRefreshAt
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// ConsumeMessages returns and clears the success and error messages.
|
||||
func (s *WebSession) ConsumeMessages() (string, string) {
|
||||
successMessage := s.state.SuccessMessage
|
||||
errorMessage := s.state.ErrorMessage
|
||||
|
||||
if successMessage != "" || errorMessage != "" {
|
||||
s.dirty = true
|
||||
s.state.SuccessMessage = ""
|
||||
s.state.ErrorMessage = ""
|
||||
}
|
||||
|
||||
return successMessage, errorMessage
|
||||
}
|
||||
|
||||
// SetLanguage updates the language.
|
||||
func (s *WebSession) SetLanguage(language string) {
|
||||
s.dirty = true
|
||||
s.state.Language = language
|
||||
}
|
||||
|
||||
// SetTheme updates the theme.
|
||||
func (s *WebSession) SetTheme(theme string) {
|
||||
s.dirty = true
|
||||
s.state.Theme = theme
|
||||
}
|
||||
|
||||
// SetSuccessMessage stores a success message shown on the next page load.
|
||||
func (s *WebSession) SetSuccessMessage(message string) {
|
||||
s.dirty = true
|
||||
s.state.SuccessMessage = message
|
||||
}
|
||||
|
||||
// SetErrorMessage stores an error message shown on the next page load.
|
||||
func (s *WebSession) SetErrorMessage(message string) {
|
||||
s.dirty = true
|
||||
s.state.ErrorMessage = message
|
||||
}
|
||||
|
||||
// StartOAuth2Flow stores the OAuth2 state parameter and PKCE code verifier.
|
||||
func (s *WebSession) StartOAuth2Flow(state, codeVerifier string) {
|
||||
s.dirty = true
|
||||
s.state.OAuth2 = &WebSessionOAuth2{
|
||||
State: state,
|
||||
CodeVerifier: codeVerifier,
|
||||
}
|
||||
}
|
||||
|
||||
// ClearOAuth2Flow discards any pending OAuth2 flow state.
|
||||
func (s *WebSession) ClearOAuth2Flow() {
|
||||
s.dirty = true
|
||||
s.state.OAuth2 = nil
|
||||
}
|
||||
|
||||
// SetUser binds the session to an authenticated user and copies their preferences.
|
||||
func (s *WebSession) SetUser(user *User) {
|
||||
if user == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.dirty = true
|
||||
userID := user.ID
|
||||
s.userID = &userID
|
||||
s.state.Language = user.Language
|
||||
s.state.Theme = user.Theme
|
||||
}
|
||||
|
||||
// ClearUser removes the user binding from the session.
|
||||
func (s *WebSession) ClearUser() {
|
||||
s.dirty = true
|
||||
s.userID = nil
|
||||
}
|
||||
|
||||
// MarkForceRefreshed records the current time as the last force refresh.
|
||||
func (s *WebSession) MarkForceRefreshed() {
|
||||
s.dirty = true
|
||||
now := time.Now().UTC()
|
||||
s.state.LastForceRefreshAt = &now
|
||||
}
|
||||
|
||||
// SetWebAuthn stores or clears WebAuthn session data.
|
||||
func (s *WebSession) SetWebAuthn(data *webauthn.SessionData) {
|
||||
s.dirty = true
|
||||
s.state.WebAuthn = data
|
||||
}
|
||||
|
||||
// MarshalState serializes the session state to JSON for storage.
|
||||
func (s *WebSession) MarshalState() ([]byte, error) {
|
||||
return json.Marshal(s.state)
|
||||
}
|
||||
|
||||
// UnmarshalState populates the session state from raw JSON bytes.
|
||||
func (s *WebSession) UnmarshalState(data []byte) error {
|
||||
s.state = webSessionState{}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &s.state)
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
func TestNewWebSession(t *testing.T) {
|
||||
const userAgent = "test-agent"
|
||||
const ip = "127.0.0.1"
|
||||
|
||||
session, secret := NewWebSession(userAgent, ip)
|
||||
|
||||
if session == nil {
|
||||
t.Fatal("NewWebSession returned a nil session")
|
||||
}
|
||||
if secret == "" {
|
||||
t.Error("NewWebSession returned an empty secret")
|
||||
}
|
||||
if session.ID == "" {
|
||||
t.Error("NewWebSession produced an empty ID")
|
||||
}
|
||||
if session.ID == secret {
|
||||
t.Error("session ID and secret must not be equal")
|
||||
}
|
||||
if len(session.SecretHash) == 0 {
|
||||
t.Error("NewWebSession produced an empty SecretHash")
|
||||
}
|
||||
if session.CSRF() == "" {
|
||||
t.Error("NewWebSession produced an empty CSRF token")
|
||||
}
|
||||
if session.UserAgent != userAgent {
|
||||
t.Errorf("UserAgent = %q, want %q", session.UserAgent, userAgent)
|
||||
}
|
||||
if session.IP != ip {
|
||||
t.Errorf("IP = %q, want %q", session.IP, ip)
|
||||
}
|
||||
if session.IsAuthenticated() {
|
||||
t.Error("a fresh session must not be authenticated")
|
||||
}
|
||||
if session.IsDirty() {
|
||||
t.Error("a fresh session must not be dirty")
|
||||
}
|
||||
if !session.VerifySecret(secret) {
|
||||
t.Error("VerifySecret rejected the secret returned by NewWebSession")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWebSession_ProducesUniqueIdentities(t *testing.T) {
|
||||
s1, secret1 := NewWebSession("", "")
|
||||
s2, secret2 := NewWebSession("", "")
|
||||
|
||||
if s1.ID == s2.ID {
|
||||
t.Error("successive NewWebSession calls produced the same ID")
|
||||
}
|
||||
if secret1 == secret2 {
|
||||
t.Error("successive NewWebSession calls produced the same secret")
|
||||
}
|
||||
if bytes.Equal(s1.SecretHash, s2.SecretHash) {
|
||||
t.Error("successive NewWebSession calls produced the same SecretHash")
|
||||
}
|
||||
if s1.CSRF() == s2.CSRF() {
|
||||
t.Error("successive NewWebSession calls produced the same CSRF token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSession_Rotate(t *testing.T) {
|
||||
session, originalSecret := NewWebSession("agent", "ip")
|
||||
originalID := session.ID
|
||||
originalHash := bytes.Clone(session.SecretHash)
|
||||
originalCSRF := session.CSRF()
|
||||
|
||||
// Bind a user so we can verify Rotate preserves the user binding.
|
||||
session.SetUser(&User{ID: 42})
|
||||
|
||||
oldID, newSecret := session.Rotate()
|
||||
|
||||
if oldID != originalID {
|
||||
t.Errorf("Rotate returned oldID = %q, want %q", oldID, originalID)
|
||||
}
|
||||
if newSecret == "" {
|
||||
t.Error("Rotate returned an empty new secret")
|
||||
}
|
||||
if newSecret == originalSecret {
|
||||
t.Error("Rotate returned the same secret as before")
|
||||
}
|
||||
if session.ID == originalID {
|
||||
t.Error("Rotate did not change the session ID")
|
||||
}
|
||||
if bytes.Equal(session.SecretHash, originalHash) {
|
||||
t.Error("Rotate did not change the SecretHash")
|
||||
}
|
||||
if session.VerifySecret(originalSecret) {
|
||||
t.Error("VerifySecret must reject the pre-rotation secret")
|
||||
}
|
||||
if !session.VerifySecret(newSecret) {
|
||||
t.Error("VerifySecret must accept the post-rotation secret")
|
||||
}
|
||||
if session.CSRF() != originalCSRF {
|
||||
t.Error("Rotate must preserve the CSRF token so in-flight forms remain valid")
|
||||
}
|
||||
if !session.IsAuthenticated() {
|
||||
t.Error("Rotate must preserve the user binding")
|
||||
}
|
||||
if id, _ := session.UserID(); id != 42 {
|
||||
t.Errorf("Rotate corrupted user ID: got %d, want 42", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSession_VerifySecret(t *testing.T) {
|
||||
good, goodSecret := NewWebSession("", "")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
hash []byte
|
||||
secret string
|
||||
want bool
|
||||
}{
|
||||
{"correct secret", good.SecretHash, goodSecret, true},
|
||||
{"wrong secret", good.SecretHash, "not-the-right-secret", false},
|
||||
{"empty secret", good.SecretHash, "", false},
|
||||
{"nil hash", nil, goodSecret, false},
|
||||
{"empty hash and secret", nil, "", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := &WebSession{SecretHash: tc.hash}
|
||||
if got := s.VerifySecret(tc.secret); got != tc.want {
|
||||
t.Errorf("VerifySecret(%q) = %v, want %v", tc.secret, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSession_UserBindingLifecycle(t *testing.T) {
|
||||
session, _ := NewWebSession("", "")
|
||||
|
||||
if session.IsAuthenticated() {
|
||||
t.Error("a fresh session must not be authenticated")
|
||||
}
|
||||
if id, ok := session.UserID(); ok || id != 0 {
|
||||
t.Errorf("UserID() = (%d, %v), want (0, false)", id, ok)
|
||||
}
|
||||
|
||||
user := &User{ID: 99, Language: "fr_FR", Theme: "dark_serif"}
|
||||
session.SetUser(user)
|
||||
|
||||
if !session.IsAuthenticated() {
|
||||
t.Error("session must be authenticated after SetUser")
|
||||
}
|
||||
if id, ok := session.UserID(); !ok || id != 99 {
|
||||
t.Errorf("UserID() = (%d, %v), want (99, true)", id, ok)
|
||||
}
|
||||
if session.Language() != "fr_FR" {
|
||||
t.Errorf("SetUser did not copy Language: got %q, want %q", session.Language(), "fr_FR")
|
||||
}
|
||||
if session.Theme() != "dark_serif" {
|
||||
t.Errorf("SetUser did not copy Theme: got %q, want %q", session.Theme(), "dark_serif")
|
||||
}
|
||||
if !session.IsDirty() {
|
||||
t.Error("SetUser must mark the session dirty")
|
||||
}
|
||||
|
||||
session.ClearUser()
|
||||
if session.IsAuthenticated() {
|
||||
t.Error("session must not be authenticated after ClearUser")
|
||||
}
|
||||
if id, ok := session.UserID(); ok || id != 0 {
|
||||
t.Errorf("UserID() after ClearUser = (%d, %v), want (0, false)", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSession_SetUser_NilIsNoop(t *testing.T) {
|
||||
session, _ := NewWebSession("", "")
|
||||
session.SetUser(nil)
|
||||
|
||||
if session.IsAuthenticated() {
|
||||
t.Error("SetUser(nil) must not authenticate the session")
|
||||
}
|
||||
if session.IsDirty() {
|
||||
t.Error("SetUser(nil) must not mark the session dirty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSession_UserIDStorageRoundTrip(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
in sql.NullInt64
|
||||
}{
|
||||
{"null", sql.NullInt64{}},
|
||||
{"zero valid", sql.NullInt64{Int64: 0, Valid: true}},
|
||||
{"positive valid", sql.NullInt64{Int64: 42, Valid: true}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
session := &WebSession{}
|
||||
session.ScanUserID(tc.in)
|
||||
|
||||
if got := session.NullUserID(); got != tc.in {
|
||||
t.Errorf("round-trip = %+v, want %+v", got, tc.in)
|
||||
}
|
||||
if got := session.IsAuthenticated(); got != tc.in.Valid {
|
||||
t.Errorf("IsAuthenticated() = %v, want %v", got, tc.in.Valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSession_ScanUserID_ClearsPreviousValue(t *testing.T) {
|
||||
session := &WebSession{}
|
||||
session.ScanUserID(sql.NullInt64{Int64: 1, Valid: true})
|
||||
session.ScanUserID(sql.NullInt64{})
|
||||
|
||||
if session.IsAuthenticated() {
|
||||
t.Error("ScanUserID with an invalid value must clear the user binding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSession_LanguageAndThemeDefaults(t *testing.T) {
|
||||
session := &WebSession{}
|
||||
|
||||
if got := session.Language(); got != defaultSessionLanguage {
|
||||
t.Errorf("default Language() = %q, want %q", got, defaultSessionLanguage)
|
||||
}
|
||||
if got := session.Theme(); got != defaultSessionTheme {
|
||||
t.Errorf("default Theme() = %q, want %q", got, defaultSessionTheme)
|
||||
}
|
||||
|
||||
session.SetLanguage("de_DE")
|
||||
session.SetTheme("light_sans_serif")
|
||||
|
||||
if got := session.Language(); got != "de_DE" {
|
||||
t.Errorf("Language() = %q, want %q", got, "de_DE")
|
||||
}
|
||||
if got := session.Theme(); got != "light_sans_serif" {
|
||||
t.Errorf("Theme() = %q, want %q", got, "light_sans_serif")
|
||||
}
|
||||
if !session.IsDirty() {
|
||||
t.Error("SetLanguage/SetTheme must mark the session dirty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSession_OAuth2FlowLifecycle(t *testing.T) {
|
||||
session := &WebSession{}
|
||||
|
||||
if session.OAuth2State() != "" {
|
||||
t.Error("OAuth2State() must be empty by default")
|
||||
}
|
||||
if session.OAuth2CodeVerifier() != "" {
|
||||
t.Error("OAuth2CodeVerifier() must be empty by default")
|
||||
}
|
||||
|
||||
session.StartOAuth2Flow("state-token", "code-verifier")
|
||||
|
||||
if got := session.OAuth2State(); got != "state-token" {
|
||||
t.Errorf("OAuth2State() = %q, want %q", got, "state-token")
|
||||
}
|
||||
if got := session.OAuth2CodeVerifier(); got != "code-verifier" {
|
||||
t.Errorf("OAuth2CodeVerifier() = %q, want %q", got, "code-verifier")
|
||||
}
|
||||
if !session.IsDirty() {
|
||||
t.Error("StartOAuth2Flow must mark the session dirty")
|
||||
}
|
||||
|
||||
session.ClearOAuth2Flow()
|
||||
|
||||
if session.OAuth2State() != "" {
|
||||
t.Errorf("OAuth2State() after Clear = %q, want empty", session.OAuth2State())
|
||||
}
|
||||
if session.OAuth2CodeVerifier() != "" {
|
||||
t.Errorf("OAuth2CodeVerifier() after Clear = %q, want empty", session.OAuth2CodeVerifier())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSession_ConsumeMessages(t *testing.T) {
|
||||
t.Run("no messages", func(t *testing.T) {
|
||||
session := &WebSession{}
|
||||
|
||||
success, errMsg := session.ConsumeMessages()
|
||||
if success != "" || errMsg != "" {
|
||||
t.Errorf("ConsumeMessages() = (%q, %q), want empty", success, errMsg)
|
||||
}
|
||||
if session.IsDirty() {
|
||||
t.Error("ConsumeMessages with no messages must not mark the session dirty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns and clears", func(t *testing.T) {
|
||||
session := &WebSession{}
|
||||
session.SetSuccessMessage("saved")
|
||||
session.SetErrorMessage("nope")
|
||||
session.dirty = false // isolate the dirty contribution of ConsumeMessages
|
||||
|
||||
success, errMsg := session.ConsumeMessages()
|
||||
if success != "saved" || errMsg != "nope" {
|
||||
t.Errorf("ConsumeMessages() = (%q, %q), want (%q, %q)", success, errMsg, "saved", "nope")
|
||||
}
|
||||
if !session.IsDirty() {
|
||||
t.Error("ConsumeMessages with messages must mark the session dirty")
|
||||
}
|
||||
|
||||
success, errMsg = session.ConsumeMessages()
|
||||
if success != "" || errMsg != "" {
|
||||
t.Errorf("second ConsumeMessages() = (%q, %q), want empty", success, errMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebSession_ConsumeWebAuthnSession(t *testing.T) {
|
||||
t.Run("no data", func(t *testing.T) {
|
||||
session := &WebSession{}
|
||||
|
||||
if got := session.ConsumeWebAuthnSession(); got != nil {
|
||||
t.Errorf("ConsumeWebAuthnSession() = %v, want nil", got)
|
||||
}
|
||||
if session.IsDirty() {
|
||||
t.Error("ConsumeWebAuthnSession with no data must not mark the session dirty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns and clears", func(t *testing.T) {
|
||||
data := &webauthn.SessionData{}
|
||||
session := &WebSession{}
|
||||
session.SetWebAuthn(data)
|
||||
session.dirty = false // isolate the dirty contribution of ConsumeWebAuthnSession
|
||||
|
||||
if got := session.ConsumeWebAuthnSession(); got != data {
|
||||
t.Errorf("ConsumeWebAuthnSession() = %p, want %p", got, data)
|
||||
}
|
||||
if !session.IsDirty() {
|
||||
t.Error("ConsumeWebAuthnSession with data must mark the session dirty")
|
||||
}
|
||||
if got := session.ConsumeWebAuthnSession(); got != nil {
|
||||
t.Errorf("second ConsumeWebAuthnSession() = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebSession_MarkForceRefreshed(t *testing.T) {
|
||||
session := &WebSession{}
|
||||
|
||||
if got := session.LastForceRefresh(); !got.IsZero() {
|
||||
t.Errorf("default LastForceRefresh() = %v, want zero time", got)
|
||||
}
|
||||
|
||||
before := time.Now().UTC()
|
||||
session.MarkForceRefreshed()
|
||||
after := time.Now().UTC()
|
||||
|
||||
got := session.LastForceRefresh()
|
||||
if got.Before(before) || got.After(after) {
|
||||
t.Errorf("LastForceRefresh() = %v, want between %v and %v", got, before, after)
|
||||
}
|
||||
if !session.IsDirty() {
|
||||
t.Error("MarkForceRefreshed must mark the session dirty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSession_StateRoundTrip(t *testing.T) {
|
||||
original := &WebSession{}
|
||||
original.SetLanguage("de_DE")
|
||||
original.SetTheme("light_sans_serif")
|
||||
original.SetSuccessMessage("saved")
|
||||
original.SetErrorMessage("oops")
|
||||
original.StartOAuth2Flow("state-token", "code-verifier")
|
||||
original.MarkForceRefreshed()
|
||||
originalRefreshAt := original.LastForceRefresh()
|
||||
|
||||
data, err := original.MarshalState()
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalState() error: %v", err)
|
||||
}
|
||||
if !json.Valid(data) {
|
||||
t.Errorf("MarshalState() produced invalid JSON: %s", data)
|
||||
}
|
||||
|
||||
restored := &WebSession{}
|
||||
if err := restored.UnmarshalState(data); err != nil {
|
||||
t.Fatalf("UnmarshalState() error: %v", err)
|
||||
}
|
||||
|
||||
if got := restored.Language(); got != "de_DE" {
|
||||
t.Errorf("Language() = %q, want %q", got, "de_DE")
|
||||
}
|
||||
if got := restored.Theme(); got != "light_sans_serif" {
|
||||
t.Errorf("Theme() = %q, want %q", got, "light_sans_serif")
|
||||
}
|
||||
if got := restored.OAuth2State(); got != "state-token" {
|
||||
t.Errorf("OAuth2State() = %q, want %q", got, "state-token")
|
||||
}
|
||||
if got := restored.OAuth2CodeVerifier(); got != "code-verifier" {
|
||||
t.Errorf("OAuth2CodeVerifier() = %q, want %q", got, "code-verifier")
|
||||
}
|
||||
if got := restored.LastForceRefresh(); !got.Equal(originalRefreshAt) {
|
||||
t.Errorf("LastForceRefresh() = %v, want %v", got, originalRefreshAt)
|
||||
}
|
||||
|
||||
success, errMsg := restored.ConsumeMessages()
|
||||
if success != "saved" || errMsg != "oops" {
|
||||
t.Errorf("ConsumeMessages() = (%q, %q), want (%q, %q)", success, errMsg, "saved", "oops")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSession_UnmarshalState_EmptyDataResetsState(t *testing.T) {
|
||||
session := &WebSession{}
|
||||
session.SetLanguage("fr_FR")
|
||||
session.StartOAuth2Flow("s", "v")
|
||||
|
||||
if err := session.UnmarshalState(nil); err != nil {
|
||||
t.Fatalf("UnmarshalState(nil) error: %v", err)
|
||||
}
|
||||
|
||||
if got := session.Language(); got != defaultSessionLanguage {
|
||||
t.Errorf("UnmarshalState(nil) did not reset Language: got %q", got)
|
||||
}
|
||||
if session.OAuth2State() != "" {
|
||||
t.Error("UnmarshalState(nil) did not reset OAuth2 state")
|
||||
}
|
||||
}
|
||||
@@ -4,47 +4,21 @@
|
||||
package model // import "miniflux.app/v2/internal/model"
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// WebAuthnSession handles marshalling / unmarshalling session data
|
||||
type WebAuthnSession struct {
|
||||
*webauthn.SessionData
|
||||
}
|
||||
|
||||
func (s WebAuthnSession) Value() (driver.Value, error) {
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
func (s *WebAuthnSession) Scan(value any) error {
|
||||
b, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New("type assertion to []byte failed")
|
||||
}
|
||||
|
||||
return json.Unmarshal(b, &s)
|
||||
}
|
||||
|
||||
func (s WebAuthnSession) String() string {
|
||||
if s.SessionData == nil {
|
||||
return "{}"
|
||||
}
|
||||
return fmt.Sprintf("{Challenge: %s, UserID: %x}", s.Challenge, s.UserID)
|
||||
}
|
||||
|
||||
type WebAuthnCredential struct {
|
||||
Credential webauthn.Credential
|
||||
Name string
|
||||
AddedOn *time.Time
|
||||
LastSeenOn *time.Time
|
||||
Handle []byte
|
||||
|
||||
// False for rows predating the backup_eligible column; the login handler backfills from the assertion on first use.
|
||||
BackupEligibleKnown bool
|
||||
}
|
||||
|
||||
func (s WebAuthnCredential) HandleEncoded() string {
|
||||
|
||||
@@ -5,6 +5,7 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
@@ -19,7 +20,10 @@ type atom03Adapter struct {
|
||||
}
|
||||
|
||||
func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
feed := new(model.Feed)
|
||||
feed := &model.Feed{
|
||||
FeedURL: baseURL,
|
||||
SiteURL: baseURL,
|
||||
}
|
||||
|
||||
// Populate the feed URL.
|
||||
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
|
||||
@@ -27,8 +31,6 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {
|
||||
feed.FeedURL = absoluteFeedURL
|
||||
}
|
||||
} else {
|
||||
feed.FeedURL = baseURL
|
||||
}
|
||||
|
||||
// Populate the site URL.
|
||||
@@ -37,8 +39,6 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
if absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {
|
||||
feed.SiteURL = absoluteSiteURL
|
||||
}
|
||||
} else {
|
||||
feed.SiteURL = baseURL
|
||||
}
|
||||
|
||||
// Populate the feed title.
|
||||
@@ -69,6 +69,7 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
if entry.Title == "" {
|
||||
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
|
||||
}
|
||||
|
||||
if entry.Title == "" {
|
||||
entry.Title = entry.URL
|
||||
}
|
||||
@@ -81,17 +82,24 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
|
||||
|
||||
// Populate the entry date.
|
||||
for _, value := range []string{atomEntry.Issued, atomEntry.Modified, atomEntry.Created} {
|
||||
if parsedDate, err := date.Parse(value); err == nil {
|
||||
entry.Date = parsedDate
|
||||
break
|
||||
} else {
|
||||
if value = strings.TrimSpace(value); value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parsedDate, err := date.Parse(value)
|
||||
if err != nil {
|
||||
slog.Debug("Unable to parse date from Atom 0.3 feed",
|
||||
slog.String("date", value),
|
||||
slog.String("id", atomEntry.ID),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
entry.Date = parsedDate
|
||||
break
|
||||
}
|
||||
|
||||
if entry.Date.IsZero() {
|
||||
entry.Date = time.Now()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user