Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Mirror to Codeberg
|
||||
|
||||
@@ -38,22 +38,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- 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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate Alpine Docker tags
|
||||
id: docker_alpine_tags
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.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@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
- name: Run gofmt linter
|
||||
run: gofmt -d -e .
|
||||
|
||||
@@ -38,11 +38,11 @@ jobs:
|
||||
name: Commit Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- 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
|
||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- 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.
|
||||
|
||||
+2
-2
@@ -1079,14 +1079,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
|
||||
|
||||
+2
-3
@@ -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.
|
||||
|
||||
@@ -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.18.0
|
||||
github.com/go-webauthn/webauthn v0.17.3
|
||||
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.51.0
|
||||
golang.org/x/image v0.40.0
|
||||
golang.org/x/net v0.54.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.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.2.2 // indirect
|
||||
github.com/go-webauthn/x v0.2.5 // 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.44.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.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/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.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
|
||||
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
|
||||
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
|
||||
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
|
||||
github.com/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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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=
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -115,7 +115,12 @@ func (h *handler) getCategoriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
includeCounts := request.QueryStringParam(r, "counts", "false")
|
||||
|
||||
if includeCounts == "true" {
|
||||
categories, err = h.store.CategoriesWithFeedCount(request.UserID(r))
|
||||
user, userErr := h.store.UserByID(request.UserID(r))
|
||||
if userErr != nil {
|
||||
response.JSONServerError(w, r, userErr)
|
||||
return
|
||||
}
|
||||
categories, err = h.store.CategoriesWithFeedCount(user.ID, user.CategoriesSortingOrder)
|
||||
} else {
|
||||
categories, err = h.store.Categories(request.UserID(r))
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -79,7 +78,6 @@ func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithCategoryID(categoryID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -93,7 +91,6 @@ func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
@@ -173,7 +170,6 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
builder.WithLimit(limit)
|
||||
builder.WithTags(tags)
|
||||
builder.WithEnclosures()
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
if request.HasQueryParam(r, "globally_visible") {
|
||||
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
|
||||
@@ -242,7 +238,6 @@ func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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"))
|
||||
@@ -292,7 +287,6 @@ 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()
|
||||
if err != nil {
|
||||
@@ -412,6 +406,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
|
||||
@@ -449,7 +447,6 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
|
||||
entryBuilder.WithEntryID(entryID)
|
||||
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := entryBuilder.GetEntry()
|
||||
if err != nil {
|
||||
|
||||
@@ -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"
|
||||
@@ -242,6 +243,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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -483,7 +484,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
|
||||
@@ -1081,7 +1082,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 +1432,82 @@ 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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -239,7 +239,6 @@ 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)
|
||||
|
||||
switch {
|
||||
@@ -294,7 +293,6 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
builder = h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
result.Total, err = builder.CountEntries()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
@@ -414,7 +412,6 @@ func (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
|
||||
@@ -238,7 +238,6 @@ func (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithEntryIDs(itemIDs)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
@@ -255,7 +254,7 @@ func (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request)
|
||||
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 +264,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)
|
||||
}
|
||||
}
|
||||
@@ -652,7 +651,6 @@ func (h *greaderHandler) streamItemContentsHandler(w http.ResponseWriter, r *htt
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithEnclosures()
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithEntryIDs(itemIDs)
|
||||
builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
|
||||
|
||||
@@ -1029,7 +1027,6 @@ 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)
|
||||
@@ -1050,7 +1047,6 @@ 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)
|
||||
@@ -1071,7 +1067,6 @@ func (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *ht
|
||||
|
||||
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)
|
||||
@@ -1121,7 +1116,6 @@ func (h *greaderHandler) handleFeedStreamHandler(w http.ResponseWriter, r *http.
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(rm.UserID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithFeedID(feedID)
|
||||
builder.WithLimit(rm.Count)
|
||||
builder.WithOffset(rm.Offset)
|
||||
|
||||
+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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -64,7 +65,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["Content-Disposition"] = formatContentDisposition("attachment", filename)
|
||||
return b
|
||||
}
|
||||
|
||||
// WithInline suggests an inline filename for the current response.
|
||||
func (b *Builder) WithInline(filename string) *Builder {
|
||||
b.headers["Content-Disposition"] = formatContentDisposition("inline", filename)
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -181,3 +188,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 {
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
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.
|
||||
@@ -117,8 +119,12 @@ func HTMLNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
builder.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)
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,75 @@ 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",
|
||||
"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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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?",
|
||||
@@ -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?",
|
||||
@@ -343,7 +344,9 @@
|
||||
"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.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\".",
|
||||
@@ -425,7 +428,8 @@
|
||||
"menu.title": "Menú",
|
||||
"menu.unread": "Sen ler",
|
||||
"menu.users": "Usuarias",
|
||||
"page.about.author": "Autoría:",
|
||||
"page.about.authors_label": "Autorías:",
|
||||
"page.about.authors_value": "Frédéric Guillot e colaboradores",
|
||||
"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": "共有エントリ",
|
||||
|
||||
@@ -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,14 @@ 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
|
||||
|
||||
// Entry represents a feed item in the system.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -33,8 +33,8 @@ func (a *AtomPerson) PersonName() string {
|
||||
type atomPersons []*AtomPerson
|
||||
|
||||
func (a atomPersons) personNames() []string {
|
||||
var names []string
|
||||
authorNamesMap := make(map[string]bool)
|
||||
names := make([]string, 0, len(a))
|
||||
authorNamesMap := make(map[string]bool, len(a))
|
||||
|
||||
for _, person := range a {
|
||||
personName := person.PersonName()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package fetcher // import "miniflux.app/v2/internal/reader/fetcher"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -139,18 +141,40 @@ func (r *RequestBuilder) WithoutCompression() *RequestBuilder {
|
||||
}
|
||||
|
||||
func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) {
|
||||
dialer := &net.Dialer{
|
||||
var clientProxyURL *url.URL
|
||||
|
||||
switch {
|
||||
case r.feedProxyURL != "":
|
||||
var err error
|
||||
clientProxyURL, err = url.Parse(r.feedProxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`fetcher: invalid feed proxy URL %q: %w`, r.feedProxyURL, err)
|
||||
}
|
||||
case r.useClientProxy && r.clientProxyURL != nil:
|
||||
clientProxyURL = r.clientProxyURL
|
||||
case r.proxyRotator != nil && r.proxyRotator.HasProxies():
|
||||
clientProxyURL = r.proxyRotator.GetNextProxy()
|
||||
}
|
||||
|
||||
directDialer := &net.Dialer{
|
||||
Timeout: 10 * time.Second, // Default is 30s.
|
||||
KeepAlive: 15 * time.Second, // Default is 30s.
|
||||
}
|
||||
|
||||
proxyDialer := &net.Dialer{
|
||||
Timeout: 10 * time.Second, // Default is 30s.
|
||||
KeepAlive: 15 * time.Second, // Default is 30s.
|
||||
}
|
||||
|
||||
proxyDialAddress := normalizeProxyDialAddress(clientProxyURL)
|
||||
|
||||
// Perform the private-network check inside the dialer's Control callback,
|
||||
// which fires after DNS resolution but before the TCP connection is made.
|
||||
// This eliminates TOCTOU / DNS-rebinding vulnerabilities: the resolved IP
|
||||
// that is checked is exactly the IP that will be connected to.
|
||||
allowPrivateNetworks := config.Opts == nil || config.Opts.FetcherAllowPrivateNetworks()
|
||||
if !allowPrivateNetworks {
|
||||
dialer.Control = func(network, address string, c syscall.RawConn) error {
|
||||
directDialer.Control = func(network, address string, c syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -169,11 +193,23 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
// Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless.
|
||||
ForceAttemptHTTP2: true,
|
||||
DialContext: dialer.DialContext,
|
||||
MaxIdleConns: 50, // Default is 100.
|
||||
IdleConnTimeout: 10 * time.Second, // Default is 90s.
|
||||
}
|
||||
|
||||
transport.DialContext = directDialer.DialContext
|
||||
if !allowPrivateNetworks && proxyDialAddress != "" {
|
||||
// Explicitly configured proxies are a trusted hop. Keep the private-network
|
||||
// check for direct requests and redirects, but allow the connection to the proxy itself.
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if normalizeDialAddress(addr) == proxyDialAddress {
|
||||
return proxyDialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
return directDialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
}
|
||||
|
||||
if r.ignoreTLSErrors {
|
||||
// Add insecure ciphers if we are ignoring TLS errors. This allows to connect to badly configured servers anyway
|
||||
ciphers := slices.Concat(tls.CipherSuites(), tls.InsecureCipherSuites())
|
||||
@@ -195,21 +231,6 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
||||
transport.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
|
||||
}
|
||||
|
||||
var clientProxyURL *url.URL
|
||||
|
||||
switch {
|
||||
case r.feedProxyURL != "":
|
||||
var err error
|
||||
clientProxyURL, err = url.Parse(r.feedProxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`fetcher: invalid feed proxy URL %q: %w`, r.feedProxyURL, err)
|
||||
}
|
||||
case r.useClientProxy && r.clientProxyURL != nil:
|
||||
clientProxyURL = r.clientProxyURL
|
||||
case r.proxyRotator != nil && r.proxyRotator.HasProxies():
|
||||
clientProxyURL = r.proxyRotator.GetNextProxy()
|
||||
}
|
||||
|
||||
var clientProxyURLRedacted string
|
||||
if clientProxyURL != nil {
|
||||
transport.Proxy = http.ProxyURL(clientProxyURL)
|
||||
@@ -261,3 +282,34 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
||||
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func normalizeDialAddress(addr string) string {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return net.JoinHostPort(strings.ToLower(host), port)
|
||||
}
|
||||
|
||||
func normalizeProxyDialAddress(proxyURL *url.URL) string {
|
||||
if proxyURL == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
port := proxyURL.Port()
|
||||
if port == "" {
|
||||
switch strings.ToLower(proxyURL.Scheme) {
|
||||
case "", "http":
|
||||
port = "80"
|
||||
case "https":
|
||||
port = "443"
|
||||
case "socks5", "socks5h":
|
||||
port = "1080"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
return net.JoinHostPort(strings.ToLower(proxyURL.Hostname()), port)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/proxyrotator"
|
||||
)
|
||||
|
||||
func TestNewRequestBuilder(t *testing.T) {
|
||||
@@ -436,6 +437,80 @@ func TestRequestBuilder_AllowPrivateNetworkWhenEnabled(t *testing.T) {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestRequestBuilder_AllowPrivateConfiguredProxy(t *testing.T) {
|
||||
configureFetcherAllowPrivateNetworksOption(t, "0")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
configure func(t *testing.T, builder *RequestBuilder, proxyURL string) *RequestBuilder
|
||||
}{
|
||||
{
|
||||
name: "feed proxy",
|
||||
configure: func(t *testing.T, builder *RequestBuilder, proxyURL string) *RequestBuilder {
|
||||
return builder.WithCustomFeedProxyURL(proxyURL)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application proxy",
|
||||
configure: func(t *testing.T, builder *RequestBuilder, proxyURL string) *RequestBuilder {
|
||||
t.Helper()
|
||||
|
||||
parsedProxyURL, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to parse proxy URL: %v", err)
|
||||
}
|
||||
|
||||
return builder.WithCustomApplicationProxyURL(parsedProxyURL).UseCustomApplicationProxyURL(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proxy rotator",
|
||||
configure: func(t *testing.T, builder *RequestBuilder, proxyURL string) *RequestBuilder {
|
||||
t.Helper()
|
||||
|
||||
rotator, err := proxyrotator.NewProxyRotator([]string{proxyURL})
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create proxy rotator: %v", err)
|
||||
}
|
||||
|
||||
return builder.WithProxyRotator(rotator)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
targetURL := "http://feed.invalid/rss.xml"
|
||||
proxyRequests := make(chan string, 1)
|
||||
proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case proxyRequests <- r.URL.String():
|
||||
default:
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer proxyServer.Close()
|
||||
|
||||
builder := tt.configure(t, NewRequestBuilder(), proxyServer.URL)
|
||||
resp, err := builder.ExecuteRequest(targetURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected private proxy request to succeed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
select {
|
||||
case gotURL := <-proxyRequests:
|
||||
if gotURL != targetURL {
|
||||
t.Fatalf("Expected proxy request URL to be %q, got %q", targetURL, gotURL)
|
||||
}
|
||||
default:
|
||||
t.Fatal("Expected request to be sent through the proxy")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBuilder_RefusePrivateNetworkOnRedirect(t *testing.T) {
|
||||
configureFetcherAllowPrivateNetworksOption(t, "0")
|
||||
|
||||
|
||||
@@ -189,6 +189,10 @@ func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
if r.isCloudflareChallenge() {
|
||||
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: blocked by Cloudflare challenge (%d status code)", r.httpResponse.StatusCode), "error.http_cloudflare_challenge")
|
||||
}
|
||||
|
||||
switch r.httpResponse.StatusCode {
|
||||
case http.StatusUnauthorized:
|
||||
return locale.NewLocalizedErrorWrapper(errors.New("fetcher: access unauthorized (401 status code)"), "error.http_not_authorized")
|
||||
@@ -224,31 +228,48 @@ func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isCloudflareChallenge reports whether the response looks like a Cloudflare
|
||||
// bot/captcha interstitial rather than a genuine error from the origin. It
|
||||
// relies on response headers only (no body read) to keep the check cheap and
|
||||
// to run before ReadBody is called.
|
||||
func (r *ResponseHandler) isCloudflareChallenge() bool {
|
||||
if r.httpResponse == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return r.httpResponse.StatusCode == http.StatusForbidden &&
|
||||
strings.EqualFold(r.httpResponse.Header.Get("cf-mitigated"), "challenge") &&
|
||||
strings.HasPrefix(strings.ToLower(r.ContentType()), "text/html")
|
||||
}
|
||||
|
||||
func isNetworkError(err error) bool {
|
||||
if _, ok := err.(*url.Error); ok {
|
||||
if _, ok := errors.AsType[*url.Error](err); ok {
|
||||
return true
|
||||
}
|
||||
if err == io.EOF {
|
||||
|
||||
if errors.Is(err, io.EOF) {
|
||||
return true
|
||||
}
|
||||
var opErr *net.OpError
|
||||
if ok := errors.As(err, &opErr); ok {
|
||||
|
||||
if _, ok := errors.AsType[*net.OpError](err); ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isSSLError(err error) bool {
|
||||
var certErr x509.UnknownAuthorityError
|
||||
if errors.As(err, &certErr) {
|
||||
if _, ok := errors.AsType[x509.UnknownAuthorityError](err); ok {
|
||||
return true
|
||||
}
|
||||
|
||||
var hostErr x509.HostnameError
|
||||
if errors.As(err, &hostErr) {
|
||||
if _, ok := errors.AsType[x509.HostnameError](err); ok {
|
||||
return true
|
||||
}
|
||||
|
||||
var algErr x509.InsecureAlgorithmError
|
||||
return errors.As(err, &algErr)
|
||||
if _, ok := errors.AsType[x509.InsecureAlgorithmError](err); ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -190,6 +190,95 @@ func TestCacheControlMaxAgeInMinutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCloudflareChallenge(t *testing.T) {
|
||||
makeResp := func(status int, headers map[string]string) *http.Response {
|
||||
h := http.Header{}
|
||||
for k, v := range headers {
|
||||
h.Set(k, v)
|
||||
}
|
||||
return &http.Response{StatusCode: status, Header: h}
|
||||
}
|
||||
|
||||
cases := map[string]struct {
|
||||
response *http.Response
|
||||
expected bool
|
||||
}{
|
||||
"403 with cf-mitigated challenge and html": {
|
||||
response: makeResp(http.StatusForbidden, map[string]string{
|
||||
"Cf-Mitigated": "challenge",
|
||||
"Content-Type": "text/html; charset=UTF-8",
|
||||
}),
|
||||
expected: true,
|
||||
},
|
||||
"cf-mitigated challenge header on 200": {
|
||||
response: makeResp(http.StatusOK, map[string]string{
|
||||
"Cf-Mitigated": "challenge",
|
||||
"Content-Type": "text/html",
|
||||
}),
|
||||
expected: false,
|
||||
},
|
||||
"403 cf-mitigated challenge without html": {
|
||||
response: makeResp(http.StatusForbidden, map[string]string{
|
||||
"Cf-Mitigated": "challenge",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
expected: false,
|
||||
},
|
||||
"403 from cloudflare with html but no challenge signal": {
|
||||
response: makeResp(http.StatusForbidden, map[string]string{
|
||||
"Server": "cloudflare",
|
||||
"Cf-Ray": "8abc123def456-IAD",
|
||||
"Content-Type": "text/html; charset=UTF-8",
|
||||
}),
|
||||
expected: false,
|
||||
},
|
||||
"503 from cloudflare with html but no challenge signal": {
|
||||
response: makeResp(http.StatusServiceUnavailable, map[string]string{
|
||||
"Server": "cloudflare",
|
||||
"Cf-Ray": "8abc123def456-IAD",
|
||||
"Content-Type": "text/html",
|
||||
}),
|
||||
expected: false,
|
||||
},
|
||||
"403 from non-cloudflare server": {
|
||||
response: makeResp(http.StatusForbidden, map[string]string{
|
||||
"Server": "nginx",
|
||||
"Content-Type": "text/html",
|
||||
}),
|
||||
expected: false,
|
||||
},
|
||||
"500 from cloudflare with html": {
|
||||
response: makeResp(http.StatusInternalServerError, map[string]string{
|
||||
"Server": "cloudflare",
|
||||
"Cf-Ray": "8abc123def456-IAD",
|
||||
"Content-Type": "text/html",
|
||||
}),
|
||||
expected: false,
|
||||
},
|
||||
"200 OK from cloudflare": {
|
||||
response: makeResp(http.StatusOK, map[string]string{
|
||||
"Server": "cloudflare",
|
||||
"Cf-Ray": "8abc123def456-IAD",
|
||||
"Content-Type": "application/rss+xml",
|
||||
}),
|
||||
expected: false,
|
||||
},
|
||||
"nil response": {
|
||||
response: nil,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rh := &ResponseHandler{httpResponse: tc.response}
|
||||
if got := rh.isCloudflareChallenge(); got != tc.expected {
|
||||
t.Errorf("isCloudflareChallenge() = %v, want %v", got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseHandlerCloseClosesBodyOnClientError(t *testing.T) {
|
||||
body := &testReadCloser{}
|
||||
rh := ResponseHandler{
|
||||
|
||||
+117
-35
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
// Handler handles the logic for OPML import/export.
|
||||
@@ -31,6 +32,24 @@ func (h *Handler) Export(userID int64) (string, error) {
|
||||
SiteURL: feed.SiteURL,
|
||||
Description: feed.Description,
|
||||
CategoryName: feed.Category.Title,
|
||||
|
||||
ScraperRules: feed.ScraperRules,
|
||||
RewriteRules: feed.RewriteRules,
|
||||
UrlRewriteRules: feed.UrlRewriteRules,
|
||||
BlocklistRules: feed.BlocklistRules,
|
||||
KeeplistRules: feed.KeeplistRules,
|
||||
BlockFilterEntryRules: feed.BlockFilterEntryRules,
|
||||
KeepFilterEntryRules: feed.KeepFilterEntryRules,
|
||||
UserAgent: feed.UserAgent,
|
||||
Crawler: feed.Crawler,
|
||||
IgnoreHTTPCache: feed.IgnoreHTTPCache,
|
||||
FetchViaProxy: feed.FetchViaProxy,
|
||||
Disabled: feed.Disabled,
|
||||
NoMediaPlayer: feed.NoMediaPlayer,
|
||||
HideGlobally: feed.HideGlobally,
|
||||
AllowSelfSignedCertificates: feed.AllowSelfSignedCertificates,
|
||||
DisableHTTP2: feed.DisableHTTP2,
|
||||
IgnoreEntryUpdates: feed.IgnoreEntryUpdates,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,42 +64,105 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
|
||||
}
|
||||
|
||||
for _, subscription := range subscriptions {
|
||||
if !h.store.FeedURLExists(userID, subscription.FeedURL) {
|
||||
var category *model.Category
|
||||
var err error
|
||||
|
||||
if subscription.CategoryName == "" {
|
||||
category, err = h.store.FirstCategory(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opml: unable to find first category: %w", err)
|
||||
}
|
||||
} else {
|
||||
category, err = h.store.CategoryByTitle(userID, subscription.CategoryName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opml: unable to search category by title: %w", err)
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
category, err = h.store.CreateCategory(userID, &model.CategoryCreationRequest{Title: subscription.CategoryName})
|
||||
if err != nil {
|
||||
return fmt.Errorf(`opml: unable to create this category: %q`, subscription.CategoryName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
feed := &model.Feed{
|
||||
UserID: userID,
|
||||
Title: subscription.Title,
|
||||
FeedURL: subscription.FeedURL,
|
||||
SiteURL: subscription.SiteURL,
|
||||
Description: subscription.Description,
|
||||
Category: category,
|
||||
}
|
||||
|
||||
if err := h.store.CreateFeed(feed); err != nil {
|
||||
return fmt.Errorf(`opml: unable to create this feed: %q`, subscription.FeedURL)
|
||||
}
|
||||
if h.store.FeedURLExists(userID, subscription.FeedURL) {
|
||||
continue
|
||||
}
|
||||
|
||||
category, err := h.resolveCategory(userID, subscription.CategoryName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if validationErr := validateSubscription(userID, category.ID, h.store, subscription); validationErr != nil {
|
||||
return fmt.Errorf(`opml: invalid feed settings for %q: %w`, subscription.FeedURL, validationErr)
|
||||
}
|
||||
|
||||
feed := &model.Feed{
|
||||
UserID: userID,
|
||||
Title: subscription.Title,
|
||||
FeedURL: subscription.FeedURL,
|
||||
SiteURL: subscription.SiteURL,
|
||||
Description: subscription.Description,
|
||||
Category: category,
|
||||
}
|
||||
applySubscriptionSettings(feed, subscription)
|
||||
if err := h.store.CreateFeed(feed); err != nil {
|
||||
return fmt.Errorf(`opml: unable to create this feed: %q`, subscription.FeedURL)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) resolveCategory(userID int64, categoryName string) (*model.Category, error) {
|
||||
if categoryName == "" {
|
||||
category, err := h.store.FirstCategory(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opml: unable to find first category: %w", err)
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
category, err := h.store.CategoryByTitle(userID, categoryName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opml: unable to search category by title: %w", err)
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
category, err = h.store.CreateCategory(userID, &model.CategoryCreationRequest{Title: categoryName})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`opml: unable to create this category: %q`, categoryName)
|
||||
}
|
||||
}
|
||||
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func applySubscriptionSettings(feed *model.Feed, s subcription) {
|
||||
feed.ScraperRules = s.ScraperRules
|
||||
feed.RewriteRules = s.RewriteRules
|
||||
feed.UrlRewriteRules = s.UrlRewriteRules
|
||||
feed.BlocklistRules = s.BlocklistRules
|
||||
feed.KeeplistRules = s.KeeplistRules
|
||||
feed.BlockFilterEntryRules = s.BlockFilterEntryRules
|
||||
feed.KeepFilterEntryRules = s.KeepFilterEntryRules
|
||||
feed.UserAgent = s.UserAgent
|
||||
feed.Crawler = s.Crawler
|
||||
feed.IgnoreHTTPCache = s.IgnoreHTTPCache
|
||||
feed.FetchViaProxy = s.FetchViaProxy
|
||||
feed.Disabled = s.Disabled
|
||||
feed.NoMediaPlayer = s.NoMediaPlayer
|
||||
feed.HideGlobally = s.HideGlobally
|
||||
feed.AllowSelfSignedCertificates = s.AllowSelfSignedCertificates
|
||||
feed.DisableHTTP2 = s.DisableHTTP2
|
||||
feed.IgnoreEntryUpdates = s.IgnoreEntryUpdates
|
||||
}
|
||||
|
||||
func validateSubscription(userID, categoryID int64, store *storage.Storage, s subcription) error {
|
||||
feedCreationRequest := &model.FeedCreationRequest{
|
||||
FeedURL: s.FeedURL,
|
||||
CategoryID: categoryID,
|
||||
UserAgent: s.UserAgent,
|
||||
Crawler: s.Crawler,
|
||||
IgnoreEntryUpdates: s.IgnoreEntryUpdates,
|
||||
Disabled: s.Disabled,
|
||||
NoMediaPlayer: s.NoMediaPlayer,
|
||||
IgnoreHTTPCache: s.IgnoreHTTPCache,
|
||||
AllowSelfSignedCertificates: s.AllowSelfSignedCertificates,
|
||||
FetchViaProxy: s.FetchViaProxy,
|
||||
HideGlobally: s.HideGlobally,
|
||||
DisableHTTP2: s.DisableHTTP2,
|
||||
ScraperRules: s.ScraperRules,
|
||||
RewriteRules: s.RewriteRules,
|
||||
BlocklistRules: s.BlocklistRules,
|
||||
KeeplistRules: s.KeeplistRules,
|
||||
BlockFilterEntryRules: s.BlockFilterEntryRules,
|
||||
KeepFilterEntryRules: s.KeepFilterEntryRules,
|
||||
UrlRewriteRules: s.UrlRewriteRules,
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateFeedCreation(store, userID, feedCreationRequest); validationErr != nil {
|
||||
return validationErr.Error()
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -5,15 +5,20 @@ package opml // import "miniflux.app/v2/internal/reader/opml"
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const minifluxOPMLNamespace = "https://miniflux.app/opml"
|
||||
|
||||
// Specs: http://opml.org/spec2.opml
|
||||
type opmlDocument struct {
|
||||
XMLName xml.Name `xml:"opml"`
|
||||
Version string `xml:"version,attr"`
|
||||
Header opmlHeader `xml:"head"`
|
||||
Outlines opmlOutlineCollection `xml:"body>outline"`
|
||||
XMLName xml.Name `xml:"opml"`
|
||||
Version string `xml:"version,attr"`
|
||||
MinifluxNamespace string `xml:"xmlns:miniflux,attr,omitempty"`
|
||||
Header opmlHeader `xml:"head"`
|
||||
Outlines opmlOutlineCollection `xml:"body>outline"`
|
||||
}
|
||||
|
||||
type opmlHeader struct {
|
||||
@@ -29,6 +34,25 @@ type opmlOutline struct {
|
||||
SiteURL string `xml:"htmlUrl,attr,omitempty"`
|
||||
Description string `xml:"description,attr,omitempty"`
|
||||
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
|
||||
|
||||
// Miniflux-specific feed settings
|
||||
ScraperRules string `xml:"miniflux:scraperRules,attr,omitempty"`
|
||||
RewriteRules string `xml:"miniflux:rewriteRules,attr,omitempty"`
|
||||
UrlRewriteRules string `xml:"miniflux:urlRewriteRules,attr,omitempty"`
|
||||
BlocklistRules string `xml:"miniflux:blocklistRules,attr,omitempty"`
|
||||
KeeplistRules string `xml:"miniflux:keeplistRules,attr,omitempty"`
|
||||
BlockFilterEntryRules string `xml:"miniflux:blockFilterEntryRules,attr,omitempty"`
|
||||
KeepFilterEntryRules string `xml:"miniflux:keepFilterEntryRules,attr,omitempty"`
|
||||
UserAgent string `xml:"miniflux:userAgent,attr,omitempty"`
|
||||
Crawler bool `xml:"miniflux:crawler,attr,omitempty"`
|
||||
IgnoreHTTPCache bool `xml:"miniflux:ignoreHTTPCache,attr,omitempty"`
|
||||
FetchViaProxy bool `xml:"miniflux:fetchViaProxy,attr,omitempty"`
|
||||
Disabled bool `xml:"miniflux:disabled,attr,omitempty"`
|
||||
NoMediaPlayer bool `xml:"miniflux:noMediaPlayer,attr,omitempty"`
|
||||
HideGlobally bool `xml:"miniflux:hideGlobally,attr,omitempty"`
|
||||
AllowSelfSignedCertificates bool `xml:"miniflux:allowSelfSignedCertificates,attr,omitempty"`
|
||||
DisableHTTP2 bool `xml:"miniflux:disableHTTP2,attr,omitempty"`
|
||||
IgnoreEntryUpdates bool `xml:"miniflux:ignoreEntryUpdates,attr,omitempty"`
|
||||
}
|
||||
|
||||
func (o opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
@@ -48,6 +72,25 @@ func (o opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
}, start)
|
||||
}
|
||||
|
||||
func (o *opmlOutline) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
*o = opmlOutline{}
|
||||
|
||||
type opmlOutlineAlias opmlOutline
|
||||
if err := d.DecodeElement((*opmlOutlineAlias)(o), &start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == minifluxOPMLNamespace {
|
||||
if err := o.setMinifluxAttribute(attr.Name.Local, attr.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o opmlOutline) IsSubscription() bool {
|
||||
return strings.TrimSpace(o.FeedURL) != ""
|
||||
}
|
||||
@@ -85,3 +128,54 @@ type opmlOutlineCollection []opmlOutline
|
||||
func (o opmlOutlineCollection) HasChildren() bool {
|
||||
return len(o) > 0
|
||||
}
|
||||
|
||||
func (o *opmlOutline) setMinifluxAttribute(name, value string) error {
|
||||
switch name {
|
||||
case "scraperRules":
|
||||
o.ScraperRules = value
|
||||
case "rewriteRules":
|
||||
o.RewriteRules = value
|
||||
case "urlRewriteRules":
|
||||
o.UrlRewriteRules = value
|
||||
case "blocklistRules":
|
||||
o.BlocklistRules = value
|
||||
case "keeplistRules":
|
||||
o.KeeplistRules = value
|
||||
case "blockFilterEntryRules":
|
||||
o.BlockFilterEntryRules = value
|
||||
case "keepFilterEntryRules":
|
||||
o.KeepFilterEntryRules = value
|
||||
case "userAgent":
|
||||
o.UserAgent = value
|
||||
case "crawler":
|
||||
return setMinifluxBoolAttribute(name, value, &o.Crawler)
|
||||
case "ignoreHTTPCache":
|
||||
return setMinifluxBoolAttribute(name, value, &o.IgnoreHTTPCache)
|
||||
case "fetchViaProxy":
|
||||
return setMinifluxBoolAttribute(name, value, &o.FetchViaProxy)
|
||||
case "disabled":
|
||||
return setMinifluxBoolAttribute(name, value, &o.Disabled)
|
||||
case "noMediaPlayer":
|
||||
return setMinifluxBoolAttribute(name, value, &o.NoMediaPlayer)
|
||||
case "hideGlobally":
|
||||
return setMinifluxBoolAttribute(name, value, &o.HideGlobally)
|
||||
case "allowSelfSignedCertificates":
|
||||
return setMinifluxBoolAttribute(name, value, &o.AllowSelfSignedCertificates)
|
||||
case "disableHTTP2":
|
||||
return setMinifluxBoolAttribute(name, value, &o.DisableHTTP2)
|
||||
case "ignoreEntryUpdates":
|
||||
return setMinifluxBoolAttribute(name, value, &o.IgnoreEntryUpdates)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setMinifluxBoolAttribute(name, value string, target *bool) error {
|
||||
parsedValue, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opml: invalid miniflux attribute %q: %w", name, err)
|
||||
}
|
||||
|
||||
*target = parsedValue
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -38,6 +38,24 @@ func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category strin
|
||||
SiteURL: outline.GetSiteURL(),
|
||||
Description: outline.Description,
|
||||
CategoryName: category,
|
||||
|
||||
ScraperRules: outline.ScraperRules,
|
||||
RewriteRules: outline.RewriteRules,
|
||||
UrlRewriteRules: outline.UrlRewriteRules,
|
||||
BlocklistRules: outline.BlocklistRules,
|
||||
KeeplistRules: outline.KeeplistRules,
|
||||
BlockFilterEntryRules: outline.BlockFilterEntryRules,
|
||||
KeepFilterEntryRules: outline.KeepFilterEntryRules,
|
||||
UserAgent: outline.UserAgent,
|
||||
Crawler: outline.Crawler,
|
||||
IgnoreHTTPCache: outline.IgnoreHTTPCache,
|
||||
FetchViaProxy: outline.FetchViaProxy,
|
||||
Disabled: outline.Disabled,
|
||||
NoMediaPlayer: outline.NoMediaPlayer,
|
||||
HideGlobally: outline.HideGlobally,
|
||||
AllowSelfSignedCertificates: outline.AllowSelfSignedCertificates,
|
||||
DisableHTTP2: outline.DisableHTTP2,
|
||||
IgnoreEntryUpdates: outline.IgnoreEntryUpdates,
|
||||
})
|
||||
} else if outline.Outlines.HasChildren() {
|
||||
subscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.GetTitle())...)
|
||||
|
||||
@@ -8,13 +8,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// equals compare two subscriptions.
|
||||
func (s subcription) equals(subscription subcription) bool {
|
||||
return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL &&
|
||||
s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName &&
|
||||
s.Description == subscription.Description
|
||||
}
|
||||
|
||||
func TestParseOpmlWithoutCategories(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="ISO-8859-1"?>
|
||||
<opml version="2.0">
|
||||
@@ -51,7 +44,7 @@ func TestParseOpmlWithoutCategories(t *testing.T) {
|
||||
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 13)
|
||||
}
|
||||
|
||||
if !subscriptions[0].equals(expected[0]) {
|
||||
if subscriptions[0] != expected[0] {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[0], expected[0])
|
||||
}
|
||||
}
|
||||
@@ -89,7 +82,7 @@ func TestParseOpmlWithCategories(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := range len(subscriptions) {
|
||||
if !subscriptions[i].equals(expected[i]) {
|
||||
if subscriptions[i] != expected[i] {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
|
||||
}
|
||||
}
|
||||
@@ -122,7 +115,7 @@ func TestParseOpmlWithEmptyTitleAndEmptySiteURL(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := range len(subscriptions) {
|
||||
if !subscriptions[i].equals(expected[i]) {
|
||||
if subscriptions[i] != expected[i] {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
|
||||
}
|
||||
}
|
||||
@@ -160,7 +153,7 @@ func TestParseOpmlVersion1(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := range len(subscriptions) {
|
||||
if !subscriptions[i].equals(expected[i]) {
|
||||
if subscriptions[i] != expected[i] {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
|
||||
}
|
||||
}
|
||||
@@ -194,7 +187,7 @@ func TestParseOpmlVersion1WithoutOuterOutline(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := range len(subscriptions) {
|
||||
if !subscriptions[i].equals(expected[i]) {
|
||||
if subscriptions[i] != expected[i] {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
|
||||
}
|
||||
}
|
||||
@@ -236,7 +229,7 @@ func TestParseOpmlVersion1WithSeveralNestedOutlines(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := range len(subscriptions) {
|
||||
if !subscriptions[i].equals(expected[i]) {
|
||||
if subscriptions[i] != expected[i] {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
|
||||
}
|
||||
}
|
||||
@@ -269,7 +262,7 @@ func TestParseOpmlWithInvalidCharacterEntity(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := range len(subscriptions) {
|
||||
if !subscriptions[i].equals(expected[i]) {
|
||||
if subscriptions[i] != expected[i] {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
|
||||
}
|
||||
}
|
||||
@@ -282,3 +275,76 @@ func TestParseInvalidXML(t *testing.T) {
|
||||
t.Error("Parse should generate an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpmlWithMinifluxSettings(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<opml version="2.0" xmlns:miniflux="https://miniflux.app/opml">
|
||||
<head><title>Miniflux</title></head>
|
||||
<body>
|
||||
<outline text="My Category">
|
||||
<outline type="rss"
|
||||
text="Feed 1"
|
||||
title="Feed 1"
|
||||
xmlUrl="http://example.org/feed1/"
|
||||
htmlUrl="http://example.org/1"
|
||||
miniflux:scraperRules="article.content"
|
||||
miniflux:rewriteRules="replace("foo"|"bar")"
|
||||
miniflux:urlRewriteRules="rewrite("^https://old"|"https://new")"
|
||||
miniflux:blocklistRules="sponsored"
|
||||
miniflux:keeplistRules="important"
|
||||
miniflux:blockFilterEntryRules="EntryTitle=~"ad""
|
||||
miniflux:keepFilterEntryRules="EntryTitle=~"news""
|
||||
miniflux:userAgent="CustomAgent/1.0"
|
||||
miniflux:proxyUrl="http://proxy.example.org"
|
||||
miniflux:crawler="true"
|
||||
miniflux:ignoreHTTPCache="true"
|
||||
miniflux:fetchViaProxy="true"
|
||||
miniflux:disabled="true"
|
||||
miniflux:noMediaPlayer="true"
|
||||
miniflux:hideGlobally="true"
|
||||
miniflux:allowSelfSignedCertificates="true"
|
||||
miniflux:disableHTTP2="true"
|
||||
miniflux:ignoreEntryUpdates="true"
|
||||
/>
|
||||
</outline>
|
||||
</body>
|
||||
</opml>
|
||||
`
|
||||
|
||||
expected := subcription{
|
||||
Title: "Feed 1",
|
||||
FeedURL: "http://example.org/feed1/",
|
||||
SiteURL: "http://example.org/1",
|
||||
CategoryName: "My Category",
|
||||
ScraperRules: "article.content",
|
||||
RewriteRules: `replace("foo"|"bar")`,
|
||||
UrlRewriteRules: `rewrite("^https://old"|"https://new")`,
|
||||
BlocklistRules: "sponsored",
|
||||
KeeplistRules: "important",
|
||||
BlockFilterEntryRules: `EntryTitle=~"ad"`,
|
||||
KeepFilterEntryRules: `EntryTitle=~"news"`,
|
||||
UserAgent: "CustomAgent/1.0",
|
||||
Crawler: true,
|
||||
IgnoreHTTPCache: true,
|
||||
FetchViaProxy: true,
|
||||
Disabled: true,
|
||||
NoMediaPlayer: true,
|
||||
HideGlobally: true,
|
||||
AllowSelfSignedCertificates: true,
|
||||
DisableHTTP2: true,
|
||||
IgnoreEntryUpdates: true,
|
||||
}
|
||||
|
||||
subscriptions, err := parse(bytes.NewBufferString(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(subscriptions) != 1 {
|
||||
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 1)
|
||||
}
|
||||
|
||||
if subscriptions[0] != expected {
|
||||
t.Errorf("Subscription is different:\ngot: %+v\nwant: %+v", subscriptions[0], expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func serialize(subscriptions []subcription) string {
|
||||
func convertSubscriptionsToOPML(subscriptions []subcription) *opmlDocument {
|
||||
opmlDocument := &opmlDocument{}
|
||||
opmlDocument.Version = "2.0"
|
||||
opmlDocument.MinifluxNamespace = minifluxOPMLNamespace
|
||||
opmlDocument.Header.Title = "Miniflux"
|
||||
opmlDocument.Header.DateCreated = time.Now().Format("Mon, 02 Jan 2006 15:04:05 MST")
|
||||
|
||||
@@ -53,6 +54,24 @@ func convertSubscriptionsToOPML(subscriptions []subcription) *opmlDocument {
|
||||
FeedURL: subscription.FeedURL,
|
||||
SiteURL: subscription.SiteURL,
|
||||
Description: subscription.Description,
|
||||
|
||||
ScraperRules: subscription.ScraperRules,
|
||||
RewriteRules: subscription.RewriteRules,
|
||||
UrlRewriteRules: subscription.UrlRewriteRules,
|
||||
BlocklistRules: subscription.BlocklistRules,
|
||||
KeeplistRules: subscription.KeeplistRules,
|
||||
BlockFilterEntryRules: subscription.BlockFilterEntryRules,
|
||||
KeepFilterEntryRules: subscription.KeepFilterEntryRules,
|
||||
UserAgent: subscription.UserAgent,
|
||||
Crawler: subscription.Crawler,
|
||||
IgnoreHTTPCache: subscription.IgnoreHTTPCache,
|
||||
FetchViaProxy: subscription.FetchViaProxy,
|
||||
Disabled: subscription.Disabled,
|
||||
NoMediaPlayer: subscription.NoMediaPlayer,
|
||||
HideGlobally: subscription.HideGlobally,
|
||||
AllowSelfSignedCertificates: subscription.AllowSelfSignedCertificates,
|
||||
DisableHTTP2: subscription.DisableHTTP2,
|
||||
IgnoreEntryUpdates: subscription.IgnoreEntryUpdates,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package opml // import "miniflux.app/v2/internal/reader/opml"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -38,6 +39,92 @@ func TestSerialize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeWithMinifluxSettings(t *testing.T) {
|
||||
input := subcription{
|
||||
Title: "Feed 1",
|
||||
FeedURL: "http://example.org/feed/1",
|
||||
SiteURL: "http://example.org/1",
|
||||
CategoryName: "Category 1",
|
||||
ScraperRules: `article [class^="content"]`,
|
||||
RewriteRules: `replace("foo"|"bar")`,
|
||||
UrlRewriteRules: `rewrite("^https://old"|"https://new")`,
|
||||
BlocklistRules: "sponsored",
|
||||
KeeplistRules: "important",
|
||||
BlockFilterEntryRules: `EntryTitle=~"ad"`,
|
||||
KeepFilterEntryRules: `EntryTitle=~"news"`,
|
||||
UserAgent: "CustomAgent/1.0",
|
||||
Crawler: true,
|
||||
IgnoreHTTPCache: true,
|
||||
FetchViaProxy: true,
|
||||
Disabled: true,
|
||||
NoMediaPlayer: true,
|
||||
HideGlobally: true,
|
||||
AllowSelfSignedCertificates: true,
|
||||
DisableHTTP2: true,
|
||||
IgnoreEntryUpdates: true,
|
||||
}
|
||||
|
||||
output := serialize([]subcription{input})
|
||||
if !strings.Contains(output, `xmlns:miniflux="https://miniflux.app/opml"`) {
|
||||
t.Fatal("Miniflux OPML namespace is missing")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, `miniflux:crawler="true"`) {
|
||||
t.Fatal("Miniflux settings are not serialized with the Miniflux namespace")
|
||||
}
|
||||
|
||||
if strings.Contains(output, "cookie") {
|
||||
t.Fatal("Sensitive feed settings should not be serialized")
|
||||
}
|
||||
|
||||
feeds, err := parse(bytes.NewBufferString(output))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feeds) != 1 {
|
||||
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(feeds), 1)
|
||||
}
|
||||
|
||||
if feeds[0] != input {
|
||||
t.Errorf("Round-trip failed:\ngot: %+v\nwant: %+v", feeds[0], input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializePreservesNewlinesInRules(t *testing.T) {
|
||||
input := subcription{
|
||||
Title: "Feed 1",
|
||||
FeedURL: "http://example.org/feed/1",
|
||||
SiteURL: "http://example.org/1",
|
||||
CategoryName: "Category 1",
|
||||
RewriteRules: "replace(\"foo\"|\"bar\")\nadd_youtube_video",
|
||||
ScraperRules: "article.content\np.body",
|
||||
BlockFilterEntryRules: "EntryTitle=~\"ad\"\nEntryURL=~\"click\"",
|
||||
}
|
||||
|
||||
output := serialize([]subcription{input})
|
||||
feeds, err := parse(bytes.NewBufferString(output))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feeds) != 1 {
|
||||
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(feeds), 1)
|
||||
}
|
||||
|
||||
if feeds[0].RewriteRules != input.RewriteRules {
|
||||
t.Errorf("RewriteRules newlines not preserved:\ngot: %q\nwant: %q", feeds[0].RewriteRules, input.RewriteRules)
|
||||
}
|
||||
|
||||
if feeds[0].ScraperRules != input.ScraperRules {
|
||||
t.Errorf("ScraperRules newlines not preserved:\ngot: %q\nwant: %q", feeds[0].ScraperRules, input.ScraperRules)
|
||||
}
|
||||
|
||||
if feeds[0].BlockFilterEntryRules != input.BlockFilterEntryRules {
|
||||
t.Errorf("BlockFilterEntryRules newlines not preserved:\ngot: %q\nwant: %q", feeds[0].BlockFilterEntryRules, input.BlockFilterEntryRules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizedCategoriesOrder(t *testing.T) {
|
||||
var orderTests = []struct {
|
||||
naturalOrderName string
|
||||
|
||||
@@ -10,4 +10,23 @@ type subcription struct {
|
||||
FeedURL string
|
||||
CategoryName string
|
||||
Description string
|
||||
|
||||
// Miniflux-specific feed settings
|
||||
ScraperRules string
|
||||
RewriteRules string
|
||||
UrlRewriteRules string
|
||||
BlocklistRules string
|
||||
KeeplistRules string
|
||||
BlockFilterEntryRules string
|
||||
KeepFilterEntryRules string
|
||||
UserAgent string
|
||||
Crawler bool
|
||||
IgnoreHTTPCache bool
|
||||
FetchViaProxy bool
|
||||
Disabled bool
|
||||
NoMediaPlayer bool
|
||||
HideGlobally bool
|
||||
AllowSelfSignedCertificates bool
|
||||
DisableHTTP2 bool
|
||||
IgnoreEntryUpdates bool
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package parser // import "miniflux.app/v2/internal/reader/parser"
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"unicode"
|
||||
|
||||
@@ -71,7 +72,7 @@ func detectJSONFormat(r io.ReadSeeker) (bool, error) {
|
||||
for {
|
||||
n, err := r.Read(buffer)
|
||||
if n == 0 {
|
||||
if err == io.EOF {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return false, nil // No non-whitespace content found
|
||||
}
|
||||
return false, err
|
||||
|
||||
@@ -14,6 +14,20 @@ import (
|
||||
"github.com/tdewolff/minify/v2/html"
|
||||
)
|
||||
|
||||
var htmlMinifier = newHTMLMinifier()
|
||||
|
||||
func newHTMLMinifier() *minify.M {
|
||||
m := minify.New()
|
||||
m.Add("text/html", &html.Minifier{
|
||||
KeepEndTags: true,
|
||||
KeepQuotes: true,
|
||||
KeepComments: false,
|
||||
KeepSpecialComments: false,
|
||||
KeepDefaultAttrVals: false,
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
// parseISO8601Duration parses a subset of ISO8601 durations, mainly for youtube video.
|
||||
func parseISO8601Duration(duration string) (time.Duration, error) {
|
||||
after, ok := strings.CutPrefix(duration, "PT")
|
||||
@@ -22,58 +36,38 @@ func parseISO8601Duration(duration string) (time.Duration, error) {
|
||||
}
|
||||
|
||||
var d time.Duration
|
||||
num := ""
|
||||
start := 0
|
||||
|
||||
for _, char := range after {
|
||||
var val int
|
||||
var err error
|
||||
for i := 0; i < len(after); i++ {
|
||||
var unit time.Duration
|
||||
|
||||
switch char {
|
||||
switch after[i] {
|
||||
case 'Y', 'W', 'D':
|
||||
return 0, fmt.Errorf("the '%c' specifier isn't supported", char)
|
||||
return 0, fmt.Errorf("the '%c' specifier isn't supported", after[i])
|
||||
case 'H':
|
||||
if val, err = strconv.Atoi(num); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
d += time.Duration(val) * time.Hour
|
||||
num = ""
|
||||
unit = time.Hour
|
||||
case 'M':
|
||||
if val, err = strconv.Atoi(num); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
d += time.Duration(val) * time.Minute
|
||||
num = ""
|
||||
unit = time.Minute
|
||||
case 'S':
|
||||
if val, err = strconv.Atoi(num); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
d += time.Duration(val) * time.Second
|
||||
num = ""
|
||||
unit = time.Second
|
||||
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
||||
num += string(char)
|
||||
continue
|
||||
default:
|
||||
return 0, errors.New("invalid character in the period")
|
||||
}
|
||||
|
||||
val, err := strconv.Atoi(after[start:i])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
d += time.Duration(val) * unit
|
||||
start = i + 1
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func minifyContent(content string) string {
|
||||
m := minify.New()
|
||||
|
||||
// Options required to avoid breaking the HTML content.
|
||||
m.Add("text/html", &html.Minifier{
|
||||
KeepEndTags: true,
|
||||
KeepQuotes: true,
|
||||
KeepComments: false,
|
||||
KeepSpecialComments: false,
|
||||
KeepDefaultAttrVals: false,
|
||||
})
|
||||
|
||||
if minifiedHTML, err := m.String("text/html", content); err == nil {
|
||||
content = minifiedHTML
|
||||
}
|
||||
|
||||
return content
|
||||
// when an error occurs, String returns the original content.
|
||||
ret, _ := htmlMinifier.String("text/html", content)
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -21,7 +21,15 @@ func EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed in
|
||||
if isCJK(sanitizedContent[:truncationPoint]) {
|
||||
return int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed)))
|
||||
}
|
||||
return int(math.Ceil(float64(len(strings.Fields(sanitizedContent))) / float64(defaultReadingSpeed)))
|
||||
return int(math.Ceil(float64(countWords(sanitizedContent)) / float64(defaultReadingSpeed)))
|
||||
}
|
||||
|
||||
func countWords(s string) int {
|
||||
n := 0
|
||||
for range strings.FieldsSeq(s) {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func isCJK(text string) bool {
|
||||
|
||||
@@ -86,3 +86,14 @@ func BenchmarkEstimateReadingTime(b *testing.B) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountWordsZeroAllocs(t *testing.T) {
|
||||
allocs := testing.AllocsPerRun(10, func() {
|
||||
for _, sample := range samples {
|
||||
countWords(sample)
|
||||
}
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Errorf("countWords allocated %v times, expected 0", allocs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ func (rule rule) applyRule(entryURL string, entry *model.Entry) {
|
||||
slog.String("entry_url", entryURL),
|
||||
)
|
||||
}
|
||||
case "add_enclosure_links":
|
||||
entry.Content = addEnclosureLinks(entry)
|
||||
case "add_castopod_episode":
|
||||
entry.Content = addCastopodEpisode(entryURL, entry.Content)
|
||||
case "base64_decode":
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
|
||||
nethtml "golang.org/x/net/html"
|
||||
|
||||
@@ -355,6 +356,24 @@ func addPDFLink(entryURL, entryContent string) string {
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func addEnclosureLinks(entry *model.Entry) string {
|
||||
var links strings.Builder
|
||||
|
||||
for _, enclosure := range entry.Enclosures {
|
||||
if enclosure.URL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
enclosureURL := html.EscapeString(enclosure.URL)
|
||||
links.WriteString(`<li><a href="` + enclosureURL + `">` + enclosureURL + `</a></li>`)
|
||||
}
|
||||
|
||||
if links.Len() > 0 {
|
||||
return entry.Content + "<hr/><ul>" + strings.TrimSpace(links.String()) + "</ul>"
|
||||
}
|
||||
return entry.Content
|
||||
}
|
||||
|
||||
func replaceTextLinks(input string) string {
|
||||
return textLinkRegex.ReplaceAllString(input, `<a href="${1}">${1}</a>`)
|
||||
}
|
||||
|
||||
@@ -832,6 +832,60 @@ func TestRewriteAddCastopodEpisode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteAddEnclosureLinks(t *testing.T) {
|
||||
controlEntry := &model.Entry{
|
||||
URL: "https://example.org/article",
|
||||
Title: `A title`,
|
||||
Content: `Article Content<hr/><ul>` +
|
||||
`<li><a href="https://example.org/episode.mp3?token=a&b=c">https://example.org/episode.mp3?token=a&b=c</a></li>` +
|
||||
`<li><a href="https://example.org/video.mp4">https://example.org/video.mp4</a></li></ul>`,
|
||||
Enclosures: model.EnclosureList{
|
||||
{URL: "https://example.org/episode.mp3?token=a&b=c", MimeType: "audio/mpeg"},
|
||||
{URL: "https://example.org/video.mp4", MimeType: "video/mp4"},
|
||||
{URL: "", MimeType: "application/pdf"},
|
||||
},
|
||||
}
|
||||
testEntry := &model.Entry{
|
||||
URL: "https://example.org/article",
|
||||
Title: `A title`,
|
||||
Content: `Article Content`,
|
||||
Enclosures: model.EnclosureList{
|
||||
{URL: "https://example.org/episode.mp3?token=a&b=c", MimeType: "audio/mpeg"},
|
||||
{URL: "https://example.org/video.mp4", MimeType: "video/mp4"},
|
||||
{URL: "", MimeType: "application/pdf"},
|
||||
},
|
||||
}
|
||||
ApplyContentRewriteRules(testEntry, `add_enclosure_links`)
|
||||
|
||||
if !reflect.DeepEqual(testEntry, controlEntry) {
|
||||
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteAddEnclosureLinksWithoutEnclosureURL(t *testing.T) {
|
||||
controlEntry := &model.Entry{
|
||||
URL: "https://example.org/article",
|
||||
Title: `A title`,
|
||||
Content: `Article Content`,
|
||||
Enclosures: model.EnclosureList{
|
||||
{URL: "", MimeType: "audio/mpeg"},
|
||||
},
|
||||
}
|
||||
testEntry := &model.Entry{
|
||||
URL: "https://example.org/article",
|
||||
Title: `A title`,
|
||||
Content: `Article Content`,
|
||||
Enclosures: model.EnclosureList{
|
||||
{URL: "", MimeType: "audio/mpeg"},
|
||||
},
|
||||
}
|
||||
ApplyContentRewriteRules(testEntry, `add_enclosure_links`)
|
||||
|
||||
if !reflect.DeepEqual(testEntry, controlEntry) {
|
||||
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteBase64Decode(t *testing.T) {
|
||||
controlEntry := &model.Entry{
|
||||
URL: "https://example.org/article",
|
||||
|
||||
@@ -66,6 +66,10 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
|
||||
}
|
||||
}
|
||||
|
||||
// Track GUIDs already seen in this feed to disambiguate items from
|
||||
// non-conformant feeds that reuse the same <guid> for every entry.
|
||||
seenGUIDs := make(map[string]int)
|
||||
|
||||
for _, item := range r.rss.Channel.Items {
|
||||
entry := model.NewEntry()
|
||||
entry.Date = findEntryDate(&item)
|
||||
@@ -105,9 +109,24 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
|
||||
}
|
||||
|
||||
// Generate the entry hash.
|
||||
//
|
||||
// The RSS 2.0 spec requires <guid> to uniquely identify the item, but
|
||||
// some feeds ship the same GUID for every entry. Keep the first
|
||||
// occurrence stable (so existing stored entries still match) and
|
||||
// disambiguate later collisions using the entry URL or, as a last
|
||||
// resort, the item position.
|
||||
switch {
|
||||
case item.GUID.Data != "":
|
||||
entry.Hash = crypto.SHA256(item.GUID.Data)
|
||||
n := seenGUIDs[item.GUID.Data]
|
||||
seenGUIDs[item.GUID.Data] = n + 1
|
||||
switch {
|
||||
case n == 0:
|
||||
entry.Hash = crypto.SHA256(item.GUID.Data)
|
||||
case entry.URL != "":
|
||||
entry.Hash = crypto.SHA256(item.GUID.Data + "|" + entry.URL)
|
||||
default:
|
||||
entry.Hash = crypto.SHA256(item.GUID.Data + "|" + strconv.Itoa(n))
|
||||
}
|
||||
case entryURL != "":
|
||||
entry.Hash = crypto.SHA256(entryURL)
|
||||
default:
|
||||
@@ -162,7 +181,8 @@ func findFeedAuthor(rssChannel *rssChannel) string {
|
||||
}
|
||||
|
||||
func findFeedTags(rssChannel *rssChannel) []string {
|
||||
tags := make([]string, 0)
|
||||
itunesCategories := rssChannel.GetItunesCategories()
|
||||
tags := make([]string, 0, len(rssChannel.Categories)+len(itunesCategories)+1)
|
||||
|
||||
for _, tag := range rssChannel.Categories {
|
||||
tag = strings.TrimSpace(tag)
|
||||
@@ -171,7 +191,7 @@ func findFeedTags(rssChannel *rssChannel) []string {
|
||||
}
|
||||
}
|
||||
|
||||
for _, tag := range rssChannel.GetItunesCategories() {
|
||||
for _, tag := range itunesCategories {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
@@ -280,7 +300,8 @@ func findEntryAuthor(rssItem *rssItem) string {
|
||||
}
|
||||
|
||||
func findEntryTags(rssItem *rssItem) []string {
|
||||
tags := make([]string, 0)
|
||||
mediaLabels := rssItem.MediaCategories.Labels()
|
||||
tags := make([]string, 0, len(rssItem.Categories)+len(mediaLabels))
|
||||
|
||||
for _, tag := range rssItem.Categories {
|
||||
tag = strings.TrimSpace(tag)
|
||||
@@ -289,7 +310,7 @@ func findEntryTags(rssItem *rssItem) []string {
|
||||
}
|
||||
}
|
||||
|
||||
for _, tag := range rssItem.MediaCategories.Labels() {
|
||||
for _, tag := range mediaLabels {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
@@ -300,10 +321,14 @@ func findEntryTags(rssItem *rssItem) []string {
|
||||
}
|
||||
|
||||
func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
|
||||
enclosures := make(model.EnclosureList, 0)
|
||||
duplicates := make(map[string]bool)
|
||||
mediaThumbnails := rssItem.AllMediaThumbnails()
|
||||
mediaContents := rssItem.AllMediaContents()
|
||||
mediaPeerLinks := rssItem.AllMediaPeerLinks()
|
||||
capacity := len(mediaThumbnails) + len(rssItem.Enclosures) + len(mediaContents) + len(mediaPeerLinks)
|
||||
enclosures := make(model.EnclosureList, 0, capacity)
|
||||
duplicates := make(map[string]bool, capacity)
|
||||
|
||||
for _, mediaThumbnail := range rssItem.AllMediaThumbnails() {
|
||||
for _, mediaThumbnail := range mediaThumbnails {
|
||||
mediaURL := strings.TrimSpace(mediaThumbnail.URL)
|
||||
if mediaURL == "" {
|
||||
continue
|
||||
@@ -356,7 +381,7 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
|
||||
}
|
||||
}
|
||||
|
||||
for _, mediaContent := range rssItem.AllMediaContents() {
|
||||
for _, mediaContent := range mediaContents {
|
||||
mediaURL := strings.TrimSpace(mediaContent.URL)
|
||||
if mediaURL == "" {
|
||||
continue
|
||||
@@ -380,7 +405,7 @@ func findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {
|
||||
}
|
||||
}
|
||||
|
||||
for _, mediaPeerLink := range rssItem.AllMediaPeerLinks() {
|
||||
for _, mediaPeerLink := range mediaPeerLinks {
|
||||
mediaURL := strings.TrimSpace(mediaPeerLink.URL)
|
||||
if mediaURL == "" {
|
||||
continue
|
||||
|
||||
@@ -2179,3 +2179,139 @@ func TestParseFeedWithIncorrectTTLValue(t *testing.T) {
|
||||
t.Errorf("Incorrect TTL, got: %d", feed.TTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntriesWithDuplicateGUIDAndDistinctLinks(t *testing.T) {
|
||||
// Some non-conformant feeds (e.g. fluentboards.com) ship the same <guid>
|
||||
// for every item. The first occurrence must keep the historical
|
||||
// SHA256(guid) hash so previously stored entries still match, while later
|
||||
// duplicates are disambiguated using the entry URL.
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<item>
|
||||
<title>Item A</title>
|
||||
<link>https://example.org/a</link>
|
||||
<guid isPermaLink="false">dup-guid</guid>
|
||||
</item>
|
||||
<item>
|
||||
<title>Item B</title>
|
||||
<link>https://example.org/b</link>
|
||||
<guid isPermaLink="false">dup-guid</guid>
|
||||
</item>
|
||||
<item>
|
||||
<title>Item C</title>
|
||||
<link>https://example.org/a</link>
|
||||
<guid isPermaLink="false">dup-guid</guid>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 3 {
|
||||
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||
}
|
||||
|
||||
expected := []string{
|
||||
"22561495b53d916c228504600fda7c06cefc55a80395bbdf007102a6e9070d3f", // SHA256("dup-guid")
|
||||
"3479bd288c08f8d2f8e992aa511a267ee50b18e22a4c475a4862c15c4d4f675b", // SHA256("dup-guid|https://example.org/b")
|
||||
"8e4bb51d800e7a0db6b81b48d09af4711f8a25e00d09251e3d9f675e02bfe11e", // SHA256("dup-guid|https://example.org/a")
|
||||
}
|
||||
for i, want := range expected {
|
||||
if feed.Entries[i].Hash != want {
|
||||
t.Errorf("Entry %d: incorrect hash, got: %s, want: %s", i, feed.Entries[i].Hash, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity-check uniqueness across all three entries.
|
||||
seen := make(map[string]bool)
|
||||
for _, e := range feed.Entries {
|
||||
if seen[e.Hash] {
|
||||
t.Errorf("Duplicate hash across entries: %s", e.Hash)
|
||||
}
|
||||
seen[e.Hash] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntriesWithDuplicateGUIDAndNoLink(t *testing.T) {
|
||||
// When colliding entries also lack a usable URL, we fall back to the
|
||||
// position-based disambiguator. The site URL is used as the entry URL
|
||||
// fallback (see findEntryURL handling), so the second entry hashes
|
||||
// against that URL rather than the position.
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<item>
|
||||
<title>Item A</title>
|
||||
<guid isPermaLink="false">dup-guid</guid>
|
||||
</item>
|
||||
<item>
|
||||
<title>Item B</title>
|
||||
<guid isPermaLink="false">dup-guid</guid>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 2 {
|
||||
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||
}
|
||||
|
||||
if feed.Entries[0].Hash != "22561495b53d916c228504600fda7c06cefc55a80395bbdf007102a6e9070d3f" {
|
||||
t.Errorf("Entry 0: incorrect hash, got: %s", feed.Entries[0].Hash)
|
||||
}
|
||||
// Both items fall back to the channel link as their URL, so the second
|
||||
// hash uses "dup-guid|https://example.org/".
|
||||
if feed.Entries[1].Hash != "175d6d57a61533fb553c5d9bc3d52aa9092553a7e0ae473e3536c32c87cfa418" {
|
||||
t.Errorf("Entry 1: incorrect hash, got: %s", feed.Entries[1].Hash)
|
||||
}
|
||||
if feed.Entries[0].Hash == feed.Entries[1].Hash {
|
||||
t.Errorf("Hashes should differ for duplicate-GUID items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntriesWithUniqueGUIDsAreUnchanged(t *testing.T) {
|
||||
// Regression guard: feeds with unique GUIDs must keep the historical
|
||||
// SHA256(guid) hashing so existing stored entries are not duplicated.
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<item>
|
||||
<title>Item A</title>
|
||||
<link>https://example.org/a</link>
|
||||
<guid isPermaLink="false">guid-a</guid>
|
||||
</item>
|
||||
<item>
|
||||
<title>Item B</title>
|
||||
<link>https://example.org/b</link>
|
||||
<guid isPermaLink="false">guid-b</guid>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 2 {
|
||||
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||
}
|
||||
|
||||
if feed.Entries[0].Hash != "2b13a8de1741fb778d7e733e7a888088cb578d22e5ec6d40e0a88f82d4829cbd" {
|
||||
t.Errorf("Entry 0: incorrect hash, got: %s", feed.Entries[0].Hash)
|
||||
}
|
||||
if feed.Entries[1].Hash != "33ce1b8657a81c6a49590313a36325e44683f27229e96faa690f9dc7b2a75d2a" {
|
||||
t.Errorf("Entry 1: incorrect hash, got: %s", feed.Entries[1].Hash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,53 +148,6 @@ var (
|
||||
"x.com/share",
|
||||
}
|
||||
|
||||
// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
|
||||
validURISchemes = []string{
|
||||
// Most commong schemes on top.
|
||||
"https:",
|
||||
"http:",
|
||||
|
||||
// Then the rest.
|
||||
"apt:",
|
||||
"bitcoin:",
|
||||
"callto:",
|
||||
"dav:",
|
||||
"davs:",
|
||||
"ed2k:",
|
||||
"facetime:",
|
||||
"feed:",
|
||||
"ftp:",
|
||||
"geo:",
|
||||
"git:",
|
||||
"gopher:",
|
||||
"irc:",
|
||||
"irc6:",
|
||||
"ircs:",
|
||||
"itms-apps:",
|
||||
"itms:",
|
||||
"magnet:",
|
||||
"mailto:",
|
||||
"news:",
|
||||
"nntp:",
|
||||
"rtmp:",
|
||||
"sftp:",
|
||||
"sip:",
|
||||
"sips:",
|
||||
"skype:",
|
||||
"spotify:",
|
||||
"ssh:",
|
||||
"steam:",
|
||||
"svn:",
|
||||
"svn+ssh:",
|
||||
"tel:",
|
||||
"webcal:",
|
||||
"xmpp:",
|
||||
|
||||
// iOS Apps
|
||||
"opener:", // https://www.opener.link
|
||||
"hack:", // https://apps.apple.com/it/app/hack-for-hacker-news-reader/id1464477788?l=en-GB
|
||||
}
|
||||
|
||||
dataAttributeAllowedPrefixes = []string{
|
||||
"data:image/avif",
|
||||
"data:image/apng",
|
||||
@@ -350,15 +303,6 @@ func hasRequiredAttributes(s *mandatoryAttributesStruct, tagName string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func hasValidURIScheme(absoluteURL string) bool {
|
||||
for _, scheme := range validURISchemes {
|
||||
if strings.HasPrefix(absoluteURL, scheme) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBlockedResource(absoluteURL string) bool {
|
||||
for _, blockedURL := range blockedResourceURLSubstrings {
|
||||
if strings.Contains(absoluteURL, blockedURL) {
|
||||
@@ -504,7 +448,20 @@ func trackAttributes(s *mandatoryAttributesStruct, attributeName string) {
|
||||
}
|
||||
|
||||
func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []html.Attribute, sanitizerOptions *SanitizerOptions) (string, bool) {
|
||||
htmlAttrs := make([]string, 0, len(attributes))
|
||||
var htmlAttrs strings.Builder
|
||||
// Rough estimate: most attributes are short; ~24 bytes (key + ="value") is
|
||||
// a reasonable starting point. Avoids early grows for typical elements.
|
||||
htmlAttrs.Grow(len(attributes) * 24)
|
||||
|
||||
// writeAttr appends key="value" to htmlAttrs, prefixing with a single
|
||||
// space when not the first written attribute. value is HTML-escaped.
|
||||
writeAttr := func(key, value string) {
|
||||
htmlAttrs.WriteByte(' ')
|
||||
htmlAttrs.WriteString(key)
|
||||
htmlAttrs.WriteString(`="`)
|
||||
htmlAttrs.WriteString(html.EscapeString(value))
|
||||
htmlAttrs.WriteByte('"')
|
||||
}
|
||||
|
||||
// Keep track of mandatory attributes for some tags
|
||||
mandatoryAttributes := mandatoryAttributesStruct{false, false, false}
|
||||
@@ -587,20 +544,24 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
|
||||
continue
|
||||
}
|
||||
|
||||
if !hasValidURIScheme(value) {
|
||||
if !HasValidURIScheme(value) {
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO use feedURL instead of baseURL twice.
|
||||
parsedValueUrl, _ := url.Parse(value)
|
||||
if cleanedURL, err := urlcleaner.RemoveTrackingParameters(parsedBaseUrl, parsedBaseUrl, parsedValueUrl); err == nil {
|
||||
value = cleanedURL
|
||||
// Skip the parse + RemoveTrackingParameters round trip when there
|
||||
// is no query string to clean, which is common for <img>.
|
||||
if strings.IndexByte(value, '?') >= 0 {
|
||||
parsedValueUrl, _ := url.Parse(value)
|
||||
// TODO use feedURL instead of baseURL twice.
|
||||
if cleanedURL, err := urlcleaner.RemoveTrackingParameters(parsedBaseUrl, parsedBaseUrl, parsedValueUrl); err == nil {
|
||||
value = cleanedURL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trackAttributes(&mandatoryAttributes, attribute.Key)
|
||||
htmlAttrs = append(htmlAttrs, attribute.Key+`="`+html.EscapeString(value)+`"`)
|
||||
writeAttr(attribute.Key, value)
|
||||
}
|
||||
|
||||
if !hasRequiredAttributes(&mandatoryAttributes, tagName) {
|
||||
@@ -610,27 +571,29 @@ func sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []htm
|
||||
if !isAnchorLink {
|
||||
switch tagName {
|
||||
case "a":
|
||||
htmlAttrs = append(htmlAttrs, `rel="noopener noreferrer"`, `referrerpolicy="no-referrer"`)
|
||||
writeAttr("rel", "noopener noreferrer")
|
||||
writeAttr("referrerpolicy", "no-referrer")
|
||||
if sanitizerOptions.OpenLinksInNewTab {
|
||||
htmlAttrs = append(htmlAttrs, `target="_blank"`)
|
||||
writeAttr("target", "_blank")
|
||||
}
|
||||
case "video", "audio":
|
||||
htmlAttrs = append(htmlAttrs, "controls")
|
||||
htmlAttrs.WriteString(" controls")
|
||||
case "iframe":
|
||||
htmlAttrs = append(htmlAttrs, `sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"`, `loading="lazy"`)
|
||||
writeAttr("sandbox", "allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox")
|
||||
writeAttr("loading", "lazy")
|
||||
|
||||
// Note: the referrerpolicy seems to be required to avoid YouTube error 153 video player configuration error
|
||||
// See https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-player-api-client-identity
|
||||
if isYouTubeEmbed {
|
||||
htmlAttrs = append(htmlAttrs, `referrerpolicy="strict-origin-when-cross-origin"`)
|
||||
writeAttr("referrerpolicy", "strict-origin-when-cross-origin")
|
||||
}
|
||||
|
||||
case "img":
|
||||
htmlAttrs = append(htmlAttrs, `loading="lazy"`)
|
||||
writeAttr("loading", "lazy")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(htmlAttrs, " "), true
|
||||
return strings.TrimLeft(htmlAttrs.String(), " "), true
|
||||
}
|
||||
|
||||
func sanitizeSrcsetAttr(parsedBaseURL *url.URL, value string) string {
|
||||
@@ -647,7 +610,7 @@ func sanitizeSrcsetAttr(parsedBaseURL *url.URL, value string) string {
|
||||
continue
|
||||
}
|
||||
|
||||
if !hasValidURIScheme(absoluteURL) || isBlockedResource(absoluteURL) {
|
||||
if !HasValidURIScheme(absoluteURL) || isBlockedResource(absoluteURL) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
@@ -19,7 +20,7 @@ func StripTags(input string) string {
|
||||
for {
|
||||
if tokenizer.Next() == html.ErrorToken {
|
||||
err := tokenizer.Err()
|
||||
if err == io.EOF {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return buffer.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
|
||||
|
||||
import "strings"
|
||||
|
||||
// validURISchemes is the allowlist for URLs in sanitized feed body content.
|
||||
// It is intentionally broad; stricter surfaces (redirects, template hrefs)
|
||||
// should use urllib.IsAbsoluteURL / urllib.IsRelativePath instead.
|
||||
//
|
||||
// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
|
||||
var validURISchemes = []string{
|
||||
// Most commong schemes on top.
|
||||
"https",
|
||||
"http",
|
||||
|
||||
// Then the rest.
|
||||
"apt",
|
||||
"bitcoin",
|
||||
"callto",
|
||||
"dav",
|
||||
"davs",
|
||||
"ed2k",
|
||||
"facetime",
|
||||
"feed",
|
||||
"ftp",
|
||||
"geo",
|
||||
"git",
|
||||
"gopher",
|
||||
"irc",
|
||||
"irc6",
|
||||
"ircs",
|
||||
"itms-apps",
|
||||
"itms",
|
||||
"magnet",
|
||||
"mailto",
|
||||
"news",
|
||||
"nntp",
|
||||
"rtmp",
|
||||
"sftp",
|
||||
"sip",
|
||||
"sips",
|
||||
"shortcuts",
|
||||
"skype",
|
||||
"spotify",
|
||||
"ssh",
|
||||
"steam",
|
||||
"svn",
|
||||
"svn+ssh",
|
||||
"tel",
|
||||
"webcal",
|
||||
"xmpp",
|
||||
|
||||
// iOS Apps
|
||||
"opener", // https://www.opener.link
|
||||
"hack", // https://apps.apple.com/it/app/hack-for-hacker-news-reader/id1464477788?l=en-GB
|
||||
}
|
||||
|
||||
// HasValidURIScheme reports whether the URL begins with an allowed scheme.
|
||||
// The scheme comparison is case-insensitive per RFC 3986 §3.1.
|
||||
func HasValidURIScheme(absoluteURL string) bool {
|
||||
scheme, _, ok := strings.Cut(absoluteURL, ":")
|
||||
if !ok || scheme == "" {
|
||||
return false
|
||||
}
|
||||
for _, validScheme := range validURISchemes {
|
||||
if strings.EqualFold(scheme, validScheme) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasValidURIScheme(t *testing.T) {
|
||||
scenarios := map[string]bool{
|
||||
// Allowed: web schemes.
|
||||
"http://example.org/article": true,
|
||||
"https://example.org/article": true,
|
||||
|
||||
// Allowed: a sample of the broader feed-content schemes.
|
||||
"mailto:author@example.org": true,
|
||||
"magnet:?xt=urn:btih:abc": true,
|
||||
"tel:+15551234567": true,
|
||||
"ftp://example.org/file": true,
|
||||
"feed:https://example.org/": true,
|
||||
"webcal://example.org/cal": true,
|
||||
|
||||
// Rejected: schemes that enable script execution or local resource access.
|
||||
"javascript:alert(1)": false,
|
||||
"data:text/html,<script>alert(1)</script>": false,
|
||||
"vbscript:msgbox(1)": false,
|
||||
"file:///etc/passwd": false,
|
||||
|
||||
// Rejected: missing or malformed scheme.
|
||||
"": false,
|
||||
"example.org": false,
|
||||
"/relative/path": false,
|
||||
"//evil.example.org/path": false,
|
||||
|
||||
// Allowed: scheme matching is case-insensitive (RFC 3986 §3.1).
|
||||
"HTTPS://example.org": true,
|
||||
"MailTo:author@host": true,
|
||||
"SVN+SSH://example.org": true,
|
||||
|
||||
// Rejected: case-insensitive match still rejects disallowed schemes.
|
||||
"JavaScript:alert(1)": false,
|
||||
"VBScript:msgbox(1)": false,
|
||||
}
|
||||
|
||||
for input, expected := range scenarios {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
if actual := HasValidURIScheme(input); actual != expected {
|
||||
t.Errorf("HasValidURIScheme(%q) = %v, want %v", input, actual, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -83,13 +83,14 @@ func findContentUsingCustomRules(page io.Reader, rules string) (baseURL string,
|
||||
}
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
document.Find(rules).Each(func(i int, s *goquery.Selection) {
|
||||
if content, err := goquery.OuterHtml(s); err == nil {
|
||||
extractedContent += content
|
||||
buf.WriteString(content)
|
||||
}
|
||||
})
|
||||
|
||||
return baseURL, extractedContent, nil
|
||||
return baseURL, buf.String(), nil
|
||||
}
|
||||
|
||||
func getPredefinedScraperRules(websiteURL string) string {
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *Storage) Category(userID, categoryID int64) (*model.Category, error) {
|
||||
err := s.db.QueryRow(query, userID, categoryID).Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally)
|
||||
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf(`store: unable to fetch category: %v`, err)
|
||||
@@ -61,7 +61,7 @@ func (s *Storage) FirstCategory(userID int64) (*model.Category, error) {
|
||||
err := s.db.QueryRow(query, userID).Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally)
|
||||
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf(`store: unable to fetch category: %v`, err)
|
||||
@@ -78,7 +78,7 @@ func (s *Storage) CategoryByTitle(userID int64, title string) (*model.Category,
|
||||
err := s.db.QueryRow(query, userID, title).Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally)
|
||||
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf(`store: unable to fetch category: %v`, err)
|
||||
@@ -109,13 +109,8 @@ func (s *Storage) Categories(userID int64) (model.Categories, error) {
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// CategoriesWithFeedCount returns all categories with the number of feeds.
|
||||
func (s *Storage) CategoriesWithFeedCount(userID int64) (model.Categories, error) {
|
||||
user, err := s.UserByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// CategoriesWithFeedCount returns all categories with the number of feeds, sorted according to sortOrder.
|
||||
func (s *Storage) CategoriesWithFeedCount(userID int64, sortOrder string) (model.Categories, error) {
|
||||
query := `
|
||||
SELECT
|
||||
c.id,
|
||||
@@ -132,7 +127,7 @@ func (s *Storage) CategoriesWithFeedCount(userID int64) (model.Categories, error
|
||||
user_id=$2
|
||||
`
|
||||
|
||||
if user.CategoriesSortingOrder == "alphabetical" {
|
||||
if sortOrder == "alphabetical" {
|
||||
query += `
|
||||
ORDER BY
|
||||
c.title ASC
|
||||
|
||||
@@ -6,6 +6,7 @@ package storage // import "miniflux.app/v2/internal/storage"
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
@@ -32,7 +33,7 @@ func (c *certificateCache) Get(ctx context.Context, key string) ([]byte, error)
|
||||
query := `SELECT data::bytea FROM acme_cache WHERE key = $1`
|
||||
var data []byte
|
||||
err := c.storage.db.QueryRowContext(ctx, query, key).Scan(&data)
|
||||
if err == sql.ErrNoRows {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, autocert.ErrCacheMiss
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package storage // import "miniflux.app/v2/internal/storage"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -119,7 +120,6 @@ func (s *Storage) GetEnclosure(enclosureID int64) (*model.Enclosure, error) {
|
||||
enclosures
|
||||
WHERE
|
||||
id = $1
|
||||
ORDER BY id ASC
|
||||
`
|
||||
|
||||
row := s.db.QueryRow(query, enclosureID)
|
||||
@@ -135,7 +135,7 @@ func (s *Storage) GetEnclosure(enclosureID int64) (*model.Enclosure, error) {
|
||||
&enclosure.MediaProgression,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf(`store: unable to fetch enclosure row: %v`, err)
|
||||
@@ -245,24 +245,3 @@ func (s *Storage) UpdateEnclosure(enclosure *model.Enclosure) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEnclosuresOfRemovedEntries deletes enclosures associated with entries marked as "removed".
|
||||
func (s *Storage) DeleteEnclosuresOfRemovedEntries() (int64, error) {
|
||||
query := `
|
||||
DELETE FROM
|
||||
enclosures
|
||||
WHERE
|
||||
enclosures.entry_id IN (SELECT id FROM entries WHERE status=$1)
|
||||
`
|
||||
result, err := s.db.Exec(query, model.EntryStatusRemoved)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(`store: unable to delete enclosures from removed entries: %v`, err)
|
||||
}
|
||||
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(`store: unable to get the number of rows affected while deleting enclosures from removed entries: %v`, err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
+100
-171
@@ -16,6 +16,10 @@ import (
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// ErrEntryTombstoned is returned when an entry cannot be created because its
|
||||
// (feed_id, hash) pair has a tombstone recording a prior deletion.
|
||||
var ErrEntryTombstoned = errors.New("store: entry is tombstoned")
|
||||
|
||||
// CountAllEntries returns the number of entries for each status in the database.
|
||||
func (s *Storage) CountAllEntries() (map[string]int64, error) {
|
||||
rows, err := s.db.Query(`SELECT status, count(*) FROM entries GROUP BY status`)
|
||||
@@ -27,7 +31,6 @@ func (s *Storage) CountAllEntries() (map[string]int64, error) {
|
||||
results := make(map[string]int64)
|
||||
results[model.EntryStatusUnread] = 0
|
||||
results[model.EntryStatusRead] = 0
|
||||
results[model.EntryStatusRemoved] = 0
|
||||
|
||||
for rows.Next() {
|
||||
var status string
|
||||
@@ -40,28 +43,10 @@ func (s *Storage) CountAllEntries() (map[string]int64, error) {
|
||||
results[status] = count
|
||||
}
|
||||
|
||||
results["total"] = results[model.EntryStatusUnread] + results[model.EntryStatusRead] + results[model.EntryStatusRemoved]
|
||||
results["total"] = results[model.EntryStatusUnread] + results[model.EntryStatusRead]
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// CountUnreadEntries returns the number of unread entries.
|
||||
func (s *Storage) CountUnreadEntries(userID int64) int {
|
||||
builder := s.NewEntryQueryBuilder(userID)
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder.WithGloballyVisible()
|
||||
|
||||
n, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
slog.Error("Unable to count unread entries",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// NewEntryQueryBuilder returns a new EntryQueryBuilder
|
||||
func (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder {
|
||||
return NewEntryQueryBuilder(s, userID)
|
||||
@@ -100,6 +85,9 @@ func (s *Storage) UpdateEntryTitleAndContent(entry *model.Entry) error {
|
||||
// createEntry add a new entry.
|
||||
func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
|
||||
truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
|
||||
// The WHERE NOT EXISTS guard makes the tombstone check atomic with the insert, so a
|
||||
// concurrent archive committing between an earlier existence check and this statement
|
||||
// cannot bring a deleted entry back as unread.
|
||||
query := `
|
||||
INSERT INTO entries
|
||||
(
|
||||
@@ -117,22 +105,23 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
|
||||
document_vectors,
|
||||
tags
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
now(),
|
||||
setweight(to_tsvector($11), 'A') || setweight(to_tsvector($12), 'B'),
|
||||
$13
|
||||
)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
now(),
|
||||
setweight(to_tsvector($11), 'A') || setweight(to_tsvector($12), 'B'),
|
||||
$13
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM entry_tombstones WHERE feed_id=$9 AND hash=$2
|
||||
)
|
||||
RETURNING
|
||||
id, status, created_at, changed_at
|
||||
`
|
||||
@@ -157,6 +146,9 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
|
||||
&entry.CreatedAt,
|
||||
&entry.ChangedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrEntryTombstoned
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to create entry %q (feed #%d): %v`, entry.URL, entry.FeedID, err)
|
||||
}
|
||||
@@ -245,7 +237,7 @@ func (s *Storage) getEntryIDByHash(tx *sql.Tx, feedID int64, entryHash string) (
|
||||
entryHash,
|
||||
).Scan(&entryID)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -289,9 +281,20 @@ func (s *Storage) InsertEntryForFeed(userID, feedID int64, entry *model.Entry) (
|
||||
}
|
||||
|
||||
func (s *Storage) IsNewEntry(feedID int64, entryHash string) bool {
|
||||
var result bool
|
||||
s.db.QueryRow(`SELECT true FROM entries WHERE feed_id=$1 AND hash=$2 LIMIT 1`, feedID, entryHash).Scan(&result)
|
||||
return !result
|
||||
// An entry is new only if it is neither stored nor tombstoned; otherwise
|
||||
// callers (such as the crawler) would do expensive work on every refresh
|
||||
// for items that will be discarded.
|
||||
query := `
|
||||
SELECT
|
||||
EXISTS (
|
||||
SELECT 1 FROM entries WHERE feed_id=$1 AND hash=$2
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM entry_tombstones WHERE feed_id=$1 AND hash=$2
|
||||
)
|
||||
`
|
||||
var known bool
|
||||
s.db.QueryRow(query, feedID, entryHash).Scan(&known)
|
||||
return !known
|
||||
}
|
||||
|
||||
func (s *Storage) GetReadTime(feedID int64, entryHash string) int {
|
||||
@@ -313,73 +316,8 @@ func (s *Storage) GetReadTime(feedID int64, entryHash string) int {
|
||||
return result
|
||||
}
|
||||
|
||||
// cleanupRemovedEntriesNotInFeed deletes from the database entries marked as "removed" and not visible anymore in the feed.
|
||||
func (s *Storage) cleanupRemovedEntriesNotInFeed(feedID int64, entryHashes []string) error {
|
||||
// Acquire locks in id order and skip already-locked rows to avoid deadlocks with
|
||||
// ClearRemovedEntriesContent, which also updates removed entries concurrently.
|
||||
query := `
|
||||
WITH to_delete AS (
|
||||
SELECT id
|
||||
FROM entries
|
||||
WHERE
|
||||
feed_id=$1 AND
|
||||
status=$2 AND
|
||||
NOT (hash=ANY($3))
|
||||
ORDER BY id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
DELETE FROM entries
|
||||
USING to_delete
|
||||
WHERE entries.id = to_delete.id
|
||||
`
|
||||
if _, err := s.db.Exec(query, feedID, model.EntryStatusRemoved, pq.Array(entryHashes)); err != nil {
|
||||
return fmt.Errorf(`store: unable to remove entries not in feed: %v`, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearRemovedEntriesContent clears the content fields of entries marked as "removed", keeping only their metadata.
|
||||
func (s *Storage) ClearRemovedEntriesContent(limit int) (int64, error) {
|
||||
// Skip locked rows so this batch scrubber doesn't block or deadlock with the
|
||||
// concurrent cleanup that deletes removed entries in the same table.
|
||||
query := `
|
||||
UPDATE
|
||||
entries
|
||||
SET
|
||||
title='',
|
||||
content=NULL,
|
||||
url='',
|
||||
author=NULL,
|
||||
comments_url=NULL,
|
||||
document_vectors=NULL
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM entries
|
||||
WHERE status = $1 AND content IS NOT NULL
|
||||
ORDER BY id ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT $2
|
||||
)
|
||||
`
|
||||
|
||||
result, err := s.db.Exec(query, model.EntryStatusRemoved, limit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(`store: unable to clear content from removed entries: %v`, err)
|
||||
}
|
||||
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(`store: unable to get the number of rows affected while clearing content from removed entries: %v`, err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// RefreshFeedEntries updates feed entries while refreshing a feed.
|
||||
func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries, updateExistingEntries bool) (newEntries model.Entries, err error) {
|
||||
entryHashes := make([]string, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
entry.UserID = userID
|
||||
entry.FeedID = feedID
|
||||
@@ -403,7 +341,10 @@ func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries
|
||||
}
|
||||
} else {
|
||||
err = s.createEntry(tx, entry)
|
||||
if err == nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrEntryTombstoned):
|
||||
err = nil
|
||||
case err == nil:
|
||||
newEntries = append(newEntries, entry)
|
||||
}
|
||||
}
|
||||
@@ -418,55 +359,43 @@ func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf(`store: unable to commit transaction: %v`, err)
|
||||
}
|
||||
|
||||
entryHashes = append(entryHashes, entry.Hash)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := s.cleanupRemovedEntriesNotInFeed(feedID, entryHashes); err != nil {
|
||||
slog.Error("Unable to cleanup removed entries",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return newEntries, nil
|
||||
}
|
||||
|
||||
// ArchiveEntries changes the status of entries to "removed" after the interval (24h minimum).
|
||||
// ArchiveEntries deletes entries older than the given interval and records tombstones so they are not re-ingested.
|
||||
func (s *Storage) ArchiveEntries(status string, interval time.Duration, limit int) (int64, error) {
|
||||
if interval < 0 || limit <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE
|
||||
entries
|
||||
SET
|
||||
status=$1
|
||||
WHERE
|
||||
id IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
entries
|
||||
WHERE
|
||||
status=$2 AND
|
||||
starred is false AND
|
||||
share_code='' AND
|
||||
created_at < now () - $3::interval
|
||||
ORDER BY
|
||||
created_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT $4
|
||||
)
|
||||
WITH to_delete AS (
|
||||
SELECT id, feed_id, hash
|
||||
FROM entries
|
||||
WHERE
|
||||
status=$1 AND
|
||||
starred is false AND
|
||||
share_code='' AND
|
||||
created_at < now() - $2::interval
|
||||
ORDER BY created_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT $3
|
||||
), deleted AS (
|
||||
DELETE FROM entries
|
||||
USING to_delete
|
||||
WHERE entries.id = to_delete.id
|
||||
RETURNING entries.feed_id, entries.hash
|
||||
)
|
||||
INSERT INTO entry_tombstones (feed_id, hash)
|
||||
SELECT feed_id, hash FROM deleted WHERE hash <> ''
|
||||
ON CONFLICT (feed_id, hash) DO NOTHING
|
||||
`
|
||||
|
||||
days := max(int(interval/(24*time.Hour)), 1)
|
||||
|
||||
result, err := s.db.Exec(query, model.EntryStatusRemoved, status, fmt.Sprintf("%d days", days), limit)
|
||||
result, err := s.db.Exec(query, status, fmt.Sprintf("%d days", days), limit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(`store: unable to archive %s entries: %v`, status, err)
|
||||
}
|
||||
@@ -481,7 +410,6 @@ func (s *Storage) ArchiveEntries(status string, interval time.Duration, limit in
|
||||
|
||||
// SetEntriesStatus update the status of the given list of entries.
|
||||
func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string) error {
|
||||
// Entries that have the model.EntryStatusRemoved status are immutable.
|
||||
query := `
|
||||
UPDATE
|
||||
entries
|
||||
@@ -490,37 +418,38 @@ func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string
|
||||
changed_at=now()
|
||||
WHERE
|
||||
user_id=$2 AND
|
||||
id=ANY($3) AND
|
||||
status!=$4
|
||||
id=ANY($3)
|
||||
`
|
||||
if _, err := s.db.Exec(query, status, userID, pq.Array(entryIDs), model.EntryStatusRemoved); err != nil {
|
||||
if _, err := s.db.Exec(query, status, userID, pq.Array(entryIDs)); err != nil {
|
||||
return fmt.Errorf(`store: unable to update entries statuses %v: %v`, entryIDs, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) SetEntriesStatusCount(userID int64, entryIDs []int64, status string) (int, error) {
|
||||
if err := s.SetEntriesStatus(userID, entryIDs, status); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// SetEntriesStatusAndCountVisible updates the status of the given entries and returns how many are visible in global views.
|
||||
func (s *Storage) SetEntriesStatusAndCountVisible(userID int64, entryIDs []int64, status string) (int, error) {
|
||||
query := `
|
||||
WITH updated AS (
|
||||
UPDATE entries
|
||||
SET
|
||||
status=$1,
|
||||
changed_at=now()
|
||||
WHERE
|
||||
user_id=$2 AND
|
||||
id=ANY($3)
|
||||
RETURNING feed_id
|
||||
)
|
||||
SELECT count(*)
|
||||
FROM entries e
|
||||
JOIN feeds f ON (f.id = e.feed_id)
|
||||
JOIN categories c ON (c.id = f.category_id)
|
||||
WHERE e.user_id = $1
|
||||
AND e.id = ANY($2)
|
||||
AND NOT f.hide_globally
|
||||
AND NOT c.hide_globally
|
||||
FROM updated u
|
||||
JOIN feeds f ON (f.id = u.feed_id)
|
||||
JOIN categories c ON (c.id = f.category_id)
|
||||
WHERE NOT f.hide_globally AND NOT c.hide_globally
|
||||
`
|
||||
row := s.db.QueryRow(query, userID, pq.Array(entryIDs))
|
||||
visible := 0
|
||||
if err := row.Scan(&visible); err != nil {
|
||||
return 0, fmt.Errorf(`store: unable to query entries visibility %v: %v`, entryIDs, err)
|
||||
var visible int
|
||||
if err := s.db.QueryRow(query, status, userID, pq.Array(entryIDs)).Scan(&visible); err != nil {
|
||||
return 0, fmt.Errorf(`store: unable to update entries status %v: %v`, entryIDs, err)
|
||||
}
|
||||
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
@@ -564,19 +493,19 @@ func (s *Storage) ToggleStarred(userID int64, entryID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FlushHistory changes all entries with the status "read" to "removed".
|
||||
// FlushHistory deletes all read entries (non-starred, non-shared) and records tombstones to prevent re-ingestion.
|
||||
func (s *Storage) FlushHistory(userID int64) error {
|
||||
query := `
|
||||
UPDATE
|
||||
entries
|
||||
SET
|
||||
status=$1,
|
||||
changed_at=now()
|
||||
WHERE
|
||||
user_id=$2 AND status=$3 AND starred is false AND share_code=''
|
||||
WITH deleted AS (
|
||||
DELETE FROM entries
|
||||
WHERE user_id=$1 AND status=$2 AND starred is false AND share_code=''
|
||||
RETURNING feed_id, hash
|
||||
)
|
||||
INSERT INTO entry_tombstones (feed_id, hash)
|
||||
SELECT feed_id, hash FROM deleted WHERE hash <> ''
|
||||
ON CONFLICT (feed_id, hash) DO NOTHING
|
||||
`
|
||||
_, err := s.db.Exec(query, model.EntryStatusRemoved, userID, model.EntryStatusRead)
|
||||
if err != nil {
|
||||
if _, err := s.db.Exec(query, userID, model.EntryStatusRead); err != nil {
|
||||
return fmt.Errorf(`store: unable to flush history: %v`, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package storage // import "miniflux.app/v2/internal/storage"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -149,7 +150,7 @@ func (e *entryPaginationBuilder) getPrevNextID(tx *sql.Tx) (prevID int64, nextID
|
||||
var pID, nID sql.NullInt64
|
||||
err = tx.QueryRow(query, e.args...).Scan(&pID, &nID)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return 0, 0, nil
|
||||
case err != nil:
|
||||
return 0, 0, fmt.Errorf("entry pagination: %v", err)
|
||||
@@ -175,7 +176,7 @@ func (e *entryPaginationBuilder) getEntry(tx *sql.Tx, entryID int64) (*model.Ent
|
||||
)
|
||||
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("fetching sibling entry: %v", err)
|
||||
@@ -188,8 +189,8 @@ func (e *entryPaginationBuilder) getEntry(tx *sql.Tx, entryID int64) (*model.Ent
|
||||
func NewEntryPaginationBuilder(store *Storage, userID, entryID int64, order, direction string) *entryPaginationBuilder {
|
||||
return &entryPaginationBuilder{
|
||||
store: store,
|
||||
args: []any{userID, "removed"},
|
||||
conditions: []string{"e.user_id = $1", "e.status <> $2"},
|
||||
args: []any{userID},
|
||||
conditions: []string{"e.user_id = $1"},
|
||||
entryID: entryID,
|
||||
order: order,
|
||||
direction: direction,
|
||||
|
||||
@@ -25,6 +25,7 @@ type EntryQueryBuilder struct {
|
||||
limit int
|
||||
offset int
|
||||
fetchEnclosures bool
|
||||
excludeContent bool
|
||||
}
|
||||
|
||||
// WithEnclosures fetches enclosures for each entry.
|
||||
@@ -33,6 +34,14 @@ func (e *EntryQueryBuilder) WithEnclosures() *EntryQueryBuilder {
|
||||
return e
|
||||
}
|
||||
|
||||
// WithoutContent excludes the content column from the query results,
|
||||
// replacing it with an empty string. This significantly reduces data
|
||||
// transfer from PostgreSQL on list pages where content is not displayed.
|
||||
func (e *EntryQueryBuilder) WithoutContent() *EntryQueryBuilder {
|
||||
e.excludeContent = true
|
||||
return e
|
||||
}
|
||||
|
||||
// WithSearchQuery adds full-text search query to the condition.
|
||||
func (e *EntryQueryBuilder) WithSearchQuery(query string) *EntryQueryBuilder {
|
||||
if query != "" {
|
||||
@@ -207,7 +216,7 @@ func (e *EntryQueryBuilder) WithSorting(column, direction string) *EntryQueryBui
|
||||
// WithLimit set the limit.
|
||||
func (e *EntryQueryBuilder) WithLimit(limit int) *EntryQueryBuilder {
|
||||
if limit > 0 {
|
||||
e.limit = limit
|
||||
e.limit = min(limit, model.MaxEntryLimit)
|
||||
}
|
||||
return e
|
||||
}
|
||||
@@ -298,7 +307,7 @@ func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, er
|
||||
e.comments_url,
|
||||
e.author,
|
||||
e.share_code,
|
||||
e.content,
|
||||
` + e.contentColumn() + `,
|
||||
e.status,
|
||||
e.starred,
|
||||
e.reading_time,
|
||||
@@ -344,9 +353,10 @@ func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, er
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := make(model.Entries, 0)
|
||||
entryMap := make(map[int64]*model.Entry)
|
||||
var entryIDs []int64
|
||||
size := max(e.limit, 0)
|
||||
entries := make(model.Entries, 0, size)
|
||||
entryMap := make(map[int64]*model.Entry, size)
|
||||
entryIDs := make([]int64, 0, size)
|
||||
var totalCount int
|
||||
|
||||
for rows.Next() {
|
||||
@@ -479,6 +489,13 @@ func (e *EntryQueryBuilder) GetEntryIDs() ([]int64, error) {
|
||||
return entryIDs, nil
|
||||
}
|
||||
|
||||
func (e *EntryQueryBuilder) contentColumn() string {
|
||||
if e.excludeContent {
|
||||
return "'' AS content"
|
||||
}
|
||||
return "e.content"
|
||||
}
|
||||
|
||||
func (e *EntryQueryBuilder) buildCondition() string {
|
||||
return strings.Join(e.conditions, " AND ")
|
||||
}
|
||||
|
||||
+13
-54
@@ -7,7 +7,6 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -52,7 +51,7 @@ func (s *Storage) CheckedAt(userID, feedID int64) (time.Time, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CategoryFeedExists returns true if the given feed exists that belongs to the given category.
|
||||
// CategoryFeedExists returns true if the given feed exists and belongs to the given category.
|
||||
func (s *Storage) CategoryFeedExists(userID, categoryID, feedID int64) bool {
|
||||
var result bool
|
||||
query := `SELECT true FROM feeds WHERE user_id=$1 AND category_id=$2 AND id=$3 LIMIT 1`
|
||||
@@ -60,7 +59,7 @@ func (s *Storage) CategoryFeedExists(userID, categoryID, feedID int64) bool {
|
||||
return result
|
||||
}
|
||||
|
||||
// FeedURLExists checks if feed URL already exists.
|
||||
// FeedURLExists returns true if the given feed URL already exists for the user.
|
||||
func (s *Storage) FeedURLExists(userID int64, feedURL string) bool {
|
||||
var result bool
|
||||
query := `SELECT true FROM feeds WHERE user_id=$1 AND feed_url=$2 LIMIT 1`
|
||||
@@ -68,7 +67,7 @@ func (s *Storage) FeedURLExists(userID int64, feedURL string) bool {
|
||||
return result
|
||||
}
|
||||
|
||||
// AnotherFeedURLExists checks if the user a duplicated feed.
|
||||
// AnotherFeedURLExists returns true if another feed with the same URL exists for the user.
|
||||
func (s *Storage) AnotherFeedURLExists(userID, feedID int64, feedURL string) bool {
|
||||
var result bool
|
||||
query := `SELECT true FROM feeds WHERE id <> $1 AND user_id=$2 AND feed_url=$3 LIMIT 1`
|
||||
@@ -76,7 +75,7 @@ func (s *Storage) AnotherFeedURLExists(userID, feedID int64, feedURL string) boo
|
||||
return result
|
||||
}
|
||||
|
||||
// CountAllFeeds returns the number of feeds in the database.
|
||||
// CountAllFeeds returns the number of feeds keyed by enabled, disabled, and total.
|
||||
func (s *Storage) CountAllFeeds() (map[string]int64, error) {
|
||||
rows, err := s.db.Query(`SELECT disabled, count(*) FROM feeds GROUP BY disabled`)
|
||||
if err != nil {
|
||||
@@ -109,22 +108,6 @@ func (s *Storage) CountAllFeeds() (map[string]int64, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// CountUserFeedsWithErrors returns the number of feeds with parsing errors that belong to the given user.
|
||||
func (s *Storage) CountUserFeedsWithErrors(userID int64) int {
|
||||
pollingParsingErrorLimit := config.Opts.PollingParsingErrorLimit()
|
||||
if pollingParsingErrorLimit <= 0 {
|
||||
pollingParsingErrorLimit = 1
|
||||
}
|
||||
query := `SELECT count(*) FROM feeds WHERE user_id=$1 AND parsing_error_count >= $2`
|
||||
var result int
|
||||
err := s.db.QueryRow(query, userID, pollingParsingErrorLimit).Scan(&result)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// CountAllFeedsWithErrors returns the number of feeds with parsing errors.
|
||||
func (s *Storage) CountAllFeedsWithErrors() (int, error) {
|
||||
pollingParsingErrorLimit := config.Opts.PollingParsingErrorLimit()
|
||||
@@ -141,7 +124,7 @@ func (s *Storage) CountAllFeedsWithErrors() (int, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Feeds returns all feeds that belongs to the given user.
|
||||
// Feeds returns all feeds that belong to the given user.
|
||||
func (s *Storage) Feeds(userID int64) (model.Feeds, error) {
|
||||
builder := NewFeedQueryBuilder(s, userID)
|
||||
builder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)
|
||||
@@ -157,7 +140,7 @@ func getFeedsSorted(builder *feedQueryBuilder) (model.Feeds, error) {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// FeedsWithCounters returns all feeds of the given user with counters of read and unread entries.
|
||||
// FeedsWithCounters returns all feeds of the given user with read and unread entry counters.
|
||||
func (s *Storage) FeedsWithCounters(userID int64) (model.Feeds, error) {
|
||||
builder := NewFeedQueryBuilder(s, userID)
|
||||
builder.WithCounters()
|
||||
@@ -165,7 +148,7 @@ func (s *Storage) FeedsWithCounters(userID int64) (model.Feeds, error) {
|
||||
return getFeedsSorted(builder)
|
||||
}
|
||||
|
||||
// FetchCounters returns read and unread count.
|
||||
// FetchCounters returns the per-feed read and unread entry counts for the given user.
|
||||
func (s *Storage) FetchCounters(userID int64) (model.FeedCounters, error) {
|
||||
builder := NewFeedQueryBuilder(s, userID)
|
||||
builder.WithCounters()
|
||||
@@ -173,7 +156,7 @@ func (s *Storage) FetchCounters(userID int64) (model.FeedCounters, error) {
|
||||
return model.FeedCounters{ReadCounters: reads, UnreadCounters: unreads}, err
|
||||
}
|
||||
|
||||
// FeedsByCategoryWithCounters returns all feeds of the given user/category with counters of read and unread entries.
|
||||
// FeedsByCategoryWithCounters returns all feeds in the given category for the given user with read and unread entry counters.
|
||||
func (s *Storage) FeedsByCategoryWithCounters(userID, categoryID int64) (model.Feeds, error) {
|
||||
builder := NewFeedQueryBuilder(s, userID)
|
||||
builder.WithCategoryID(categoryID)
|
||||
@@ -214,7 +197,7 @@ func (s *Storage) WeeklyFeedEntryCount(userID, feedID int64) (int, error) {
|
||||
return weeklyCount, nil
|
||||
}
|
||||
|
||||
// FeedByID returns a feed by the ID.
|
||||
// FeedByID returns the feed with the given ID.
|
||||
func (s *Storage) FeedByID(userID, feedID int64) (*model.Feed, error) {
|
||||
builder := NewFeedQueryBuilder(s, userID)
|
||||
builder.WithFeedID(feedID)
|
||||
@@ -442,7 +425,7 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateFeedError updates feed errors.
|
||||
// UpdateFeedError persists the parsing error fields for the given feed.
|
||||
func (s *Storage) UpdateFeedError(feed *model.Feed) (err error) {
|
||||
query := `
|
||||
UPDATE
|
||||
@@ -471,45 +454,21 @@ func (s *Storage) UpdateFeedError(feed *model.Feed) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveFeed removes a feed and all entries.
|
||||
// This operation can takes time if the feed has lot of entries.
|
||||
// RemoveFeed removes the given feed along with its entries and enclosures.
|
||||
func (s *Storage) RemoveFeed(userID, feedID int64) error {
|
||||
rows, err := s.db.Query(`SELECT id FROM entries WHERE user_id=$1 AND feed_id=$2`, userID, feedID)
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to get user feed entries: %v`, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var entryID int64
|
||||
if err := rows.Scan(&entryID); err != nil {
|
||||
return fmt.Errorf(`store: unable to read user feed entry ID: %v`, err)
|
||||
}
|
||||
|
||||
slog.Debug("Deleting entry",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
|
||||
if _, err := s.db.Exec(`DELETE FROM entries WHERE id=$1 AND user_id=$2`, entryID, userID); err != nil {
|
||||
return fmt.Errorf(`store: unable to delete user feed entries #%d: %v`, entryID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(`DELETE FROM feeds WHERE id=$1 AND user_id=$2`, feedID, userID); err != nil {
|
||||
return fmt.Errorf(`store: unable to delete feed #%d: %v`, feedID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetFeedErrors removes all feed errors.
|
||||
// ResetFeedErrors clears the parsing error fields for all feeds.
|
||||
func (s *Storage) ResetFeedErrors() error {
|
||||
_, err := s.db.Exec(`UPDATE feeds SET parsing_error_count=0, parsing_error_msg=''`)
|
||||
return err
|
||||
}
|
||||
|
||||
// ResetNextCheckAt schedules all feeds to be checked immediately.
|
||||
func (s *Storage) ResetNextCheckAt() error {
|
||||
_, err := s.db.Exec(`UPDATE feeds SET next_check_at=now()`)
|
||||
return err
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user