pax_global_header 0000666 0000000 0000000 00000000064 15201231005 0014477 g ustar 00root root 0000000 0000000 52 comment=20545d28e987e858d4c1470c4839780c0ac93798
v2-2.3.0/ 0000775 0000000 0000000 00000000000 15201231005 0012030 5 ustar 00root root 0000000 0000000 v2-2.3.0/.devcontainer/ 0000775 0000000 0000000 00000000000 15201231005 0014567 5 ustar 00root root 0000000 0000000 v2-2.3.0/.devcontainer/devcontainer.json 0000664 0000000 0000000 00000001377 15201231005 0020153 0 ustar 00root root 0000000 0000000 {
"name": "Miniflux",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"remoteUser": "vscode",
"forwardPorts": [
8080
],
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
"moby": false
}
},
"customizations": {
"vscode": {
"settings": {
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "/go"
},
"extensions": [
"ms-azuretools.vscode-docker",
"golang.go",
"rangav.vscode-thunder-client",
"GitHub.codespaces",
"GitHub.copilot",
"GitHub.copilot-chat"
]
}
}
}
v2-2.3.0/.devcontainer/docker-compose.yml 0000664 0000000 0000000 00000001375 15201231005 0020232 0 ustar 00root root 0000000 0000000 services:
app:
image: mcr.microsoft.com/devcontainers/go:1-trixie # https://www.debian.org/releases/trixie/index.en.html
volumes:
- ..:/workspace:cached
command: sleep infinity
network_mode: service:db
environment:
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
db:
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql
hostname: postgres
environment:
POSTGRES_DB: miniflux2
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
apprise:
image: caronc/apprise:1.0
restart: unless-stopped
hostname: apprise
volumes:
postgres-data: null
v2-2.3.0/.github/ 0000775 0000000 0000000 00000000000 15201231005 0013370 5 ustar 00root root 0000000 0000000 v2-2.3.0/.github/ISSUE_TEMPLATE/ 0000775 0000000 0000000 00000000000 15201231005 0015553 5 ustar 00root root 0000000 0000000 v2-2.3.0/.github/ISSUE_TEMPLATE/bug_report.yml 0000664 0000000 0000000 00000005540 15201231005 0020452 0 ustar 00root root 0000000 0000000 name: "Bug Report"
description: "Report a bug or unexpected behavior"
title: "[Bug]: "
type: "Bug"
labels: ["triage needed"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug! Please provide detailed information to help us reproduce and fix the issue.
- type: input
id: summary
attributes:
label: "Bug Summary"
description: "Briefly describe the bug."
placeholder: "e.g., Error when saving a new entry"
validations:
required: true
- type: textarea
id: description
attributes:
label: "Description"
description: "A clear and concise description of the bug."
placeholder: "e.g., When I click 'Save', I get a 500 error."
validations:
required: true
- type: textarea
id: steps_to_reproduce
attributes:
label: "Steps to Reproduce"
description: "Steps to reproduce the behavior."
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
validations:
required: true
- type: textarea
id: expected_behavior
attributes:
label: "Expected Behavior"
description: "What should happen instead?"
placeholder: "e.g., The form should be saved successfully."
validations:
required: true
- type: textarea
id: actual_behavior
attributes:
label: "Actual Behavior"
description: "What actually happens?"
placeholder: "e.g., A 500 error is returned with no useful error message."
validations:
required: true
- type: input
id: version
attributes:
label: "Version"
description: "Which version of Miniflux are you using?"
placeholder: "e.g., 2.2.6"
validations:
required: true
- type: input
id: browser
attributes:
label: "Browser"
description: "If applicable, which browser are you using? Please provide the version."
placeholder: "e.g., Chrome, Firefox, Safari"
validations:
required: false
- type: textarea
id: logs
attributes:
label: "Relevant Logs or Error Output"
description: "Paste any relevant logs or error messages (if applicable)."
render: shell
placeholder: "e.g., Stack trace, log files, browser console logs, or console output"
validations:
required: false
- type: textarea
id: additional_context
attributes:
label: "Additional Context"
description: "Add any other context about the problem here."
placeholder: "e.g., Screenshots, video recordings, or related issues"
validations:
required: false
- type: checkboxes
id: agreement
attributes:
label: "Checklist"
description: "Please confirm the following:"
options:
- label: "I have searched existing issues to ensure this bug hasn't been reported before."
required: true
v2-2.3.0/.github/ISSUE_TEMPLATE/config.yml 0000664 0000000 0000000 00000000034 15201231005 0017540 0 ustar 00root root 0000000 0000000 blank_issues_enabled: false
v2-2.3.0/.github/ISSUE_TEMPLATE/documentation.yml 0000664 0000000 0000000 00000005046 15201231005 0021154 0 ustar 00root root 0000000 0000000 name: "Documentation Issue"
description: "Report issues or suggest improvements for the documentation"
title: "[Docs]: "
type: "Documentation"
labels: ["triage needed"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve the Miniflux documentation! Clear and accurate documentation helps everyone.
- type: dropdown
id: issue_type
attributes:
label: "Documentation Issue Type"
description: "What kind of documentation issue are you reporting?"
options:
- "Missing Information"
- "Incorrect Information"
- "Outdated Information"
- "Unclear Explanation"
- "Formatting/Structural Issue"
- "Typo/Grammar Error"
- "Documentation Request"
- "Other"
validations:
required: true
- type: input
id: summary
attributes:
label: "Summary"
description: "Briefly describe the documentation issue."
placeholder: "e.g., The API authentication section is outdated"
validations:
required: true
- type: input
id: location
attributes:
label: "Location"
description: "Where is the documentation you're referring to? Provide URLs, file paths, or section names."
placeholder: "e.g., README.md, docs/api.md, Installation section of the website"
validations:
required: true
- type: textarea
id: description
attributes:
label: "Detailed Description"
description: "Provide a detailed description of the issue or improvement."
placeholder: "e.g., The API authentication section doesn't mention the new token-based authentication method introduced in version 2.0.5."
validations:
required: true
- type: textarea
id: current_content
attributes:
label: "Current Content (if applicable)"
description: "What does the current documentation say?"
placeholder: "Paste the current documentation text here."
validations:
required: false
- type: textarea
id: suggested_content
attributes:
label: "Suggested Changes"
description: "If you have specific suggestions for how to improve the documentation, please provide them here."
placeholder: "e.g., Add a new section about token-based authentication with these details..."
validations:
required: false
- type: input
id: version
attributes:
label: "Version"
description: "Which version of Miniflux does this documentation issue relate to?"
placeholder: "e.g., 2.2.6, or 'all versions'"
validations:
required: false
v2-2.3.0/.github/ISSUE_TEMPLATE/feature_request.yml 0000664 0000000 0000000 00000004267 15201231005 0021512 0 ustar 00root root 0000000 0000000 name: "Feature Request"
description: "Suggest an idea or improvement for the project"
title: "[Feature]: "
type: "Feature"
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest a feature! Please provide detailed information to help us understand and evaluate your idea.
- type: input
id: summary
attributes:
label: "Feature Summary"
description: "Briefly describe the feature or enhancement."
placeholder: "e.g., Add dark mode support"
validations:
required: true
- type: textarea
id: problem
attributes:
label: "What problem does this feature solve?"
description: "Explain the problem or limitation this feature would address."
placeholder: "e.g., It's difficult to use the app in low-light environments."
validations:
required: true
- type: textarea
id: solution
attributes:
label: "Proposed Solution"
description: "Describe how you think this feature should work."
placeholder: "e.g., Add a toggle in settings to switch between light and dark mode."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "Alternatives Considered"
description: "Have you considered other solutions or workarounds?"
placeholder: "e.g., Using browser extensions to force dark mode."
validations:
required: false
- type: textarea
id: additional_context
attributes:
label: "Additional Context"
description: "Add any other context, screenshots, or examples to explain your request."
placeholder: "e.g., A screenshot of a similar feature in another project."
validations:
required: false
- type: checkboxes
id: agreement
attributes:
label: "Checklist"
description: "Please confirm the following:"
options:
- label: "I have searched existing issues to ensure this feature hasn't been requested before."
required: true
- label: "I understand that feature requests are not guaranteed to be implemented."
required: true
- label: "I agree to follow the project's contribution guidelines."
required: true
v2-2.3.0/.github/ISSUE_TEMPLATE/feed_issue.yml 0000664 0000000 0000000 00000005245 15201231005 0020417 0 ustar 00root root 0000000 0000000 name: "Feed/Website Issue"
description: "Report problems with a specific feed or website"
title: "[Feed Issue]: "
type: "Feed Issue"
labels: ["triage needed"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting an issue with a feed or website! Please provide detailed information to help us diagnose and resolve the problem.
- type: input
id: feed_url
attributes:
label: "Feed URL"
description: "Provide the URL of the feed that is not working correctly."
placeholder: "e.g., https://example.com/feed.xml"
validations:
required: true
- type: input
id: website_url
attributes:
label: "Website URL"
description: "Provide the URL of the website."
placeholder: "e.g., https://example.com"
validations:
required: true
- type: textarea
id: problem_description
attributes:
label: "Problem Description"
description: "Describe the issue you are experiencing with this feed."
placeholder: |
e.g.,
- The feed URL returns a 403 error.
- The content is malformed.
- Images are not loading in the web ui.
validations:
required: true
- type: textarea
id: expected_behavior
attributes:
label: "Expected Behavior"
description: "Describe what you expect to happen."
placeholder: "e.g., The feed should show the images correctly."
validations:
required: true
- type: textarea
id: error_logs
attributes:
label: "Relevant Logs or Error Output"
description: "Paste any relevant logs or error messages, if available."
render: shell
placeholder: "e.g., HTTP error codes, invalid XML warnings, etc."
validations:
required: false
- type: textarea
id: additional_context
attributes:
label: "Additional Context"
description: "Add any other context, screenshots, or related information to help us troubleshoot."
placeholder: "e.g., Is this a recurring problem? Did the feed work before?"
validations:
required: false
- type: checkboxes
id: troubleshooting
attributes:
label: "Troubleshooting Steps"
description: "Please confirm that you have tried the following:"
options:
- label: "I have checked if the feed URL is correct and accessible in a web browser."
required: true
- label: "I have checked if the feed URL is correct and accessible with `curl`."
required: true
- label: "I have verified that the feed is valid using an RSS/Atom validator."
required: false
- label: "I have searched for existing issues to avoid duplicates."
required: true
v2-2.3.0/.github/ISSUE_TEMPLATE/proposal.yml 0000664 0000000 0000000 00000006216 15201231005 0020142 0 ustar 00root root 0000000 0000000 name: "Proposal / RFC"
description: "Propose a significant change, or architectural decision"
title: "[Proposal]: "
type: "Proposal"
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to submit a proposal! Please provide detailed information to ensure a productive discussion.
- type: input
id: summary
attributes:
label: "Proposal Summary"
description: "A brief summary of the proposed change or idea."
placeholder: "e.g., Refactor database schema for performance optimization"
validations:
required: true
- type: textarea
id: motivation
attributes:
label: "Motivation and Context"
description: "Explain the problem this proposal addresses. Why is it necessary? What are the current limitations or pain points?"
placeholder: |
e.g.,
- The current database schema causes performance bottlenecks when querying large datasets.
- Adding this feature will improve scalability and reliability for large-scale use cases.
validations:
required: true
- type: textarea
id: proposed_solution
attributes:
label: "Proposed Solution"
description: "Describe the proposed solution or approach. Include technical details, diagrams, and examples where possible."
placeholder: |
e.g.,
- Redesign the schema to normalize tables and introduce indexing.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "Alternatives Considered"
description: "List any alternative approaches that were considered and explain why they were rejected."
placeholder: |
e.g.,
- Use Redis for caching, but it adds operational complexity.
- Stick with the current schema and optimize queries, but this has limited impact on performance.
validations:
required: false
- type: textarea
id: impact
attributes:
label: "Impact and Risks"
description: "Describe the potential impact of this change. Highlight possible risks and backward compatibility concerns."
placeholder: |
e.g.,
- May require data migration with downtime.
- Could introduce breaking changes in API responses.
- Affects core functionality, requiring extensive testing.
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: "Additional Context or References"
description: "Add any relevant context, links to related discussions, RFCs, or design documents."
placeholder: "e.g., Links to research, GitHub issues, or similar projects"
validations:
required: false
- type: checkboxes
id: agreement
attributes:
label: "Checklist"
description: "Please confirm the following:"
options:
- label: "I have reviewed existing proposals to ensure this change hasn't been proposed before."
required: true
- label: "I agree to provide follow-up updates and maintain discussion on this proposal."
required: true
- label: "I agree to follow the project's contribution guidelines."
required: true
v2-2.3.0/.github/dependabot.yml 0000664 0000000 0000000 00000001145 15201231005 0016221 0 ustar 00root root 0000000 0000000 version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
groups:
gomod:
patterns:
- "*"
- package-ecosystem: "docker"
directories:
- "/packaging/docker/alpine"
- "/packaging/docker/distroless"
- "/packaging/debian"
- "/packaging/rpm"
schedule:
interval: "monthly"
groups:
docker:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
groups:
github-actions:
patterns:
- "*"
v2-2.3.0/.github/pull_request_template.md 0000664 0000000 0000000 00000000640 15201231005 0020331 0 ustar 00root root 0000000 0000000 Have you followed these guidelines?
- [ ] I have tested my changes
- [ ] There are no breaking changes
- [ ] I have thoroughly tested my changes and verified there are no regressions
- [ ] My commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
- [ ] I have read and understood the [contribution guidelines](https://github.com/miniflux/v2/blob/main/CONTRIBUTING.md)
v2-2.3.0/.github/workflows/ 0000775 0000000 0000000 00000000000 15201231005 0015425 5 ustar 00root root 0000000 0000000 v2-2.3.0/.github/workflows/build_binaries.yml 0000664 0000000 0000000 00000002067 15201231005 0021130 0 ustar 00root root 0000000 0000000 name: Build Binaries
permissions:
contents: read
on:
workflow_dispatch:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [ main ]
paths:
- '.github/workflows/build_binaries.yml'
- 'Makefile'
- 'go.mod'
- 'go.sum'
- '**.go'
jobs:
build:
name: Build
if: github.repository_owner == 'miniflux'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Golang
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
check-latest: true
- name: Compile binaries
env:
CGO_ENABLED: 0
run: make build
- name: Upload binaries
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: binaries
path: miniflux-*
if-no-files-found: error
retention-days: 5
v2-2.3.0/.github/workflows/codeberg_mirror.yml 0000664 0000000 0000000 00000001407 15201231005 0021316 0 ustar 00root root 0000000 0000000 name: Mirror to Codeberg
on:
push:
branches: [ main ]
delete:
workflow_dispatch:
jobs:
mirror:
if: github.repository_owner == 'miniflux'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Mirror to Codeberg
env:
CODEBERG_USERNAME: ${{ secrets.CODEBERG_USERNAME }}
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
run: |
git remote add codeberg https://${{ secrets.CODEBERG_USERNAME }}:${{ secrets.CODEBERG_TOKEN }}@codeberg.org/miniflux/v2.git
git push --force --prune codeberg \
"refs/heads/*:refs/heads/*" \
"refs/tags/*:refs/tags/*"
v2-2.3.0/.github/workflows/codeql-analysis.yml 0000664 0000000 0000000 00000002723 15201231005 0021244 0 ustar 00root root 0000000 0000000 name: "CodeQL"
permissions: read-all
on:
push:
branches: [ main ]
paths:
- '**.js'
- '**.go'
- '!**_test.go'
- '.github/workflows/codeql-analysis.yml'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
paths:
- '**.js'
- '**.go'
- '!**_test.go'
- '.github/workflows/codeql-analysis.yml'
schedule:
- cron: '45 22 * * 3'
workflow_dispatch:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
if: matrix.language == 'go'
with:
go-version: stable
- name: Initialize CodeQL
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
category: "/language:${{ matrix.language }}"
v2-2.3.0/.github/workflows/debian_packages.yml 0000664 0000000 0000000 00000006064 15201231005 0021236 0 ustar 00root root 0000000 0000000 name: Debian Packages
permissions: read-all
on:
workflow_dispatch:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
schedule:
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
pull_request:
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')
|| github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
id: buildx
with:
install: true
- name: Available Docker Platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Build Debian Packages
run: make debian-packages
- name: List generated files
run: ls -l *.deb
build-packages-manually:
if: github.event_name == 'workflow_dispatch'
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
id: buildx
with:
install: true
- name: Available Docker Platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Build Debian Packages
run: make debian-packages
- name: Upload package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: packages
path: "*.deb"
if-no-files-found: error
retention-days: 3
publish-packages:
if: github.event_name == 'push' && github.repository_owner == 'miniflux'
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
id: buildx
with:
install: true
- name: Available Docker Platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Build Debian Packages
run: make debian-packages
- name: List generated files
run: ls -l *.deb
- name: Upload packages to repository
env:
FURY_TOKEN: ${{ secrets.FURY_TOKEN }}
run: for f in *.deb; do curl -F package=@$f https://$FURY_TOKEN@push.fury.io/miniflux/; done
v2-2.3.0/.github/workflows/docker.yml 0000664 0000000 0000000 00000007100 15201231005 0017415 0 ustar 00root root 0000000 0000000 name: Docker
on:
schedule:
- cron: '0 1 * * *'
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [ main ]
paths:
- 'packaging/docker/**'
- '.github/workflows/docker.yml'
jobs:
docker-images:
name: Docker Images
if: github.repository_owner == 'miniflux'
permissions:
packages: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Generate Alpine Docker tags
id: docker_alpine_tags
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
ghcr.io/${{ github.repository_owner }}/miniflux
quay.io/${{ github.repository_owner }}/miniflux
tags: |
type=ref,event=pr
type=schedule,pattern=nightly
type=semver,pattern={{raw}}
- name: Generate Distroless Docker tags
id: docker_distroless_tags
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
ghcr.io/${{ github.repository_owner }}/miniflux
quay.io/${{ github.repository_owner }}/miniflux
tags: |
type=ref,event=pr
type=schedule,pattern=nightly
type=semver,pattern={{raw}}
flavor: |
suffix=-distroless,onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to DockerHub
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' }}
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' }}
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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./packaging/docker/alpine/Dockerfile
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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./packaging/docker/distroless/Dockerfile
platforms: linux/amd64,linux/arm64,linux/riscv64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_distroless_tags.outputs.tags }}
v2-2.3.0/.github/workflows/linters.yml 0000664 0000000 0000000 00000002771 15201231005 0017637 0 ustar 00root root 0000000 0000000 name: Linters
permissions: read-all
on:
pull_request:
branches:
- main
workflow_dispatch:
jobs:
jshint:
name: Javascript Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install linters
run: |
sudo npm install -g jshint@2.13.6 eslint@8.57.0
- name: Run jshint
run: jshint internal/ui/static/js/*.js
- name: Run ESLint
run: eslint internal/ui/static/js/*.js
golangci:
name: Golang Linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
- name: Run gofmt linter
run: gofmt -d -e .
commitlint:
if: github.event_name == 'pull_request'
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.13'
- name: Validate PR commits
run: python3 .github/workflows/scripts/commit-checker.py --base ${{ github.event.pull_request.base.sha }} --head ${{ github.event.pull_request.head.sha }}
v2-2.3.0/.github/workflows/rpm_packages.yml 0000664 0000000 0000000 00000003625 15201231005 0020612 0 ustar 00root root 0000000 0000000 name: RPM Packages
permissions: read-all
on:
workflow_dispatch:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
schedule:
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
pull_request:
branches: [ main ]
paths:
- 'packaging/rpm/**' # Only run on changes to the rpm packaging files
- '.github/workflows/rpm_packages.yml'
jobs:
test-package:
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux')
|| github.event_name == 'pull_request'
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm VERSION=2.2.x_dev
- name: List generated files
run: ls -l *.rpm
build-package-manually:
if: github.event_name == 'workflow_dispatch'
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: packages
path: "*.rpm"
if-no-files-found: error
retention-days: 3
publish-package:
if: github.event_name == 'push' && github.repository_owner == 'miniflux'
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
- name: List generated files
run: ls -l *.rpm
- name: Upload package to repository
env:
FURY_TOKEN: ${{ secrets.FURY_TOKEN }}
run: for f in *.rpm; do curl -F package=@$f https://$FURY_TOKEN@push.fury.io/miniflux/; done
v2-2.3.0/.github/workflows/scripts/ 0000775 0000000 0000000 00000000000 15201231005 0017114 5 ustar 00root root 0000000 0000000 v2-2.3.0/.github/workflows/scripts/commit-checker.py 0000664 0000000 0000000 00000006055 15201231005 0022366 0 ustar 00root root 0000000 0000000 import subprocess
import re
import sys
import argparse
from typing import Match
# Conventional commit pattern (including Git revert messages)
CONVENTIONAL_COMMIT_PATTERN: str = (
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|security|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
)
def get_commit_message(commit_hash: str) -> str:
"""Get the commit message for a given commit hash."""
try:
result: subprocess.CompletedProcess = subprocess.run(
["git", "show", "-s", "--format=%B", commit_hash],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"Error retrieving commit message: {e}")
sys.exit(1)
def check_commit_message(message: str, pattern: str = CONVENTIONAL_COMMIT_PATTERN) -> bool:
"""Check if commit message follows conventional commit format."""
first_line: str = message.split("\n")[0]
match: Match[str] | None = re.match(pattern, first_line)
return bool(match)
def check_commit_range(base_ref: str, head_ref: str) -> list[dict[str, str]]:
"""Check all commits in a range for compliance."""
try:
result: subprocess.CompletedProcess = subprocess.run(
["git", "log", "--format=%H", f"{base_ref}..{head_ref}"],
capture_output=True,
text=True,
check=True,
)
commit_hashes: list[str] = result.stdout.strip().split("\n")
# Filter out empty lines
commit_hashes = [hash for hash in commit_hashes if hash]
non_compliant: list[dict[str, str]] = []
for commit_hash in commit_hashes:
message: str = get_commit_message(commit_hash)
if not check_commit_message(message):
non_compliant.append({"hash": commit_hash, "message": message.split("\n")[0]})
return non_compliant
except subprocess.CalledProcessError as e:
print(f"Error checking commit range: {e}")
sys.exit(1)
def main() -> None:
parser: argparse.ArgumentParser = argparse.ArgumentParser(description="Check conventional commit compliance")
parser.add_argument("--base", required=True, help="Base ref (starting commit, exclusive)")
parser.add_argument("--head", required=True, help="Head ref (ending commit, inclusive)")
args: argparse.Namespace = parser.parse_args()
non_compliant: list[dict[str, str]] = check_commit_range(args.base, args.head)
if non_compliant:
print("The following commits do not follow the conventional commit format:")
for commit in non_compliant:
print(f"- {commit['hash'][:8]}: {commit['message']}")
print("\nPlease ensure your commit messages follow the format:")
print("type(scope): subject")
print("\nWhere type is one of: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test")
sys.exit(1)
else:
print("All commits follow the conventional commit format!")
sys.exit(0)
if __name__ == "__main__":
main()
v2-2.3.0/.github/workflows/stale.yml 0000664 0000000 0000000 00000001600 15201231005 0017255 0 ustar 00root root 0000000 0000000 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
v2-2.3.0/.github/workflows/tests.yml 0000664 0000000 0000000 00000003214 15201231005 0017312 0 ustar 00root root 0000000 0000000 name: Tests
permissions: read-all
on:
pull_request:
branches:
- main
workflow_dispatch:
jobs:
unit-tests:
name: Unit Tests
runs-on: ${{ matrix.os }}
strategy:
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- name: Run unit tests with coverage and race conditions checking
if: matrix.os == 'ubuntu-latest'
run: make test
- name: Run unit tests without coverage and race conditions checking
if: matrix.os != 'ubuntu-latest'
run: go test ./...
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:9.5
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: stable
- name: Install Postgres client
run: sudo apt update && sudo apt install -y postgresql-client
- name: Run integration tests
run: make integration-test
env:
PGHOST: 127.0.0.1
PGPASSWORD: postgres
v2-2.3.0/.gitignore 0000664 0000000 0000000 00000000072 15201231005 0014017 0 ustar 00root root 0000000 0000000 ./*.sha256
/miniflux
.idea
.vscode
*.deb
*.rpm
miniflux-*
v2-2.3.0/.golangci.yml 0000664 0000000 0000000 00000000663 15201231005 0014421 0 ustar 00root root 0000000 0000000 version: "2"
linters:
default: standard
disable:
- errcheck
enable:
- errname
- gocritic
- goheader
- loggercheck
- misspell
- perfsprint
- sqlclosecheck
- staticcheck
- whitespace
settings:
loggercheck:
slog: true
goheader:
template: |-
SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
SPDX-License-Identifier: Apache-2.0
v2-2.3.0/CONTRIBUTING.md 0000664 0000000 0000000 00000012205 15201231005 0014261 0 ustar 00root root 0000000 0000000 # Contributing to Miniflux
This document outlines how to contribute effectively to Miniflux.
## Philosophy
Miniflux follows a **minimalist philosophy**. The feature set is intentionally kept limited to avoid bloatware. Before contributing, please understand that:
- **Improving existing features takes priority over adding new ones**
- **Quality over quantity** - well-implemented, focused features are preferred
- **Simplicity is key** - complex solutions are discouraged in favor of simple, maintainable code
## Before You Start
### Feature Requests
Before implementing a new feature:
- Check if it aligns with Miniflux's philosophy
- Consider if the feature could be implemented differently to maintain simplicity
- Remember that developing software takes significant time, and this is a volunteer-driven project
- If you need a specific feature, the best approach is to contribute it yourself
### Bug Reports
When reporting bugs:
- Search existing issues first to avoid duplicates
- Provide clear reproduction steps
- Include relevant system information (OS, browser, Miniflux version)
- Include error messages, screenshots, and logs when applicable
## Development Setup
### Requirements
- **Git**
- **Go >= 1.26**
- **PostgreSQL**
### Getting Started
1. **Fork the repository** on GitHub
2. **Clone your fork locally:**
```bash
git clone https://github.com/YOUR_USERNAME/miniflux.git
cd miniflux
```
3. **Build the application binary:**
```bash
make miniflux
```
4. **Run locally in debug mode:**
```bash
make run
```
### Database Setup
For development and testing, you can run a local PostgreSQL database with Docker:
```bash
# Start PostgreSQL container
docker run --rm --name miniflux2-db -p 5432:5432 \
-e POSTGRES_DB=miniflux2 \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
postgres
```
You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE_URL` environment variable accordingly.
## Development Workflow
### Code Quality
1. **Run the linter:**
```bash
make lint
```
Requires `staticcheck` and `golangci-lint` to be installed.
2. **Run unit tests:**
```bash
make test
```
3. **Run integration tests:**
```bash
make integration-test
make clean-integration-test
```
### Building
- **Current platform:** `make miniflux`
- **All platforms:** `make build`
- **Specific platforms:** `make linux-amd64`, `make darwin-arm64`, etc.
- **Docker image:** `make docker-image`
### Cross-Platform Support
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
- Linux (amd64, arm64, armv7, armv6, armv5, riscv64)
- macOS (amd64, arm64)
- FreeBSD, OpenBSD, Windows (amd64)
## Pull Request Guidelines
### What Is Preferred
✅ **Good Pull Requests:**
- Focus on a single issue or feature
- Include tests for new functionality
- Maintain or improve performance
- Follow existing code style and patterns
- The commit messages follow the [conventional commit format](https://www.conventionalcommits.org/) (e.g., `feat: add new feature`, `fix: resolve bug`)
- Update documentation when necessary
### What to Avoid
❌ **Pull Requests That Cannot Be Accepted:**
- **Too many changes** - makes review difficult
- **Breaking changes** - disrupts existing functionality
- **New bugs or regressions** - reduces software quality
- **Unnecessary dependencies** - conflicts with minimalist approach
- **Performance degradation** - slows down the software
- **Poor-quality code** - hard to maintain
- **Dependent PRs** - creates review complexity
- **Radical UI changes** - disrupts user experience
- **Conflicts with philosophy** - doesn't align with minimalist approach
### Pull Request Template
When creating a pull request, please include:
- **Description:** What does this PR do?
- **Motivation:** Why is this change needed?
- **Testing:** How was this tested?
- **Breaking Changes:** Are there any breaking changes?
- **Related Issues:** Link to any related issues
## Code Style
- Follow Go conventions and best practices
- Use `gofmt` to format your Go code, and `jshint` for JavaScript
- Write clear, descriptive variable and function names
- Include comments for complex logic
- Keep functions small and focused
## Testing
### Unit Tests
- Write unit tests for new functions and methods
- Ensure tests are fast and don't require external dependencies
- Aim for good test coverage
### Integration Tests
- Add integration tests for new API endpoints
- Tests run against a real PostgreSQL database
- Ensure tests clean up after themselves
## Communication
- **Discussions:** Use GitHub Discussions for general questions and community interaction
- **Issues:** Use GitHub issues for bug reports and feature requests
- **Pull Requests:** Use PR comments for code-specific discussions
- **Philosophy Questions:** Refer to the FAQ for common questions about project direction
## Questions?
- Check the [FAQ](https://miniflux.app/faq.html) for common questions
- Review the [development documentation](https://miniflux.app/docs/development.html) and [internationalization guide](https://miniflux.app/docs/i18n.html)
- Look at existing issues and pull requests for examples
v2-2.3.0/LICENSE 0000664 0000000 0000000 00000023676 15201231005 0013053 0 ustar 00root root 0000000 0000000
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
v2-2.3.0/Makefile 0000664 0000000 0000000 00000011565 15201231005 0013500 0 ustar 00root root 0000000 0000000 APP := miniflux
DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --exact-match 2>/dev/null)
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
DOCKER_PLATFORM := amd64
export PGPASSWORD := postgres
.PHONY: \
miniflux \
miniflux-no-pie \
linux-amd64 \
linux-arm64 \
linux-armv7 \
linux-armv6 \
linux-armv5 \
linux-riscv64 \
darwin-amd64 \
darwin-arm64 \
freebsd-amd64 \
openbsd-amd64 \
build \
run \
clean \
add-string \
test \
lint \
integration-test \
clean-integration-test \
docker-image \
docker-image-distroless \
docker-images \
rpm \
debian \
debian-packages
miniflux:
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP)
miniflux-no-pie:
@ go build -ldflags=$(LD_FLAGS) -o $(APP)
linux-amd64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-arm64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv7:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv6:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
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
darwin-arm64:
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
freebsd-amd64:
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
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 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
clean:
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256
add-string:
cd internal/locale/translations && \
for file in *.json; do \
jq --indent 4 --arg key "$(KEY)" --arg val "$(VAL)" \
'. + {($$key): $$val} | to_entries | sort_by(.key) | from_entries' "$$file" > tmp && \
mv tmp "$$file"; \
done
test:
go test -cover -race -count=1 ./...
lint:
go vet ./...
test -z "$$(gofmt -l .)"
golangci-lint run
integration-test:
psql -U postgres -c 'drop database if exists miniflux_test;'
psql -U postgres -c 'create database miniflux_test;'
DATABASE_URL=$(DB_URL) \
ADMIN_USERNAME=admin \
ADMIN_PASSWORD=test123 \
CREATE_ADMIN=1 \
RUN_MIGRATIONS=1 \
LOG_LEVEL=debug \
FETCHER_ALLOW_PRIVATE_NETWORKS=1 \
INTEGRATION_ALLOW_PRIVATE_NETWORKS=1 \
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
TEST_MINIFLUX_BASE_URL=http://127.0.0.1:8080 \
TEST_MINIFLUX_ADMIN_USERNAME=admin \
TEST_MINIFLUX_ADMIN_PASSWORD=test123 \
go test -v -count=1 ./internal/api
clean-integration-test:
@ kill -9 `cat /tmp/miniflux.pid`
@ rm -f /tmp/miniflux.pid /tmp/miniflux.log
@ psql -U postgres -c 'drop database if exists miniflux_test;'
docker-image:
docker build --pull -t $(DOCKER_IMAGE):$(VERSION) -f packaging/docker/alpine/Dockerfile .
docker-image-distroless:
docker build -t $(DOCKER_IMAGE):$(VERSION) -f packaging/docker/distroless/Dockerfile .
docker-images:
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6,linux/riscv64 \
--file packaging/docker/alpine/Dockerfile \
--tag $(DOCKER_IMAGE):$(VERSION) \
--push .
rpm: clean
@ docker build \
-t miniflux-rpm-builder \
-f packaging/rpm/Dockerfile \
.
@ docker run --rm \
-v ${PWD}:/root/rpmbuild/RPMS/x86_64 miniflux-rpm-builder \
rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec
debian:
@ docker buildx build --load \
--platform linux/$(DOCKER_PLATFORM) \
-t miniflux-deb-builder \
-f packaging/debian/Dockerfile \
.
@ docker run --rm --platform linux/$(DOCKER_PLATFORM) \
-v ${PWD}:/pkg miniflux-deb-builder
debian-packages: clean
$(MAKE) debian DOCKER_PLATFORM=amd64
$(MAKE) debian DOCKER_PLATFORM=arm64
$(MAKE) debian DOCKER_PLATFORM=arm/v7
$(MAKE) debian DOCKER_PLATFORM=riscv64
v2-2.3.0/Procfile 0000664 0000000 0000000 00000000022 15201231005 0013510 0 ustar 00root root 0000000 0000000 web: miniflux.app
v2-2.3.0/README.md 0000664 0000000 0000000 00000020147 15201231005 0013313 0 ustar 00root root 0000000 0000000 Miniflux 2
==========
Miniflux is a minimalist and opinionated feed reader.
It's simple, fast, lightweight and super easy to install.
Official website:
Features
--------
### Feed Reader
- Supported feed formats: Atom 0.3/1.0, RSS 1.0/2.0, and JSON Feed 1.0/1.1.
- [OPML](https://en.wikipedia.org/wiki/OPML) file import/export and URL import.
- Supports multiple attachments (podcasts, videos, music, and images enclosures).
- Plays videos from YouTube directly inside Miniflux.
- Organizes articles using categories and bookmarks.
- Share individual articles publicly.
- Fetches website icons (favicons).
- Saves articles to third-party services.
- Provides full-text search (powered by Postgres).
- Available in 20 languages: Portuguese (Brazilian), Chinese (Simplified and Traditional), Dutch, English (US), Finnish, French, German, Greek, Hindi, Indonesian, Italian, Japanese, Polish, Romanian, Russian, Taiwanese POJ, Ukrainian, Spanish, and Turkish.
### Privacy and Security
- Removes pixel trackers.
- Strips tracking parameters from URLs (e.g., `utm_source`, `utm_medium`, `utm_campaign`, `fbclid`, etc.).
- Retrieves original links when feeds are sourced from FeedBurner.
- Opens external links with attributes `rel="noopener noreferrer" referrerpolicy="no-referrer"` for improved security.
- Implements the HTTP header `Referrer-Policy: no-referrer` to prevent referrer leakage.
- Provides a media proxy to avoid tracking and resolve mixed content warnings when using HTTPS.
- Plays YouTube videos via the privacy-focused domain `youtube-nocookie.com`.
- Supports alternative YouTube video players such as [Invidious](https://invidio.us).
- Blocks external JavaScript to prevent tracking and enhance security.
- Sanitizes external content before rendering it.
- Enforces a [Content Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) and a [Trusted Types Policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) to only application JavaScript and blocks inline scripts and styles.
### Bot Protection Bypass Mechanisms
- Optionally disable HTTP/2 to mitigate fingerprinting.
- Allows configuration of a custom user agent.
- Supports adding custom cookies for specific use cases.
- Enables the use of proxies for enhanced privacy or bypassing restrictions.
### Content Manipulation
- Fetches the original article and extracts only the relevant content using a local Readability parser.
- Allows custom scraper rules based on CSS selectors.
- Supports custom rewriting rules for content manipulation.
- Provides a regex filter to include or exclude articles based on specific patterns.
- Optionally permits self-signed or invalid certificates (disabled by default).
- Scrapes YouTube's website to retrieve video duration as read time or uses the YouTube API (disabled by default).
### User Interface
- Optimized stylesheet for readability.
- Responsive design that adapts seamlessly to desktop, tablet, and mobile devices.
- Minimalistic and distraction-free user interface.
- No requirement to download an app from Apple App Store or Google Play Store.
- Can be added directly to the home screen for quick access.
- Supports a wide range of keyboard shortcuts for efficient navigation.
- Optional touch gesture support for navigation on mobile devices.
- Custom stylesheets and JavaScript to personalize the user interface to your preferences.
- Themes:
- Light (Sans-Serif)
- Light (Serif)
- Dark (Sans-Serif)
- Dark (Serif)
- System (Sans-Serif) – Automatically switches between Dark and Light themes based on system preferences.
- System (Serif)
### Integrations
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkTaco](https://linktaco.com), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
- Bookmarklet for subscribing to websites directly from any web browser.
- Webhooks for real-time notifications or custom integrations.
- Compatibility with existing mobile applications using the Fever or Google Reader API.
- REST API with client libraries available in [Go](https://github.com/miniflux/v2/tree/main/client) and [Python](https://github.com/miniflux/python-client).
### Authentication
- Local username and password.
- Passkeys ([WebAuthn](https://en.wikipedia.org/wiki/WebAuthn)).
- Google (OAuth2).
- Generic OpenID Connect.
- Reverse-Proxy authentication.
### Technical Stuff
- Written in [Go (Golang)](https://golang.org/).
- Single binary compiled statically without dependency.
- Works only with [PostgreSQL](https://www.postgresql.org/).
- Does not use any ORM or any complicated frameworks.
- Uses modern vanilla JavaScript only when necessary.
- All static files are bundled into the application binary using the Go `embed` package.
- Supports the Systemd `sd_notify` protocol for process monitoring.
- Configures HTTPS automatically with Let's Encrypt.
- Allows the use of custom SSL certificates.
- Supports [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2) when TLS is enabled.
- Updates feeds in the background using an internal scheduler or a traditional cron job.
- Uses native lazy loading for images and iframes.
- 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 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.
- Respects/sends Last-Modified, If-Modified-Since, If-None-Match, Cache-Control, Expires and ETags headers, and has a default polling interval of 1h.
Documentation
-------------
The Miniflux documentation is available here: ([Man page](https://miniflux.app/miniflux.1.html))
- [Opinionated?](https://miniflux.app/opinionated.html)
- [Features](https://miniflux.app/features.html)
- [Requirements](https://miniflux.app/docs/requirements.html)
- [Installation Instructions](https://miniflux.app/docs/installation.html)
- [Upgrading to a New Version](https://miniflux.app/docs/upgrade.html)
- [Configuration](https://miniflux.app/docs/configuration.html)
- [Command Line Usage](https://miniflux.app/docs/cli.html)
- [User Interface Usage](https://miniflux.app/docs/ui.html)
- [Keyboard Shortcuts](https://miniflux.app/docs/keyboard_shortcuts.html)
- [Integration with External Services](https://miniflux.app/docs/#integrations)
- [Rewrite and Scraper Rules](https://miniflux.app/docs/rules.html)
- [API Reference](https://miniflux.app/docs/api.html)
- [Development](https://miniflux.app/docs/development.html)
- [Internationalization](https://miniflux.app/docs/i18n.html)
- [Frequently Asked Questions](https://miniflux.app/faq.html)
Screenshots
-----------
Default theme:

Dark theme when using keyboard navigation:

Credits
-------
- Authors: Frédéric Guillot - [List of contributors](https://github.com/miniflux/v2/graphs/contributors)
- Distributed under Apache 2.0 License
v2-2.3.0/SECURITY.md 0000664 0000000 0000000 00000001014 15201231005 0013615 0 ustar 00root root 0000000 0000000 # Security Policy
## Supported Versions
Only the latest stable version is supported.
## Reporting a Vulnerability
Preferably, [report the vulnerability privately using GitHub](https://github.com/miniflux/v2/security/advisories/new) ([documentation](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability)).
If you do not want to use GitHub, send an email to `security AT miniflux DOT net` with all the steps to reproduce the problem.
v2-2.3.0/client/ 0000775 0000000 0000000 00000000000 15201231005 0013306 5 ustar 00root root 0000000 0000000 v2-2.3.0/client/README.md 0000664 0000000 0000000 00000002131 15201231005 0014562 0 ustar 00root root 0000000 0000000 Miniflux API Client
===================
[](https://pkg.go.dev/miniflux.app/v2/client)
Go client for the Miniflux REST API. It supports API tokens or basic authentication and mirrors the server endpoints closely.
Installation
------------
```bash
go get -u miniflux.app/v2/client
```
Example
-------
```go
package main
import (
"fmt"
"os"
miniflux "miniflux.app/v2/client"
)
func main() {
// Authentication with username/password:
client := miniflux.NewClient("https://api.example.org", "admin", "secret")
// Authentication with an API Key:
client := miniflux.NewClient("https://api.example.org", "my-secret-token")
// Fetch all feeds.
feeds, err := client.Feeds()
if err != nil {
fmt.Println(err)
return
}
fmt.Println(feeds)
// Backup your feeds to an OPML file.
opml, err := client.Export()
if err != nil {
fmt.Println(err)
return
}
err = os.WriteFile("opml.xml", opml, 0644)
if err != nil {
fmt.Println(err)
return
}
}
```
v2-2.3.0/client/client.go 0000664 0000000 0000000 00000105736 15201231005 0015127 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client // import "miniflux.app/v2/client"
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
// Client holds API procedure calls.
type Client struct {
request *request
}
// New returns a new Miniflux client.
//
// Deprecated: use NewClient instead.
//
//go:fix inline
func New(endpoint string, credentials ...string) *Client {
return NewClient(endpoint, credentials...)
}
// NewClient returns a new Miniflux client.
func NewClient(endpoint string, credentials ...string) *Client {
switch len(credentials) {
case 2:
return NewClientWithOptions(endpoint, WithCredentials(credentials[0], credentials[1]))
case 1:
return NewClientWithOptions(endpoint, WithAPIKey(credentials[0]))
default:
return NewClientWithOptions(endpoint)
}
}
// NewClientWithOptions returns a new Miniflux client with options.
func NewClientWithOptions(endpoint string, options ...Option) *Client {
// Trim trailing slashes and /v1 from the endpoint.
endpoint = strings.TrimSuffix(endpoint, "/")
endpoint = strings.TrimSuffix(endpoint, "/v1")
request := &request{endpoint: endpoint, client: http.DefaultClient}
for _, option := range options {
option(request)
}
return &Client{request: request}
}
func withDefaultTimeout() (context.Context, func()) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
return ctx, cancel
}
// Healthcheck checks if the application is up and running.
func (c *Client) Healthcheck() error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.HealthcheckContext(ctx)
}
// HealthcheckContext checks if the application is up and running.
func (c *Client) HealthcheckContext(ctx context.Context) error {
body, err := c.request.Get(ctx, "/healthcheck")
if err != nil {
return fmt.Errorf("miniflux: unable to perform healthcheck: %w", err)
}
defer body.Close()
responseBodyContent, err := io.ReadAll(body)
if err != nil {
return fmt.Errorf("miniflux: unable to read healthcheck response: %w", err)
}
if string(responseBodyContent) != "OK" {
return fmt.Errorf("miniflux: invalid healthcheck response: %q", responseBodyContent)
}
return nil
}
// Version returns the version of the Miniflux instance.
func (c *Client) Version() (*VersionResponse, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.VersionContext(ctx)
}
// VersionContext returns the version of the Miniflux instance.
func (c *Client) VersionContext(ctx context.Context) (*VersionResponse, error) {
body, err := c.request.Get(ctx, "/v1/version")
if err != nil {
return nil, err
}
defer body.Close()
var versionResponse *VersionResponse
if err := json.NewDecoder(body).Decode(&versionResponse); err != nil {
return nil, fmt.Errorf("miniflux: json error (%v)", err)
}
return versionResponse, nil
}
// Me returns the logged user information.
func (c *Client) Me() (*User, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.MeContext(ctx)
}
// MeContext returns the logged user information.
func (c *Client) MeContext(ctx context.Context) (*User, error) {
body, err := c.request.Get(ctx, "/v1/me")
if err != nil {
return nil, err
}
defer body.Close()
var user *User
if err := json.NewDecoder(body).Decode(&user); err != nil {
return nil, fmt.Errorf("miniflux: json error (%v)", err)
}
return user, nil
}
// Users returns all users.
func (c *Client) Users() (Users, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UsersContext(ctx)
}
// UsersContext returns all users.
func (c *Client) UsersContext(ctx context.Context) (Users, error) {
body, err := c.request.Get(ctx, "/v1/users")
if err != nil {
return nil, err
}
defer body.Close()
var users Users
if err := json.NewDecoder(body).Decode(&users); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return users, nil
}
// UserByID returns a single user.
func (c *Client) UserByID(userID int64) (*User, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UserByIDContext(ctx, userID)
}
// UserByIDContext returns a single user.
func (c *Client) UserByIDContext(ctx context.Context, userID int64) (*User, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/users/%d", userID))
if err != nil {
return nil, err
}
defer body.Close()
var user User
if err := json.NewDecoder(body).Decode(&user); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &user, nil
}
// UserByUsername returns a single user.
func (c *Client) UserByUsername(username string) (*User, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UserByUsernameContext(ctx, username)
}
// UserByUsernameContext returns a single user.
func (c *Client) UserByUsernameContext(ctx context.Context, username string) (*User, error) {
body, err := c.request.Get(ctx, "/v1/users/"+username)
if err != nil {
return nil, err
}
defer body.Close()
var user User
if err := json.NewDecoder(body).Decode(&user); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &user, nil
}
// CreateUser creates a new user in the system.
func (c *Client) CreateUser(username, password string, isAdmin bool) (*User, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateUserContext(ctx, username, password, isAdmin)
}
// CreateUserContext creates a new user in the system.
func (c *Client) CreateUserContext(ctx context.Context, username, password string, isAdmin bool) (*User, error) {
body, err := c.request.Post(ctx, "/v1/users", &UserCreationRequest{
Username: username,
Password: password,
IsAdmin: isAdmin,
})
if err != nil {
return nil, err
}
defer body.Close()
var user *User
if err := json.NewDecoder(body).Decode(&user); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return user, nil
}
// UpdateUser updates a user in the system.
func (c *Client) UpdateUser(userID int64, userChanges *UserModificationRequest) (*User, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateUserContext(ctx, userID, userChanges)
}
// UpdateUserContext updates a user in the system.
func (c *Client) UpdateUserContext(ctx context.Context, userID int64, userChanges *UserModificationRequest) (*User, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/users/%d", userID), userChanges)
if err != nil {
return nil, err
}
defer body.Close()
var u *User
if err := json.NewDecoder(body).Decode(&u); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return u, nil
}
// DeleteUser removes a user from the system.
func (c *Client) DeleteUser(userID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DeleteUserContext(ctx, userID)
}
// DeleteUserContext removes a user from the system.
func (c *Client) DeleteUserContext(ctx context.Context, userID int64) error {
return c.request.Delete(ctx, fmt.Sprintf("/v1/users/%d", userID))
}
// APIKeys returns all API keys for the authenticated user.
func (c *Client) APIKeys() (APIKeys, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.APIKeysContext(ctx)
}
// APIKeysContext returns all API keys for the authenticated user.
func (c *Client) APIKeysContext(ctx context.Context) (APIKeys, error) {
body, err := c.request.Get(ctx, "/v1/api-keys")
if err != nil {
return nil, err
}
defer body.Close()
var apiKeys APIKeys
if err := json.NewDecoder(body).Decode(&apiKeys); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return apiKeys, nil
}
// CreateAPIKey creates a new API key for the authenticated user.
func (c *Client) CreateAPIKey(description string) (*APIKey, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateAPIKeyContext(ctx, description)
}
// CreateAPIKeyContext creates a new API key for the authenticated user.
func (c *Client) CreateAPIKeyContext(ctx context.Context, description string) (*APIKey, error) {
body, err := c.request.Post(ctx, "/v1/api-keys", &APIKeyCreationRequest{
Description: description,
})
if err != nil {
return nil, err
}
defer body.Close()
var apiKey *APIKey
if err := json.NewDecoder(body).Decode(&apiKey); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return apiKey, nil
}
// DeleteAPIKey removes an API key for the authenticated user.
func (c *Client) DeleteAPIKey(apiKeyID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DeleteAPIKeyContext(ctx, apiKeyID)
}
// DeleteAPIKeyContext removes an API key for the authenticated user.
func (c *Client) DeleteAPIKeyContext(ctx context.Context, apiKeyID int64) error {
return c.request.Delete(ctx, fmt.Sprintf("/v1/api-keys/%d", apiKeyID))
}
// MarkAllAsRead marks all unread entries as read for a given user.
func (c *Client) MarkAllAsRead(userID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.MarkAllAsReadContext(ctx, userID)
}
// MarkAllAsReadContext marks all unread entries as read for a given user.
func (c *Client) MarkAllAsReadContext(ctx context.Context, userID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
return err
}
// IntegrationsStatus fetches the integrations status for the signed-in user.
func (c *Client) IntegrationsStatus() (bool, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.IntegrationsStatusContext(ctx)
}
// IntegrationsStatusContext fetches the integrations status for the signed-in user.
func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
body, err := c.request.Get(ctx, "/v1/integrations/status")
if err != nil {
return false, err
}
defer body.Close()
var response struct {
HasIntegrations bool `json:"has_integrations"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return false, fmt.Errorf("miniflux: response error (%v)", err)
}
return response.HasIntegrations, nil
}
// Discover tries to find subscriptions on a website.
func (c *Client) Discover(url string) (Subscriptions, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DiscoverContext(ctx, url)
}
// DiscoverContext tries to find subscriptions from a website.
func (c *Client) DiscoverContext(ctx context.Context, url string) (Subscriptions, error) {
body, err := c.request.Post(ctx, "/v1/discover", map[string]string{"url": url})
if err != nil {
return nil, err
}
defer body.Close()
var subscriptions Subscriptions
if err := json.NewDecoder(body).Decode(&subscriptions); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return subscriptions, nil
}
// Categories retrieves the list of categories.
func (c *Client) Categories() (Categories, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoriesContext(ctx)
}
// CategoriesContext retrieves the list of categories.
func (c *Client) CategoriesContext(ctx context.Context) (Categories, error) {
body, err := c.request.Get(ctx, "/v1/categories")
if err != nil {
return nil, err
}
defer body.Close()
var categories Categories
if err := json.NewDecoder(body).Decode(&categories); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return categories, nil
}
// CategoriesWithCounters fetches the categories with their respective feed and unread counts.
func (c *Client) CategoriesWithCounters() (Categories, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoriesWithCountersContext(ctx)
}
// CategoriesWithCountersContext fetches the categories with their respective feed and unread counts.
func (c *Client) CategoriesWithCountersContext(ctx context.Context) (Categories, error) {
body, err := c.request.Get(ctx, "/v1/categories?counts=true")
if err != nil {
return nil, err
}
defer body.Close()
var categories Categories
if err := json.NewDecoder(body).Decode(&categories); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return categories, nil
}
// CreateCategory creates a new category.
func (c *Client) CreateCategory(title string) (*Category, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateCategoryContext(ctx, title)
}
// CreateCategoryContext creates a new category.
func (c *Client) CreateCategoryContext(ctx context.Context, title string) (*Category, error) {
body, err := c.request.Post(ctx, "/v1/categories", &CategoryCreationRequest{
Title: title,
})
if err != nil {
return nil, err
}
defer body.Close()
var category *Category
if err := json.NewDecoder(body).Decode(&category); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return category, nil
}
// CreateCategoryWithOptions creates a new category with options.
func (c *Client) CreateCategoryWithOptions(createRequest *CategoryCreationRequest) (*Category, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateCategoryWithOptionsContext(ctx, createRequest)
}
// CreateCategoryWithOptionsContext creates a new category with options.
func (c *Client) CreateCategoryWithOptionsContext(ctx context.Context, createRequest *CategoryCreationRequest) (*Category, error) {
body, err := c.request.Post(ctx, "/v1/categories", createRequest)
if err != nil {
return nil, err
}
defer body.Close()
var category *Category
if err := json.NewDecoder(body).Decode(&category); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return category, nil
}
// UpdateCategory updates a category.
func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateCategoryContext(ctx, categoryID, title)
}
// UpdateCategoryContext updates a category.
func (c *Client) UpdateCategoryContext(ctx context.Context, categoryID int64, title string) (*Category, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
Title: new(title),
})
if err != nil {
return nil, err
}
defer body.Close()
var category *Category
if err := json.NewDecoder(body).Decode(&category); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return category, nil
}
// UpdateCategoryWithOptions updates a category with options.
func (c *Client) UpdateCategoryWithOptions(categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateCategoryWithOptionsContext(ctx, categoryID, categoryChanges)
}
// UpdateCategoryWithOptionsContext updates a category with options.
func (c *Client) UpdateCategoryWithOptionsContext(ctx context.Context, categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), categoryChanges)
if err != nil {
return nil, err
}
defer body.Close()
var category *Category
if err := json.NewDecoder(body).Decode(&category); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return category, nil
}
// MarkCategoryAsRead marks all unread entries in a category as read.
func (c *Client) MarkCategoryAsRead(categoryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.MarkCategoryAsReadContext(ctx, categoryID)
}
// MarkCategoryAsReadContext marks all unread entries in a category as read.
func (c *Client) MarkCategoryAsReadContext(ctx context.Context, categoryID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d/mark-all-as-read", categoryID), nil)
return err
}
// CategoryFeeds returns all feeds for a category.
func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoryFeedsContext(ctx, categoryID)
}
// CategoryFeedsContext returns all feeds for a category.
func (c *Client) CategoryFeedsContext(ctx context.Context, categoryID int64) (Feeds, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
if err != nil {
return nil, err
}
defer body.Close()
var feeds Feeds
if err := json.NewDecoder(body).Decode(&feeds); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return feeds, nil
}
// DeleteCategory removes a category.
func (c *Client) DeleteCategory(categoryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DeleteCategoryContext(ctx, categoryID)
}
// DeleteCategoryContext removes a category.
func (c *Client) DeleteCategoryContext(ctx context.Context, categoryID int64) error {
return c.request.Delete(ctx, fmt.Sprintf("/v1/categories/%d", categoryID))
}
// RefreshCategory refreshes a category.
func (c *Client) RefreshCategory(categoryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.RefreshCategoryContext(ctx, categoryID)
}
// RefreshCategoryContext refreshes a category.
func (c *Client) RefreshCategoryContext(ctx context.Context, categoryID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d/refresh", categoryID), nil)
return err
}
// Feeds gets all feeds.
func (c *Client) Feeds() (Feeds, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedsContext(ctx)
}
// FeedsContext gets all feeds.
func (c *Client) FeedsContext(ctx context.Context) (Feeds, error) {
body, err := c.request.Get(ctx, "/v1/feeds")
if err != nil {
return nil, err
}
defer body.Close()
var feeds Feeds
if err := json.NewDecoder(body).Decode(&feeds); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return feeds, nil
}
// Export exports subscriptions as an OPML document.
func (c *Client) Export() ([]byte, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.ExportContext(ctx)
}
// ExportContext exports subscriptions as an OPML document.
func (c *Client) ExportContext(ctx context.Context) ([]byte, error) {
body, err := c.request.Get(ctx, "/v1/export")
if err != nil {
return nil, err
}
defer body.Close()
opml, err := io.ReadAll(body)
if err != nil {
return nil, err
}
return opml, nil
}
// Import imports an OPML file.
func (c *Client) Import(f io.ReadCloser) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.ImportContext(ctx, f)
}
// ImportContext imports an OPML file.
func (c *Client) ImportContext(ctx context.Context, f io.ReadCloser) error {
_, err := c.request.PostFile(ctx, "/v1/import", f)
return err
}
// Feed gets a feed.
func (c *Client) Feed(feedID int64) (*Feed, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedContext(ctx, feedID)
}
// FeedContext gets a feed.
func (c *Client) FeedContext(ctx context.Context, feedID int64) (*Feed, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d", feedID))
if err != nil {
return nil, err
}
defer body.Close()
var feed *Feed
if err := json.NewDecoder(body).Decode(&feed); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return feed, nil
}
// CreateFeed creates a new feed.
func (c *Client) CreateFeed(feedCreationRequest *FeedCreationRequest) (int64, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CreateFeedContext(ctx, feedCreationRequest)
}
// CreateFeedContext creates a new feed.
func (c *Client) CreateFeedContext(ctx context.Context, feedCreationRequest *FeedCreationRequest) (int64, error) {
body, err := c.request.Post(ctx, "/v1/feeds", feedCreationRequest)
if err != nil {
return 0, err
}
defer body.Close()
type result struct {
FeedID int64 `json:"feed_id"`
}
var r result
if err := json.NewDecoder(body).Decode(&r); err != nil {
return 0, fmt.Errorf("miniflux: response error (%v)", err)
}
return r.FeedID, nil
}
// UpdateFeed updates a feed.
func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateFeedContext(ctx, feedID, feedChanges)
}
// UpdateFeedContext updates a feed.
func (c *Client) UpdateFeedContext(ctx context.Context, feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d", feedID), feedChanges)
if err != nil {
return nil, err
}
defer body.Close()
var f *Feed
if err := json.NewDecoder(body).Decode(&f); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return f, nil
}
// ImportFeedEntry imports a single entry into a feed.
func (c *Client) ImportFeedEntry(feedID int64, payload any) (int64, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
body, err := c.request.Post(
ctx,
fmt.Sprintf("/v1/feeds/%d/entries/import", feedID),
payload,
)
if err != nil {
return 0, err
}
defer body.Close()
var response struct {
ID int64 `json:"id"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return 0, fmt.Errorf("miniflux: json error (%v)", err)
}
return response.ID, nil
}
// MarkFeedAsRead marks all unread entries of the feed as read.
func (c *Client) MarkFeedAsRead(feedID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.MarkFeedAsReadContext(ctx, feedID)
}
// MarkFeedAsReadContext marks all unread entries of the feed as read.
func (c *Client) MarkFeedAsReadContext(ctx context.Context, feedID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d/mark-all-as-read", feedID), nil)
return err
}
// RefreshAllFeeds refreshes all feeds.
func (c *Client) RefreshAllFeeds() error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.RefreshAllFeedsContext(ctx)
}
// RefreshAllFeedsContext refreshes all feeds.
func (c *Client) RefreshAllFeedsContext(ctx context.Context) error {
_, err := c.request.Put(ctx, "/v1/feeds/refresh", nil)
return err
}
// RefreshFeed refreshes a feed.
func (c *Client) RefreshFeed(feedID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.RefreshFeedContext(ctx, feedID)
}
// RefreshFeedContext refreshes a feed.
func (c *Client) RefreshFeedContext(ctx context.Context, feedID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
return err
}
// DeleteFeed removes a feed.
func (c *Client) DeleteFeed(feedID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.DeleteFeedContext(ctx, feedID)
}
// DeleteFeedContext removes a feed.
func (c *Client) DeleteFeedContext(ctx context.Context, feedID int64) error {
return c.request.Delete(ctx, fmt.Sprintf("/v1/feeds/%d", feedID))
}
// FeedIcon gets a feed icon.
func (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedIconContext(ctx, feedID)
}
// FeedIconContext gets a feed icon.
func (c *Client) FeedIconContext(ctx context.Context, feedID int64) (*FeedIcon, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d/icon", feedID))
if err != nil {
return nil, err
}
defer body.Close()
var feedIcon *FeedIcon
if err := json.NewDecoder(body).Decode(&feedIcon); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return feedIcon, nil
}
// FeedEntry gets a single feed entry.
func (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedEntryContext(ctx, feedID, entryID)
}
// FeedEntryContext gets a single feed entry.
func (c *Client) FeedEntryContext(ctx context.Context, feedID, entryID int64) (*Entry, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d/entries/%d", feedID, entryID))
if err != nil {
return nil, err
}
defer body.Close()
var entry *Entry
if err := json.NewDecoder(body).Decode(&entry); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return entry, nil
}
// CategoryEntry gets a single category entry.
func (c *Client) CategoryEntry(categoryID, entryID int64) (*Entry, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoryEntryContext(ctx, categoryID, entryID)
}
// CategoryEntryContext gets a single category entry.
func (c *Client) CategoryEntryContext(ctx context.Context, categoryID, entryID int64) (*Entry, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/entries/%d", categoryID, entryID))
if err != nil {
return nil, err
}
defer body.Close()
var entry *Entry
if err := json.NewDecoder(body).Decode(&entry); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return entry, nil
}
// Entry gets a single entry.
func (c *Client) Entry(entryID int64) (*Entry, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.EntryContext(ctx, entryID)
}
// EntryContext gets a single entry.
func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/entries/%d", entryID))
if err != nil {
return nil, err
}
defer body.Close()
var entry *Entry
if err := json.NewDecoder(body).Decode(&entry); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return entry, nil
}
// Entries fetches entries using the given filter.
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.EntriesContext(ctx, filter)
}
// EntriesContext fetches entries.
func (c *Client) EntriesContext(ctx context.Context, filter *Filter) (*EntryResultSet, error) {
path := buildFilterQueryString("/v1/entries", filter)
body, err := c.request.Get(ctx, path)
if err != nil {
return nil, err
}
defer body.Close()
var result EntryResultSet
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &result, nil
}
// FeedEntries fetches entries for a feed using the given filter.
func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FeedEntriesContext(ctx, feedID, filter)
}
// FeedEntriesContext fetches feed entries.
func (c *Client) FeedEntriesContext(ctx context.Context, feedID int64, filter *Filter) (*EntryResultSet, error) {
path := buildFilterQueryString(fmt.Sprintf("/v1/feeds/%d/entries", feedID), filter)
body, err := c.request.Get(ctx, path)
if err != nil {
return nil, err
}
defer body.Close()
var result EntryResultSet
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &result, nil
}
// CategoryEntries fetches entries for a category using the given filter.
func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.CategoryEntriesContext(ctx, categoryID, filter)
}
// CategoryEntriesContext fetches category entries.
func (c *Client) CategoryEntriesContext(ctx context.Context, categoryID int64, filter *Filter) (*EntryResultSet, error) {
path := buildFilterQueryString(fmt.Sprintf("/v1/categories/%d/entries", categoryID), filter)
body, err := c.request.Get(ctx, path)
if err != nil {
return nil, err
}
defer body.Close()
var result EntryResultSet
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &result, nil
}
// UpdateEntries updates the status of a list of entries.
func (c *Client) UpdateEntries(entryIDs []int64, status string) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateEntriesContext(ctx, entryIDs, status)
}
// UpdateEntriesContext updates the status of a list of entries.
func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, status string) error {
type payload struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
}
_, err := c.request.Put(ctx, "/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
return err
}
// UpdateEntry updates an entry.
func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateEntryContext(ctx, entryID, entryChanges)
}
// UpdateEntryContext updates an entry.
func (c *Client) UpdateEntryContext(ctx context.Context, entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/entries/%d", entryID), entryChanges)
if err != nil {
return nil, err
}
defer body.Close()
var entry *Entry
if err := json.NewDecoder(body).Decode(&entry); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return entry, nil
}
// ToggleStarred toggles the starred flag of an entry.
func (c *Client) ToggleStarred(entryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.ToggleStarredContext(ctx, entryID)
}
// ToggleStarredContext toggles entry starred value.
func (c *Client) ToggleStarredContext(ctx context.Context, entryID int64) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/entries/%d/star", entryID), nil)
return err
}
// SaveEntry sends an entry to a third-party service.
func (c *Client) SaveEntry(entryID int64) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.SaveEntryContext(ctx, entryID)
}
// SaveEntryContext sends an entry to a third-party service.
func (c *Client) SaveEntryContext(ctx context.Context, entryID int64) error {
_, err := c.request.Post(ctx, fmt.Sprintf("/v1/entries/%d/save", entryID), nil)
return err
}
// FetchEntryOriginalContent fetches the original content of an entry using the scraper.
func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FetchEntryOriginalContentContext(ctx, entryID)
}
// FetchEntryOriginalContentContext fetches the original content of an entry using the scraper.
func (c *Client) FetchEntryOriginalContentContext(ctx context.Context, entryID int64) (string, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
if err != nil {
return "", err
}
defer body.Close()
var response struct {
Content string `json:"content"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return "", fmt.Errorf("miniflux: response error (%v)", err)
}
return response.Content, nil
}
// FetchCounters fetches feed counters.
func (c *Client) FetchCounters() (*FeedCounters, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FetchCountersContext(ctx)
}
// FetchCountersContext fetches feed counters.
func (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error) {
body, err := c.request.Get(ctx, "/v1/feeds/counters")
if err != nil {
return nil, err
}
defer body.Close()
var result FeedCounters
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return &result, nil
}
// FlushHistory deletes all entries with the status "read".
func (c *Client) FlushHistory() error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.FlushHistoryContext(ctx)
}
// FlushHistoryContext deletes all entries with the status "read".
func (c *Client) FlushHistoryContext(ctx context.Context) error {
_, err := c.request.Put(ctx, "/v1/flush-history", nil)
return err
}
// Icon fetches a feed icon.
func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.IconContext(ctx, iconID)
}
// IconContext fetches a feed icon.
func (c *Client) IconContext(ctx context.Context, iconID int64) (*FeedIcon, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/icons/%d", iconID))
if err != nil {
return nil, err
}
defer body.Close()
var feedIcon *FeedIcon
if err := json.NewDecoder(body).Decode(&feedIcon); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return feedIcon, nil
}
// Enclosure fetches a specific enclosure.
func (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.EnclosureContext(ctx, enclosureID)
}
// EnclosureContext fetches a specific enclosure.
func (c *Client) EnclosureContext(ctx context.Context, enclosureID int64) (*Enclosure, error) {
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/enclosures/%d", enclosureID))
if err != nil {
return nil, err
}
defer body.Close()
var enclosure *Enclosure
if err := json.NewDecoder(body).Decode(&enclosure); err != nil {
return nil, fmt.Errorf("miniflux: response error(%v)", err)
}
return enclosure, nil
}
// UpdateEnclosure updates an enclosure.
func (c *Client) UpdateEnclosure(enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UpdateEnclosureContext(ctx, enclosureID, enclosureUpdate)
}
// UpdateEnclosureContext updates an enclosure.
func (c *Client) UpdateEnclosureContext(ctx context.Context, enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/enclosures/%d", enclosureID), enclosureUpdate)
return err
}
func buildFilterQueryString(path string, filter *Filter) string {
if filter != nil {
values := url.Values{}
if filter.Status != "" {
values.Set("status", filter.Status)
}
if filter.Direction != "" {
values.Set("direction", filter.Direction)
}
if filter.Order != "" {
values.Set("order", filter.Order)
}
if filter.Limit >= 0 {
values.Set("limit", strconv.Itoa(filter.Limit))
}
if filter.Offset >= 0 {
values.Set("offset", strconv.Itoa(filter.Offset))
}
if filter.After > 0 {
values.Set("after", strconv.FormatInt(filter.After, 10))
}
if filter.Before > 0 {
values.Set("before", strconv.FormatInt(filter.Before, 10))
}
if filter.PublishedAfter > 0 {
values.Set("published_after", strconv.FormatInt(filter.PublishedAfter, 10))
}
if filter.PublishedBefore > 0 {
values.Set("published_before", strconv.FormatInt(filter.PublishedBefore, 10))
}
if filter.ChangedAfter > 0 {
values.Set("changed_after", strconv.FormatInt(filter.ChangedAfter, 10))
}
if filter.ChangedBefore > 0 {
values.Set("changed_before", strconv.FormatInt(filter.ChangedBefore, 10))
}
if filter.AfterEntryID > 0 {
values.Set("after_entry_id", strconv.FormatInt(filter.AfterEntryID, 10))
}
if filter.BeforeEntryID > 0 {
values.Set("before_entry_id", strconv.FormatInt(filter.BeforeEntryID, 10))
}
if filter.Starred != "" {
values.Set("starred", filter.Starred)
}
if filter.Search != "" {
values.Set("search", filter.Search)
}
if filter.CategoryID > 0 {
values.Set("category_id", strconv.FormatInt(filter.CategoryID, 10))
}
if filter.FeedID > 0 {
values.Set("feed_id", strconv.FormatInt(filter.FeedID, 10))
}
if filter.GloballyVisible {
values.Set("globally_visible", "true")
}
for _, status := range filter.Statuses {
values.Add("status", status)
}
path = fmt.Sprintf("%s?%s", path, values.Encode())
}
return path
}
v2-2.3.0/client/client_test.go 0000664 0000000 0000000 00000110263 15201231005 0016155 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client
import (
"bytes"
"encoding/json"
"io"
"net/http"
"reflect"
"testing"
"time"
)
type roundTripperFunc func(req *http.Request) (*http.Response, error)
func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func newFakeHTTPClient(
t *testing.T,
fn func(t *testing.T, req *http.Request) *http.Response,
) *http.Client {
return &http.Client{
Transport: roundTripperFunc(
func(req *http.Request) (*http.Response, error) {
return fn(t, req), nil
}),
}
}
func jsonResponseFrom(
t *testing.T,
status int,
headers http.Header,
body any,
) *http.Response {
data, err := json.Marshal(body)
if err != nil {
t.Fatalf("Unable to marshal body: %v", err)
}
return &http.Response{
StatusCode: status,
Body: io.NopCloser(bytes.NewBuffer(data)),
Header: headers,
}
}
func asJSON(data any) string {
json, err := json.MarshalIndent(data, "", " ")
if err != nil {
panic(err)
}
return string(json)
}
func expectRequest(
t *testing.T,
method string,
url string,
checkBody func(r io.Reader),
req *http.Request,
) {
if req.Method != method {
t.Fatalf("Expected method to be %s, got %s", method, req.Method)
}
if req.URL.String() != url {
t.Fatalf("Expected URL path to be %s, got %s", url, req.URL)
}
if checkBody != nil {
checkBody(req.Body)
}
}
func expectFromJSON[T any](
t *testing.T,
r io.Reader,
expected *T,
) {
var got T
if err := json.NewDecoder(r).Decode(&got); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(&got, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(got))
}
}
func TestHealthcheck(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/healthcheck", nil, req)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString("OK")),
}
})))
if err := client.HealthcheckContext(t.Context()); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestVersion(t *testing.T) {
expected := &VersionResponse{
Version: "1.0.0",
Commit: "1234567890",
BuildDate: "2021-01-01T00:00:00Z",
GoVersion: "go1.20",
Compiler: "gc",
Arch: "amd64",
OS: "linux",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/version", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.VersionContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestMe(t *testing.T) {
expected := &User{
ID: 1,
Username: "test",
Password: "password",
IsAdmin: false,
Theme: "light",
Language: "en",
Timezone: "UTC",
EntryDirection: "asc",
EntryOrder: "created_at",
Stylesheet: "default",
CustomJS: "custom.js",
GoogleID: "google-id",
OpenIDConnectID: "openid-connect-id",
EntriesPerPage: 10,
KeyboardShortcuts: true,
ShowReadingTime: true,
EntrySwipe: true,
GestureNav: "horizontal",
DisplayMode: "read",
DefaultReadingSpeed: 1,
CJKReadingSpeed: 1,
DefaultHomePage: "home",
CategoriesSortingOrder: "asc",
MarkReadOnView: true,
MediaPlaybackRate: 1.0,
BlockFilterEntryRules: "block",
KeepFilterEntryRules: "keep",
ExternalFontHosts: "https://fonts.googleapis.com",
AlwaysOpenExternalLinks: true,
OpenExternalLinksInNewTab: true,
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/me", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.MeContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestUsers(t *testing.T) {
expected := Users{
{
ID: 1,
Username: "test1",
},
{
ID: 2,
Username: "test2",
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/users", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UsersContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestUserByID(t *testing.T) {
expected := &User{
ID: 1,
Username: "test",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/users/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UserByIDContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestUserByUsername(t *testing.T) {
expected := &User{
ID: 1,
Username: "test",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/users/test", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UserByUsernameContext(t.Context(), "test")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestCreateUser(t *testing.T) {
expected := &User{
ID: 1,
Username: "test",
Password: "password",
IsAdmin: true,
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
exp := UserCreationRequest{
Username: "test",
Password: "password",
IsAdmin: true,
}
expectRequest(
t,
http.MethodPost,
"http://mf/v1/users",
func(r io.Reader) {
expectFromJSON(t, r, &exp)
},
req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CreateUserContext(t.Context(), "test", "password", true)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestUpdateUser(t *testing.T) {
expected := &User{
ID: 1,
Username: "test",
Password: "password",
IsAdmin: true,
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/users/1", func(r io.Reader) {
expectFromJSON(t, r, &UserModificationRequest{
Username: &expected.Username,
Password: &expected.Password,
})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UpdateUserContext(t.Context(), 1, &UserModificationRequest{
Username: &expected.Username,
Password: &expected.Password,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestDeleteUser(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodDelete, "http://mf/v1/users/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.DeleteUserContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestAPIKeys(t *testing.T) {
expected := APIKeys{
{
ID: 1,
Token: "token",
Description: "test",
},
{
ID: 2,
Token: "token2",
Description: "test2",
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/api-keys", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.APIKeysContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestCreateAPIKey(t *testing.T) {
expected := &APIKey{
ID: 42,
Token: "some-token",
Description: "desc",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPost, "http://mf/v1/api-keys", func(r io.Reader) {
expectFromJSON(t, r, &APIKeyCreationRequest{
Description: "desc",
})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CreateAPIKeyContext(t.Context(), "desc")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestDeleteAPIKey(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodDelete, "http://mf/v1/api-keys/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.DeleteAPIKeyContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestMarkAllAsRead(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/users/1/mark-all-as-read", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.MarkAllAsReadContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestIntegrationsStatus(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/integrations/status", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, struct {
HasIntegrations bool `json:"has_integrations"`
}{
HasIntegrations: true,
})
})))
status, err := client.IntegrationsStatusContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !status {
t.Fatalf("Expected integrations status to be true, got false")
}
}
func TestDiscover(t *testing.T) {
expected := Subscriptions{
{
URL: "http://example.com",
Title: "Example",
Type: "rss",
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPost, "http://mf/v1/discover", func(r io.Reader) {
expectFromJSON(t, r, &map[string]string{"url": "http://example.com"})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.DiscoverContext(t.Context(), "http://example.com")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestCategories(t *testing.T) {
expected := Categories{
{
ID: 1,
Title: "Example",
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/categories", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CategoriesContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestCategoriesWithCounters(t *testing.T) {
feedCount := 1
totalUnread := 2
expected := Categories{
{
ID: 1,
Title: "Example",
FeedCount: &feedCount,
TotalUnread: &totalUnread,
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/categories?counts=true", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CategoriesWithCountersContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestCreateCategory(t *testing.T) {
expected := &Category{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPost, "http://mf/v1/categories", func(r io.Reader) {
expectFromJSON(t, r, &CategoryCreationRequest{
Title: "Example",
})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CreateCategoryContext(t.Context(), "Example")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestCreateCategoryWithOptions(t *testing.T) {
expected := &Category{
ID: 1,
Title: "Example",
HideGlobally: true,
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPost, "http://mf/v1/categories", func(r io.Reader) {
expectFromJSON(t, r, &CategoryCreationRequest{
Title: "Example",
HideGlobally: true,
})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CreateCategoryWithOptionsContext(t.Context(), &CategoryCreationRequest{
Title: "Example",
HideGlobally: true,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestUpdateCategory(t *testing.T) {
expected := &Category{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/categories/1", func(r io.Reader) {
expectFromJSON(t, r, &CategoryModificationRequest{
Title: &expected.Title,
})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UpdateCategoryContext(t.Context(), 1, "Example")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestUpdateCategoryWithOptions(t *testing.T) {
expected := &Category{
ID: 1,
Title: "Example",
HideGlobally: true,
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/categories/1", func(r io.Reader) {
expectFromJSON(t, r, &CategoryModificationRequest{
Title: &expected.Title,
HideGlobally: &expected.HideGlobally,
})
}, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UpdateCategoryWithOptionsContext(t.Context(), 1, &CategoryModificationRequest{
Title: &expected.Title,
HideGlobally: &expected.HideGlobally,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestMarkCategoryAsRead(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/categories/1/mark-all-as-read", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.MarkCategoryAsReadContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestCategoryFeeds(t *testing.T) {
expected := Feeds{
{
ID: 1,
Title: "Example",
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/categories/1/feeds", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CategoryFeedsContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestDeleteCategory(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodDelete, "http://mf/v1/categories/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.DeleteCategoryContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestRefreshCategory(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/categories/1/refresh", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.RefreshCategoryContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestFeeds(t *testing.T) {
expected := Feeds{
{
ID: 1,
Title: "Example",
FeedURL: "http://example.com",
SiteURL: "http://example.com",
CheckedAt: time.Date(1970, 1, 1, 0, 7, 0, 0, time.UTC),
Disabled: false,
IgnoreHTTPCache: false,
AllowSelfSignedCertificates: false,
FetchViaProxy: false,
ScraperRules: "",
RewriteRules: "",
UrlRewriteRules: "",
BlocklistRules: "",
KeeplistRules: "",
BlockFilterEntryRules: "",
KeepFilterEntryRules: "",
Crawler: false,
UserAgent: "",
Cookie: "",
Username: "",
Password: "",
Category: &Category{
ID: 1,
Title: "Example",
},
HideGlobally: false,
DisableHTTP2: false,
ProxyURL: "",
},
{
ID: 2,
Title: "Example 2",
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/feeds", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.FeedsContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestExport(t *testing.T) {
expected := []byte("hello")
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/export", nil, req)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(string(expected))),
Header: http.Header{},
}
})))
res, err := client.ExportContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %+v, got %+v", expected, res)
}
}
func TestImport(t *testing.T) {
expected := []byte("hello")
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(
t,
http.MethodPost,
"http://mf/v1/import",
func(r io.Reader) {
b, err := io.ReadAll(r)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !bytes.Equal(b, expected) {
t.Fatalf("expected %+v, got %+v", expected, b)
}
},
req)
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{},
}
})))
if err := client.ImportContext(t.Context(), io.NopCloser(bytes.NewBufferString(string(expected)))); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestFeed(t *testing.T) {
expected := &Feed{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/feeds/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.FeedContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestCreateFeed(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPost, "http://mf/v1/feeds", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, struct {
FeedID int64 `json:"feed_id"`
}{
FeedID: 1,
})
})))
id, err := client.CreateFeedContext(t.Context(), &FeedCreationRequest{
FeedURL: "http://example.com",
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if id != 1 {
t.Fatalf("Expected feed ID to be 1, got %d", id)
}
}
func TestUpdateFeed(t *testing.T) {
expected := &Feed{
ID: 1,
FeedURL: "http://example.com/",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/feeds/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UpdateFeedContext(t.Context(), 1, &FeedModificationRequest{
FeedURL: &expected.FeedURL,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestMarkFeedAsRead(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/feeds/1/mark-all-as-read", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.MarkFeedAsReadContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestRefreshAllFeeds(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/feeds/refresh", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.RefreshAllFeedsContext(t.Context()); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestRefreshFeed(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/feeds/1/refresh", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.RefreshFeedContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestDeleteFeed(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodDelete, "http://mf/v1/feeds/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.DeleteFeedContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestFeedIcon(t *testing.T) {
expected := &FeedIcon{
ID: 1,
MimeType: "text/plain",
Data: "data",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/feeds/1/icon", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.FeedIconContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestFeedEntry(t *testing.T) {
expected := &Entry{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/feeds/1/entries/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.FeedEntryContext(t.Context(), 1, 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestCategoryEntry(t *testing.T) {
expected := &Entry{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/categories/1/entries/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CategoryEntryContext(t.Context(), 1, 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntry(t *testing.T) {
expected := &Entry{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntryContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEntries(t *testing.T) {
expected := &EntryResultSet{
Total: 1,
Entries: Entries{
{
ID: 1,
Title: "Example",
},
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EntriesContext(t.Context(), nil)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestFeedEntries(t *testing.T) {
expected := &EntryResultSet{
Total: 1,
Entries: Entries{
{
ID: 1,
Title: "Example",
},
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/feeds/1/entries?limit=10&offset=0", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.FeedEntriesContext(t.Context(), 1, &Filter{
Limit: 10,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestCategoryEntries(t *testing.T) {
expected := &EntryResultSet{
Total: 1,
Entries: Entries{
{
ID: 1,
Title: "Example",
},
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/categories/1/entries?limit=10&offset=0", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.CategoryEntriesContext(t.Context(), 1, &Filter{
Limit: 10,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestUpdateEntries(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/entries", nil, req)
expectFromJSON(t, req.Body, &struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
}{
EntryIDs: []int64{1, 2},
Status: "read",
})
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.UpdateEntriesContext(t.Context(), []int64{1, 2}, "read"); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestUpdateEntry(t *testing.T) {
expected := &Entry{
ID: 1,
Title: "Example",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/entries/1", nil, req)
expectFromJSON(t, req.Body, &EntryModificationRequest{
Title: &expected.Title,
})
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.UpdateEntryContext(t.Context(), 1, &EntryModificationRequest{
Title: &expected.Title,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestToggleStarred(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/entries/1/star", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.ToggleStarredContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestSaveEntry(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPost, "http://mf/v1/entries/1/save", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.SaveEntryContext(t.Context(), 1); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestFetchEntryOriginalContent(t *testing.T) {
expected := "Example"
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/entries/1/fetch-content", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, struct {
Content string `json:"content"`
}{
Content: expected,
})
})))
res, err := client.FetchEntryOriginalContentContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if res != expected {
t.Fatalf("Expected %s, got %s", expected, res)
}
}
func TestFetchCounters(t *testing.T) {
expected := &FeedCounters{
ReadCounters: map[int64]int{
2: 1,
},
UnreadCounters: map[int64]int{
3: 1,
},
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/feeds/counters", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.FetchCountersContext(t.Context())
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestFlushHistory(t *testing.T) {
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/flush-history", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
})))
if err := client.FlushHistoryContext(t.Context()); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
func TestIcon(t *testing.T) {
expected := &FeedIcon{
ID: 1,
MimeType: "text/plain",
Data: "data",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/icons/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.IconContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestEnclosure(t *testing.T) {
expected := &Enclosure{
ID: 1,
URL: "http://example.com",
MimeType: "text/plain",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodGet, "http://mf/v1/enclosures/1", nil, req)
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
res, err := client.EnclosureContext(t.Context(), 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !reflect.DeepEqual(res, expected) {
t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
}
}
func TestUpdateEnclosure(t *testing.T) {
expected := &Enclosure{
ID: 1,
URL: "http://example.com",
MimeType: "text/plain",
}
client := NewClientWithOptions(
"http://mf",
WithHTTPClient(
newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
expectRequest(t, http.MethodPut, "http://mf/v1/enclosures/1", nil, req)
expectFromJSON(t, req.Body, &EnclosureUpdateRequest{
MediaProgression: 10,
})
return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
})))
if err := client.UpdateEnclosureContext(t.Context(), 1, &EnclosureUpdateRequest{
MediaProgression: 10,
}); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
v2-2.3.0/client/doc.go 0000664 0000000 0000000 00000001321 15201231005 0014377 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
/*
Package client implements a client library for the Miniflux REST API.
# Examples
This example fetches the list of users:
import (
miniflux "miniflux.app/v2/client"
)
client := miniflux.NewClient("https://api.example.org", "admin", "secret")
users, err := client.Users()
if err != nil {
fmt.Println(err)
return
}
fmt.Println(users, err)
This example discovers subscriptions on a website:
subscriptions, err := client.Discover("https://example.org/")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(subscriptions)
*/
package client // import "miniflux.app/v2/client"
v2-2.3.0/client/model.go 0000664 0000000 0000000 00000034664 15201231005 0014752 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client // import "miniflux.app/v2/client"
import (
"fmt"
"time"
)
// Entry statuses.
const (
EntryStatusUnread = "unread"
EntryStatusRead = "read"
)
// User represents a user in the system.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"`
Language string `json:"language"`
Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
CustomJS string `json:"custom_js"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"`
GestureNav string `json:"gesture_nav"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
DefaultReadingSpeed int `json:"default_reading_speed"`
CJKReadingSpeed int `json:"cjk_reading_speed"`
DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"`
MediaPlaybackRate float64 `json:"media_playback_rate"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
ExternalFontHosts string `json:"external_font_hosts"`
AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab bool `json:"open_external_links_in_new_tab"`
}
func (u User) String() string {
return fmt.Sprintf("#%d - %s (admin=%v)", u.ID, u.Username, u.IsAdmin)
}
// UserCreationRequest represents the request to create a user.
type UserCreationRequest struct {
Username string `json:"username"`
Password string `json:"password"`
IsAdmin bool `json:"is_admin"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
}
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
MediaPlaybackRate *float64 `json:"media_playback_rate"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
ExternalFontHosts *string `json:"external_font_hosts"`
AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab *bool `json:"open_external_links_in_new_tab"`
}
// Users represents a list of users.
type Users []User
// Category represents a feed category.
type Category struct {
ID int64 `json:"id"`
Title string `json:"title"`
UserID int64 `json:"user_id,omitempty"`
HideGlobally bool `json:"hide_globally,omitempty"`
FeedCount *int `json:"feed_count,omitempty"`
TotalUnread *int `json:"total_unread,omitempty"`
}
func (c Category) String() string {
return fmt.Sprintf("#%d %s", c.ID, c.Title)
}
// Categories represents a list of categories.
type Categories []*Category
// CategoryCreationRequest represents the request to create a category.
type CategoryCreationRequest struct {
Title string `json:"title"`
HideGlobally bool `json:"hide_globally"`
}
// CategoryModificationRequest represents the request to update a category.
type CategoryModificationRequest struct {
Title *string `json:"title"`
HideGlobally *bool `json:"hide_globally"`
}
// Subscription represents a feed subscription.
type Subscription struct {
Title string `json:"title"`
URL string `json:"url"`
Type string `json:"type"`
}
func (s Subscription) String() string {
return fmt.Sprintf(`Title=%q, URL=%q, Type=%q`, s.Title, s.URL, s.Type)
}
// Subscriptions represents a list of subscriptions.
type Subscriptions []*Subscription
// Feed represents a Miniflux feed.
type Feed struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
CheckedAt time.Time `json:"checked_at"`
EtagHeader string `json:"etag_header,omitempty"`
LastModifiedHeader string `json:"last_modified_header,omitempty"`
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
ParsingErrorCount int `json:"parsing_error_count,omitempty"`
Disabled bool `json:"disabled"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Category *Category `json:"category,omitempty"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
}
// FeedCreationRequest represents the request to create a feed.
type FeedCreationRequest struct {
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
Disabled bool `json:"disabled"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
}
// FeedModificationRequest represents the request to update a feed.
type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
UrlRewriteRules *string `json:"urlrewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_rules"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
IgnoreEntryUpdates *bool `json:"ignore_entry_updates"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
Username *string `json:"username"`
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
Disabled *bool `json:"disabled"`
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
HideGlobally *bool `json:"hide_globally"`
DisableHTTP2 *bool `json:"disable_http2"`
ProxyURL *string `json:"proxy_url"`
}
// FeedIcon represents the feed icon.
type FeedIcon struct {
ID int64 `json:"id"`
MimeType string `json:"mime_type"`
Data string `json:"data"`
}
type FeedCounters struct {
ReadCounters map[int64]int `json:"reads"`
UnreadCounters map[int64]int `json:"unreads"`
}
// Feeds represents a list of feeds.
type Feeds []*Feed
// Entry represents a subscription item in the system.
type Entry struct {
ID int64 `json:"id"`
Date time.Time `json:"published_at"`
ChangedAt time.Time `json:"changed_at"`
CreatedAt time.Time `json:"created_at"`
Feed *Feed `json:"feed,omitempty"`
Hash string `json:"hash"`
URL string `json:"url"`
CommentsURL string `json:"comments_url"`
Title string `json:"title"`
Status string `json:"status"`
Content string `json:"content"`
Author string `json:"author"`
ShareCode string `json:"share_code"`
Enclosures Enclosures `json:"enclosures,omitempty"`
Tags []string `json:"tags"`
ReadingTime int `json:"reading_time"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Starred bool `json:"starred"`
}
// EntryModificationRequest represents a request to modify an entry.
type EntryModificationRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
}
// Entries represents a list of entries.
type Entries []*Entry
// Enclosure represents an attachment.
type Enclosure struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
EntryID int64 `json:"entry_id"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size int `json:"size"`
MediaProgression int64 `json:"media_progression"`
}
type EnclosureUpdateRequest struct {
MediaProgression int64 `json:"media_progression"`
}
// Enclosures represents a list of attachments.
type Enclosures []*Enclosure
const (
FilterNotStarred = "0"
FilterOnlyStarred = "1"
)
// Filter is used to filter entries.
type Filter struct {
Status string
Offset int
Limit int
Order string
Direction string
Starred string
Before int64
After int64
PublishedBefore int64
PublishedAfter int64
ChangedBefore int64
ChangedAfter int64
BeforeEntryID int64
AfterEntryID int64
Search string
CategoryID int64
FeedID int64
Statuses []string
GloballyVisible bool
}
// EntryResultSet represents the response when fetching entries.
type EntryResultSet struct {
Total int `json:"total"`
Entries Entries `json:"entries"`
}
// VersionResponse represents the version and the build information of the Miniflux instance.
type VersionResponse struct {
Version string `json:"version"`
Commit string `json:"commit"`
BuildDate string `json:"build_date"`
GoVersion string `json:"go_version"`
Compiler string `json:"compiler"`
Arch string `json:"arch"`
OS string `json:"os"`
}
// APIKey represents an application API key.
type APIKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
Description string `json:"description"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
}
// APIKeys represents a collection of API keys.
type APIKeys []*APIKey
// APIKeyCreationRequest represents the request to create an API key.
type APIKeyCreationRequest struct {
Description string `json:"description"`
}
// SetOptionalField returns a pointer to the given value so optional request fields can be marked as set.
//
//go:fix inline
func SetOptionalField[T any](value T) *T {
return new(value)
}
v2-2.3.0/client/options.go 0000664 0000000 0000000 00000001317 15201231005 0015332 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client // import "miniflux.app/v2/client"
import "net/http"
type Option func(*request)
// WithAPIKey sets the API key for the client.
func WithAPIKey(apiKey string) Option {
return func(r *request) {
r.apiKey = apiKey
}
}
// WithCredentials sets the username and password for the client.
func WithCredentials(username, password string) Option {
return func(r *request) {
r.username = username
r.password = password
}
}
// WithHTTPClient sets the HTTP client for the client.
func WithHTTPClient(client *http.Client) Option {
return func(r *request) {
r.client = client
}
}
v2-2.3.0/client/request.go 0000664 0000000 0000000 00000010202 15201231005 0015320 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package client // import "miniflux.app/v2/client"
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"time"
)
const (
userAgent = "Miniflux Client Library"
defaultTimeout = 80 * time.Second
)
// List of exposed errors.
var (
ErrNotAuthorized = errors.New("miniflux: unauthorized (bad credentials)")
ErrForbidden = errors.New("miniflux: access forbidden")
ErrServerError = errors.New("miniflux: internal server error")
ErrNotFound = errors.New("miniflux: resource not found")
ErrBadRequest = errors.New("miniflux: bad request")
ErrEmptyEndpoint = errors.New("miniflux: empty endpoint provided")
)
type errorResponse struct {
ErrorMessage string `json:"error_message"`
}
type request struct {
endpoint string
username string
password string
apiKey string
client *http.Client
}
func (r *request) Get(ctx context.Context, path string) (io.ReadCloser, error) {
return r.execute(ctx, http.MethodGet, path, nil)
}
func (r *request) Post(ctx context.Context, path string, data any) (io.ReadCloser, error) {
return r.execute(ctx, http.MethodPost, path, data)
}
func (r *request) PostFile(ctx context.Context, path string, f io.ReadCloser) (io.ReadCloser, error) {
return r.execute(ctx, http.MethodPost, path, f)
}
func (r *request) Put(ctx context.Context, path string, data any) (io.ReadCloser, error) {
return r.execute(ctx, http.MethodPut, path, data)
}
func (r *request) Delete(ctx context.Context, path string) error {
_, err := r.execute(ctx, http.MethodDelete, path, nil)
return err
}
func (r *request) execute(
ctx context.Context,
method string,
path string,
data any,
) (io.ReadCloser, error) {
if r.endpoint == "" {
return nil, ErrEmptyEndpoint
}
if r.endpoint[len(r.endpoint)-1:] == "/" {
r.endpoint = r.endpoint[:len(r.endpoint)-1]
}
u, err := url.Parse(r.endpoint + path)
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, method, u.String(), nil)
if err != nil {
return nil, err
}
request.Header = r.buildHeaders()
if r.username != "" && r.password != "" {
request.SetBasicAuth(r.username, r.password)
}
if data != nil {
switch data := data.(type) {
case io.ReadCloser:
request.Body = data
default:
request.Body = io.NopCloser(bytes.NewBuffer(r.toJSON(data)))
}
}
client := r.client
response, err := client.Do(request)
if err != nil {
return nil, err
}
switch response.StatusCode {
case http.StatusUnauthorized:
response.Body.Close()
return nil, ErrNotAuthorized
case http.StatusForbidden:
response.Body.Close()
return nil, ErrForbidden
case http.StatusInternalServerError:
defer response.Body.Close()
var resp errorResponse
decoder := json.NewDecoder(response.Body)
// If we failed to decode, just return a generic ErrServerError
if err := decoder.Decode(&resp); err != nil {
return nil, ErrServerError
}
return nil, errors.New("miniflux: internal server error: " + resp.ErrorMessage)
case http.StatusNotFound:
response.Body.Close()
return nil, ErrNotFound
case http.StatusNoContent:
response.Body.Close()
return nil, nil
case http.StatusBadRequest:
defer response.Body.Close()
var resp errorResponse
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&resp); err != nil {
return nil, fmt.Errorf("%w (%v)", ErrBadRequest, err)
}
return nil, fmt.Errorf("%w (%s)", ErrBadRequest, resp.ErrorMessage)
}
if response.StatusCode > 400 {
response.Body.Close()
return nil, fmt.Errorf("miniflux: status code=%d", response.StatusCode)
}
return response.Body, nil
}
func (r *request) buildHeaders() http.Header {
headers := make(http.Header)
headers.Add("User-Agent", userAgent)
headers.Add("Content-Type", "application/json")
headers.Add("Accept", "application/json")
if r.apiKey != "" {
headers.Add("X-Auth-Token", r.apiKey)
}
return headers
}
func (r *request) toJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
log.Println("Unable to convert interface to JSON:", err)
return []byte("")
}
return b
}
v2-2.3.0/contrib/ 0000775 0000000 0000000 00000000000 15201231005 0013470 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/README.md 0000664 0000000 0000000 00000000342 15201231005 0014746 0 ustar 00root root 0000000 0000000 The contrib directory contains various useful things contributed by the community.
Community contributions are not officially supported by the maintainers.
There is no guarantee whatsoever that anything in this folder works.
v2-2.3.0/contrib/ansible/ 0000775 0000000 0000000 00000000000 15201231005 0015105 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/ansible/inventories/ 0000775 0000000 0000000 00000000000 15201231005 0017452 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/ansible/inventories/group_vars/ 0000775 0000000 0000000 00000000000 15201231005 0021641 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/ansible/inventories/group_vars/miniflux_vars.yml 0000664 0000000 0000000 00000000406 15201231005 0025252 0 ustar 00root root 0000000 0000000 ---
miniflux_linux_user: miniflux
miniflux_db_user_name: miniflux_db_user
miniflux_db_user_password: miniflux_db_user_password
miniflux_db: miniflux_db
miniflux_admin_name: admin
miniflux_admin_passwort: miniflux_admin_password
miniflux_port: 8080
v2-2.3.0/contrib/ansible/playbooks/ 0000775 0000000 0000000 00000000000 15201231005 0017110 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/ansible/playbooks/playbook.yml 0000664 0000000 0000000 00000000120 15201231005 0021444 0 ustar 00root root 0000000 0000000 ---
- hosts: miniflux
roles:
- { role: mgrote.miniflux, tags: "miniflux" } v2-2.3.0/contrib/ansible/roles/ 0000775 0000000 0000000 00000000000 15201231005 0016231 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/ansible/roles/mgrote.miniflux/ 0000775 0000000 0000000 00000000000 15201231005 0021360 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/ansible/roles/mgrote.miniflux/README.md 0000664 0000000 0000000 00000001050 15201231005 0022633 0 ustar 00root root 0000000 0000000 ## mgrote.miniflux
### Details
Installs and configures Miniflux v2 with ansible
### Works on...
- [x] Ubuntu (>=18.04)
### Variables and Defaults
##### Linux User
miniflux_linux_user: miniflux
##### DB User
miniflux_db_user_name: miniflux_db_user
##### DB Password
miniflux_db_user_password: qqqqqqqqqqqqq
##### Database
miniflux_db: miniflux_db
##### Username Miniflux Admin
miniflux_admin_name: admin
##### Password Miniflux Admin
miniflux_admin_passwort: hallowelt
##### Port for Miniflux Frontend
miniflux_port: 8080
v2-2.3.0/contrib/ansible/roles/mgrote.miniflux/defaults/ 0000775 0000000 0000000 00000000000 15201231005 0023167 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/ansible/roles/mgrote.miniflux/defaults/main.yml 0000664 0000000 0000000 00000000000 15201231005 0024624 0 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/ansible/roles/mgrote.miniflux/handlers/ 0000775 0000000 0000000 00000000000 15201231005 0023160 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/ansible/roles/mgrote.miniflux/handlers/main.yml 0000664 0000000 0000000 00000000330 15201231005 0024623 0 ustar 00root root 0000000 0000000 ---
- name: start_miniflux.service
become: yes
systemd:
name: miniflux
state: restarted
enabled: yes
# wait 15 seconds(for systemd)
- name: miniflux_wait
wait_for:
timeout: 15
v2-2.3.0/contrib/ansible/roles/mgrote.miniflux/tasks/ 0000775 0000000 0000000 00000000000 15201231005 0022505 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/ansible/roles/mgrote.miniflux/tasks/main.yml 0000664 0000000 0000000 00000001630 15201231005 0024154 0 ustar 00root root 0000000 0000000 - name: add Apt-key for miniflux-repo
become: yes
apt_key:
url: https://apt.miniflux.app/KEY.gpg
state: present
- name: add miniflux-repo
become: yes
apt_repository:
repo: 'deb https://apt.miniflux.app/ /'
state: present
filename: miniflux_repo
update_cache: yes
- name: install miniflux
become: yes
apt:
name: miniflux
state: present
- name: add miniflux linux_user
become: yes
user:
name: "{{ miniflux_linux_user }}"
home: "/var/empty"
create_home: "no"
system: "yes"
shell: "/bin/false"
- name: create directory "/etc/miniflux.d"
become: yes
file:
path: /etc/miniflux.d
state: directory
- name: copy miniflux.conf
become: yes
template:
src: "miniflux.conf"
dest: "/etc/miniflux.conf"
notify:
- start_miniflux.service
- miniflux_wait
v2-2.3.0/contrib/ansible/roles/mgrote.miniflux/templates/ 0000775 0000000 0000000 00000000000 15201231005 0023356 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/ansible/roles/mgrote.miniflux/templates/miniflux.conf 0000664 0000000 0000000 00000000732 15201231005 0026062 0 ustar 00root root 0000000 0000000 # See https://docs.miniflux.app/
LISTEN_ADDR=0.0.0.0:{{ miniflux_port }}
DATABASE_URL=user={{ miniflux_db_user_name }} password={{ miniflux_db_user_password }} dbname={{ miniflux_db }} sslmode=disable
POLLING_FREQUENCY=15
PROXY_IMAGES=http-only
# Run SQL migrations automatically:
RUN_MIGRATIONS=1
CREATE_ADMIN=1
ADMIN_USERNAME={{ miniflux_admin_name }}
ADMIN_PASSWORD={{ miniflux_admin_passwort }}
POLLING_FREQUENCY=10
# Options: https://miniflux.app/miniflux.1.html
v2-2.3.0/contrib/bruno/ 0000775 0000000 0000000 00000000000 15201231005 0014615 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/bruno/README.md 0000664 0000000 0000000 00000000314 15201231005 0016072 0 ustar 00root root 0000000 0000000 This folder contains Miniflux API collection for [Bruno](https://www.usebruno.com).
Bruno is a lightweight alternative to Postman/Insomnia.
- https://www.usebruno.com
- https://github.com/usebruno/bruno v2-2.3.0/contrib/bruno/miniflux/ 0000775 0000000 0000000 00000000000 15201231005 0016450 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/bruno/miniflux/Bookmark an entry.bru 0000664 0000000 0000000 00000000530 15201231005 0022426 0 ustar 00root root 0000000 0000000 meta {
name: Bookmark an entry
type: http
seq: 37
}
put {
url: {{minifluxBaseURL}}/v1/entries/{{entryID}}/bookmark
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
entryID: 1698
}
v2-2.3.0/contrib/bruno/miniflux/Create a feed.bru 0000664 0000000 0000000 00000000430 15201231005 0021447 0 ustar 00root root 0000000 0000000 meta {
name: Create a feed
type: http
seq: 19
}
post {
url: {{minifluxBaseURL}}/v1/feeds
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
v2-2.3.0/contrib/bruno/miniflux/Create a new category.bru 0000664 0000000 0000000 00000000411 15201231005 0023132 0 ustar 00root root 0000000 0000000 meta {
name: Create a new category
type: http
seq: 10
}
post {
url: {{minifluxBaseURL}}/v1/categories
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test"
}
}
v2-2.3.0/contrib/bruno/miniflux/Create a new user.bru 0000664 0000000 0000000 00000000441 15201231005 0022276 0 ustar 00root root 0000000 0000000 meta {
name: Create a new user
type: http
seq: 5
}
post {
url: {{minifluxBaseURL}}/v1/users
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"username": "foobar",
"password": "secret123"
}
}
v2-2.3.0/contrib/bruno/miniflux/Delete a category.bru 0000664 0000000 0000000 00000000503 15201231005 0022361 0 ustar 00root root 0000000 0000000 meta {
name: Delete a category
type: http
seq: 12
}
delete {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 1
}
v2-2.3.0/contrib/bruno/miniflux/Delete a feed.bru 0000664 0000000 0000000 00000000472 15201231005 0021454 0 ustar 00root root 0000000 0000000 meta {
name: Delete a feed
type: http
seq: 26
}
delete {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
vars:pre-request {
feedID: 18
}
v2-2.3.0/contrib/bruno/miniflux/Delete a user.bru 0000664 0000000 0000000 00000000456 15201231005 0021531 0 ustar 00root root 0000000 0000000 meta {
name: Delete a user
type: http
seq: 7
}
delete {
url: {{minifluxBaseURL}}/v1/users/{{userID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"language": "fr_FR"
}
}
vars:pre-request {
userID: 2
}
v2-2.3.0/contrib/bruno/miniflux/Discover feeds.bru 0000664 0000000 0000000 00000000416 15201231005 0022010 0 ustar 00root root 0000000 0000000 meta {
name: Discover feeds
type: http
seq: 18
}
post {
url: {{minifluxBaseURL}}/v1/discover
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"url": "https://miniflux.app"
}
}
v2-2.3.0/contrib/bruno/miniflux/Fetch entry website content.bru 0000664 0000000 0000000 00000000547 15201231005 0024421 0 ustar 00root root 0000000 0000000 meta {
name: Fetch entry website content
type: http
seq: 39
}
get {
url: {{minifluxBaseURL}}/v1/entries/{{entryID}}/fetch-content
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
entryID: 1698
}
v2-2.3.0/contrib/bruno/miniflux/Flush history.bru 0000664 0000000 0000000 00000000421 15201231005 0021722 0 ustar 00root root 0000000 0000000 meta {
name: Flush history
type: http
seq: 40
}
put {
url: {{minifluxBaseURL}}/v1/flush-history
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"url": "https://miniflux.app"
}
}
v2-2.3.0/contrib/bruno/miniflux/Get a single entry.bru 0000664 0000000 0000000 00000000520 15201231005 0022463 0 ustar 00root root 0000000 0000000 meta {
name: Get a single entry
type: http
seq: 36
}
get {
url: {{minifluxBaseURL}}/v1/entries/{{entryID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
entryID: 1698
}
v2-2.3.0/contrib/bruno/miniflux/Get a single feed entry.bru 0000664 0000000 0000000 00000000563 15201231005 0023356 0 ustar 00root root 0000000 0000000 meta {
name: Get a single feed entry
type: http
seq: 33
}
get {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/entries/{{entryID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
feedID: 19
entryID: 1698
}
v2-2.3.0/contrib/bruno/miniflux/Get a single feed.bru 0000664 0000000 0000000 00000000511 15201231005 0022225 0 ustar 00root root 0000000 0000000 meta {
name: Get a single feed
type: http
seq: 24
}
get {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
feedID: 18
}
v2-2.3.0/contrib/bruno/miniflux/Get a single user by ID.bru 0000664 0000000 0000000 00000000406 15201231005 0023153 0 ustar 00root root 0000000 0000000 meta {
name: Get a single user by ID
type: http
seq: 3
}
get {
url: {{minifluxBaseURL}}/v1/users/{{userID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
vars:pre-request {
userID: 1
}
v2-2.3.0/contrib/bruno/miniflux/Get a single user by username.bru 0000664 0000000 0000000 00000000424 15201231005 0024476 0 ustar 00root root 0000000 0000000 meta {
name: Get a single user by username
type: http
seq: 4
}
get {
url: {{minifluxBaseURL}}/v1/users/{{username}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
vars:pre-request {
username: admin
}
v2-2.3.0/contrib/bruno/miniflux/Get all categories.bru 0000664 0000000 0000000 00000000331 15201231005 0022535 0 ustar 00root root 0000000 0000000 meta {
name: Get all categories
type: http
seq: 9
}
get {
url: {{minifluxBaseURL}}/v1/categories
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
v2-2.3.0/contrib/bruno/miniflux/Get all entries.bru 0000664 0000000 0000000 00000000433 15201231005 0022064 0 ustar 00root root 0000000 0000000 meta {
name: Get all entries
type: http
seq: 34
}
get {
url: {{minifluxBaseURL}}/v1/entries
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
v2-2.3.0/contrib/bruno/miniflux/Get all feeds.bru 0000664 0000000 0000000 00000000427 15201231005 0021504 0 ustar 00root root 0000000 0000000 meta {
name: Get all feeds
type: http
seq: 20
}
get {
url: {{minifluxBaseURL}}/v1/feeds
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
v2-2.3.0/contrib/bruno/miniflux/Get all users.bru 0000664 0000000 0000000 00000000317 15201231005 0021555 0 ustar 00root root 0000000 0000000 meta {
name: Get all users
type: http
seq: 2
}
get {
url: {{minifluxBaseURL}}/v1/users
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
v2-2.3.0/contrib/bruno/miniflux/Get category entries.bru 0000664 0000000 0000000 00000000513 15201231005 0023130 0 ustar 00root root 0000000 0000000 meta {
name: Get category entries
type: http
seq: 16
}
get {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/entries
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 2
}
v2-2.3.0/contrib/bruno/miniflux/Get category entry.bru 0000664 0000000 0000000 00000000542 15201231005 0022622 0 ustar 00root root 0000000 0000000 meta {
name: Get category entry
type: http
seq: 17
}
get {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/entries/{{entryID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 2
entryID: 1
}
v2-2.3.0/contrib/bruno/miniflux/Get category feeds.bru 0000664 0000000 0000000 00000000507 15201231005 0022550 0 ustar 00root root 0000000 0000000 meta {
name: Get category feeds
type: http
seq: 14
}
get {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/feeds
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 2
}
v2-2.3.0/contrib/bruno/miniflux/Get current user.bru 0000664 0000000 0000000 00000000317 15201231005 0022304 0 ustar 00root root 0000000 0000000 meta {
name: Get current user
type: http
seq: 1
}
get {
url: {{minifluxBaseURL}}/v1/me
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
v2-2.3.0/contrib/bruno/miniflux/Get feed counters.bru 0000664 0000000 0000000 00000000444 15201231005 0022412 0 ustar 00root root 0000000 0000000 meta {
name: Get feed counters
type: http
seq: 21
}
get {
url: {{minifluxBaseURL}}/v1/feeds/counters
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
v2-2.3.0/contrib/bruno/miniflux/Get feed entries.bru 0000664 0000000 0000000 00000000520 15201231005 0022214 0 ustar 00root root 0000000 0000000 meta {
name: Get feed entries
type: http
seq: 32
}
get {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/entries
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
feedID: 19
}
v2-2.3.0/contrib/bruno/miniflux/Get feed icon by feed ID.bru 0000664 0000000 0000000 00000000507 15201231005 0023234 0 ustar 00root root 0000000 0000000 meta {
name: Get feed icon by feed ID
type: http
seq: 27
}
get {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/icon
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
vars:pre-request {
feedID: 19
}
v2-2.3.0/contrib/bruno/miniflux/Get feed icon by icon ID.bru 0000664 0000000 0000000 00000000502 15201231005 0023254 0 ustar 00root root 0000000 0000000 meta {
name: Get feed icon by icon ID
type: http
seq: 28
}
get {
url: {{minifluxBaseURL}}/v1/icons/{{iconID}}
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
vars:pre-request {
iconID: 11
}
v2-2.3.0/contrib/bruno/miniflux/Get version and build information.bru 0000664 0000000 0000000 00000000346 15201231005 0025463 0 ustar 00root root 0000000 0000000 meta {
name: Get version and build information
type: http
seq: 42
}
get {
url: {{minifluxBaseURL}}/v1/version
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
v2-2.3.0/contrib/bruno/miniflux/Mark all category entries as read.bru 0000664 0000000 0000000 00000000541 15201231005 0025315 0 ustar 00root root 0000000 0000000 meta {
name: Mark all category entries as read
type: http
seq: 13
}
put {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/mark-all-as-read
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 2
}
v2-2.3.0/contrib/bruno/miniflux/Mark all user entries as read.bru 0000664 0000000 0000000 00000000517 15201231005 0024461 0 ustar 00root root 0000000 0000000 meta {
name: Mark all user entries as read
type: http
seq: 8
}
put {
url: {{minifluxBaseURL}}/v1/users/{{userID}}/mark-all-as-read
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
userID: 1
}
v2-2.3.0/contrib/bruno/miniflux/Mark feed as read.bru 0000664 0000000 0000000 00000000514 15201231005 0022220 0 ustar 00root root 0000000 0000000 meta {
name: Mark feed as read
type: http
seq: 29
}
put {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/mark-all-as-read
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
vars:pre-request {
feedID: 19
}
v2-2.3.0/contrib/bruno/miniflux/OPML Export.bru 0000664 0000000 0000000 00000000453 15201231005 0021175 0 ustar 00root root 0000000 0000000 meta {
name: OPML Export
type: http
seq: 30
}
get {
url: {{minifluxBaseURL}}/v1/export
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
vars:pre-request {
feedID: 19
}
v2-2.3.0/contrib/bruno/miniflux/OPML Import.bru 0000664 0000000 0000000 00000001240 15201231005 0021161 0 ustar 00root root 0000000 0000000 meta {
name: OPML Import
type: http
seq: 31
}
post {
url: {{minifluxBaseURL}}/v1/import
body: xml
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
body:xml {
Miniflux
}
vars:pre-request {
feedID: 19
}
v2-2.3.0/contrib/bruno/miniflux/Refresh a single feed.bru 0000664 0000000 0000000 00000000525 15201231005 0023111 0 ustar 00root root 0000000 0000000 meta {
name: Refresh a single feed
type: http
seq: 23
}
put {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/refresh
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
feedID: 18
}
v2-2.3.0/contrib/bruno/miniflux/Refresh all feeds.bru 0000664 0000000 0000000 00000000443 15201231005 0022361 0 ustar 00root root 0000000 0000000 meta {
name: Refresh all feeds
type: http
seq: 22
}
put {
url: {{minifluxBaseURL}}/v1/feeds/refresh
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
v2-2.3.0/contrib/bruno/miniflux/Refresh category feeds.bru 0000664 0000000 0000000 00000000515 15201231005 0023426 0 ustar 00root root 0000000 0000000 meta {
name: Refresh category feeds
type: http
seq: 15
}
put {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/refresh
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 2
}
v2-2.3.0/contrib/bruno/miniflux/Save an entry.bru 0000664 0000000 0000000 00000000521 15201231005 0021557 0 ustar 00root root 0000000 0000000 meta {
name: Save an entry
type: http
seq: 38
}
post {
url: {{minifluxBaseURL}}/v1/entries/{{entryID}}/save
body: none
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"feed_url": "https://miniflux.app/feed.xml"
}
}
vars:pre-request {
entryID: 1698
}
v2-2.3.0/contrib/bruno/miniflux/Update a category.bru 0000664 0000000 0000000 00000000500 15201231005 0022376 0 ustar 00root root 0000000 0000000 meta {
name: Update a category
type: http
seq: 11
}
put {
url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "Test Update"
}
}
vars:pre-request {
categoryID: 1
}
v2-2.3.0/contrib/bruno/miniflux/Update a feed.bru 0000664 0000000 0000000 00000000467 15201231005 0021500 0 ustar 00root root 0000000 0000000 meta {
name: Update a feed
type: http
seq: 25
}
put {
url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"user_agent": "My user agent"
}
}
vars:pre-request {
feedID: 18
}
v2-2.3.0/contrib/bruno/miniflux/Update a user.bru 0000664 0000000 0000000 00000000453 15201231005 0021546 0 ustar 00root root 0000000 0000000 meta {
name: Update a user
type: http
seq: 6
}
put {
url: {{minifluxBaseURL}}/v1/users/{{userID}}
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"language": "fr_FR"
}
}
vars:pre-request {
userID: 1
}
v2-2.3.0/contrib/bruno/miniflux/Update entries status.bru 0000664 0000000 0000000 00000000445 15201231005 0023345 0 ustar 00root root 0000000 0000000 meta {
name: Update entries status
type: http
seq: 35
}
put {
url: {{minifluxBaseURL}}/v1/entries
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"entry_ids": [1698, 1699],
"status": "read"
}
}
v2-2.3.0/contrib/bruno/miniflux/Update entry.bru 0000664 0000000 0000000 00000000517 15201231005 0021531 0 ustar 00root root 0000000 0000000 meta {
name: Update entry
type: http
seq: 41
}
put {
url: {{minifluxBaseURL}}/v1/entries/{{entryID}}
body: json
auth: basic
}
auth:basic {
username: {{minifluxUsername}}
password: {{minifluxPassword}}
}
body:json {
{
"title": "New title",
"content": "Some text"
}
}
vars:pre-request {
entryID: 1789
}
v2-2.3.0/contrib/bruno/miniflux/bruno.json 0000664 0000000 0000000 00000000102 15201231005 0020461 0 ustar 00root root 0000000 0000000 {
"version": "1",
"name": "Miniflux",
"type": "collection"
} v2-2.3.0/contrib/bruno/miniflux/environments/ 0000775 0000000 0000000 00000000000 15201231005 0021177 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/bruno/miniflux/environments/Local.bru 0000664 0000000 0000000 00000000157 15201231005 0022746 0 ustar 00root root 0000000 0000000 vars {
minifluxBaseURL: http://127.0.0.1:8080
minifluxUsername: admin
}
vars:secret [
minifluxPassword
]
v2-2.3.0/contrib/docker-compose/ 0000775 0000000 0000000 00000000000 15201231005 0016402 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/docker-compose/Caddyfile 0000664 0000000 0000000 00000000061 15201231005 0020206 0 ustar 00root root 0000000 0000000 miniflux.example.org
reverse_proxy miniflux:8080
v2-2.3.0/contrib/docker-compose/README.md 0000664 0000000 0000000 00000000446 15201231005 0017665 0 ustar 00root root 0000000 0000000 Docker-Compose Examples
=======================
Here are few Docker Compose examples:
- `basic.yml`: Basic example
- `caddy.yml`: Use Caddy as reverse-proxy with automatic HTTPS
- `traefik.yml`: Use Traefik as reverse-proxy with automatic HTTPS
```bash
docker compose -f basic.yml up -d
```
v2-2.3.0/contrib/docker-compose/basic.yml 0000664 0000000 0000000 00000001641 15201231005 0020210 0 ustar 00root root 0000000 0000000 services:
miniflux:
image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}
container_name: miniflux
restart: always
ports:
- "80:8080"
depends_on:
db:
condition: service_healthy
environment:
- DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable
- RUN_MIGRATIONS=1
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
- DEBUG=1
# Optional health check:
# healthcheck:
# test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
db:
image: postgres:latest
container_name: postgres
restart: always
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=miniflux
volumes:
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
volumes:
miniflux-db:
v2-2.3.0/contrib/docker-compose/caddy.yml 0000664 0000000 0000000 00000001774 15201231005 0020222 0 ustar 00root root 0000000 0000000 services:
caddy:
image: caddy:2
container_name: caddy
depends_on:
- miniflux
ports:
- "80:80"
- "443:443"
volumes:
- $PWD/Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
miniflux:
image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}
container_name: miniflux
depends_on:
db:
condition: service_healthy
environment:
- DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable
- RUN_MIGRATIONS=1
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
- BASE_URL=https://miniflux.example.org
db:
image: postgres:latest
container_name: postgres
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
volumes:
miniflux-db:
caddy_data:
caddy_config:
v2-2.3.0/contrib/docker-compose/traefik.yml 0000664 0000000 0000000 00000003132 15201231005 0020551 0 ustar 00root root 0000000 0000000 services:
traefik:
image: "traefik:v2.3"
container_name: traefik
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
- "--certificatesresolvers.myresolver.acme.email=postmaster@example.com"
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
depends_on:
- miniflux
ports:
- "443:443"
volumes:
- "./letsencrypt:/letsencrypt"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
miniflux:
image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}
container_name: miniflux
depends_on:
db:
condition: service_healthy
expose:
- "8080"
environment:
- DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable
- RUN_MIGRATIONS=1
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
- BASE_URL=https://miniflux.example.org
labels:
- "traefik.enable=true"
- "traefik.http.routers.miniflux.rule=Host(`miniflux.example.org`)"
- "traefik.http.routers.miniflux.entrypoints=websecure"
- "traefik.http.routers.miniflux.tls.certresolver=myresolver"
db:
image: postgres:latest
container_name: postgres
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
volumes:
miniflux-db:
v2-2.3.0/contrib/grafana/ 0000775 0000000 0000000 00000000000 15201231005 0015067 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/grafana/README.md 0000664 0000000 0000000 00000000037 15201231005 0016346 0 ustar 00root root 0000000 0000000 Grafana Dashboard for Miniflux
v2-2.3.0/contrib/grafana/dashboard.json 0000664 0000000 0000000 00000115501 15201231005 0017714 0 ustar 00root root 0000000 0000000 {
"__inputs": [
{
"name": "DS_PROMETHEUS",
"label": "prometheus",
"description": "",
"type": "datasource",
"pluginId": "prometheus",
"pluginName": "Prometheus"
}
],
"__elements": {},
"__requires": [
{
"type": "panel",
"id": "bargauge",
"name": "Bar gauge",
"version": ""
},
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "10.4.3"
},
{
"type": "datasource",
"id": "prometheus",
"name": "Prometheus",
"version": "1.0.0"
},
{
"type": "panel",
"id": "stat",
"name": "Stat",
"version": ""
},
{
"type": "panel",
"id": "timeseries",
"name": "Time series",
"version": ""
}
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"collapsed": false,
"datasource": {
"uid": "Prometheus"
},
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 24,
"panels": [],
"targets": [
{
"datasource": {
"uid": "Prometheus"
},
"refId": "A"
}
],
"title": "Application",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 8,
"x": 0,
"y": 1
},
"id": 18,
"options": {
"displayMode": "basic",
"maxVizHeight": 300,
"minVizHeight": 16,
"minVizWidth": 8,
"namePlacement": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"last"
],
"fields": "",
"values": false
},
"showUnfilled": true,
"sizing": "auto",
"valueMode": "color"
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_feeds{status=\"total\"})",
"hide": false,
"interval": "",
"legendFormat": "Total",
"refId": "D"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_feeds{status=\"enabled\"})",
"hide": false,
"interval": "",
"legendFormat": "Enabled",
"refId": "C"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_broken_feeds)",
"interval": "",
"legendFormat": "Broken",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_feeds{status=\"disabled\"})",
"interval": "",
"legendFormat": "Disabled",
"refId": "B"
}
],
"title": "Feeds",
"type": "bargauge"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 3,
"w": 4,
"x": 8,
"y": 1
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"last"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_users)",
"interval": "",
"legendFormat": "Users",
"refId": "A"
}
],
"title": "Users",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 50,
"gradientMode": "opacity",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 1
},
"id": 4,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "right",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_entries{status=\"total\"})",
"hide": false,
"interval": "",
"legendFormat": "Total",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_entries{status=\"unread\"})",
"hide": false,
"interval": "",
"legendFormat": "Unread",
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_entries{status=\"read\"})",
"interval": "",
"legendFormat": "Read",
"refId": "C"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "max(miniflux_entries{status=\"removed\"})",
"interval": "",
"legendFormat": "Removed",
"refId": "D"
}
],
"title": "Entries by Status",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"description": "",
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 4,
"x": 8,
"y": 4
},
"id": 36,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"orientation": "vertical",
"reduceOptions": {
"calcs": [
"last"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "value",
"wideLayout": true
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_sys_bytes{job=\"miniflux\"}",
"interval": "",
"legendFormat": "{{ instance }} - Memory Used",
"refId": "A"
}
],
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 22,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "histogram_quantile(0.95, sum(rate(miniflux_scraper_request_duration_bucket[5m])) by (le))",
"interval": "",
"legendFormat": "Request Duration",
"refId": "A"
}
],
"title": "Scraper Request Duration",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 20,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "histogram_quantile(0.95, sum(rate(miniflux_background_feed_refresh_duration_bucket[5m])) by (le))",
"interval": "",
"legendFormat": "Refresh Duration",
"refId": "A"
}
],
"title": "Background Feed Refresh Duration",
"type": "timeseries"
},
{
"collapsed": false,
"datasource": {
"uid": "Prometheus"
},
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 16
},
"id": 28,
"panels": [],
"targets": [
{
"datasource": {
"uid": "Prometheus"
},
"refId": "A"
}
],
"title": "Process",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 17
},
"id": 16,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_sys_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }}",
"refId": "A"
}
],
"title": "Total Used Memory",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 17
},
"id": 6,
"options": {
"legend": {
"calcs": [
"mean",
"lastNotNull",
"max",
"min"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "process_open_fds{job=\"miniflux\"}",
"interval": "",
"legendFormat": "{{instance }} - Open File Descriptors",
"refId": "A"
}
],
"title": "File Descriptors",
"type": "timeseries"
},
{
"collapsed": false,
"datasource": {
"uid": "Prometheus"
},
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 25
},
"id": 26,
"panels": [],
"targets": [
{
"datasource": {
"uid": "Prometheus"
},
"refId": "A"
}
],
"title": "Go Metrics",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "bytes"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "alloc rate"
},
"properties": [
{
"id": "unit",
"value": "Bps"
}
]
}
]
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 26
},
"id": 12,
"options": {
"legend": {
"calcs": [
"mean",
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_alloc_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 2,
"legendFormat": "bytes allocated",
"metric": "go_memstats_alloc_bytes",
"refId": "A",
"step": 4
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "rate(go_memstats_alloc_bytes_total{job=\"miniflux\"}[30s])",
"format": "time_series",
"interval": "",
"intervalFactor": 2,
"legendFormat": "alloc rate",
"metric": "go_memstats_alloc_bytes_total",
"refId": "B",
"step": 4
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_stack_inuse_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 2,
"legendFormat": "stack inuse",
"metric": "go_memstats_stack_inuse_bytes",
"refId": "C",
"step": 4
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_heap_inuse_bytes{job=\"miniflux\"}",
"format": "time_series",
"hide": false,
"interval": "",
"intervalFactor": 2,
"legendFormat": "heap inuse",
"metric": "go_memstats_heap_inuse_bytes",
"refId": "D",
"step": 4
}
],
"title": "Golang Memory",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 26
},
"id": 8,
"options": {
"legend": {
"calcs": [
"mean",
"lastNotNull",
"max",
"min"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_goroutines{job=\"miniflux\"}",
"interval": "",
"legendFormat": "{{ instance }} - Goroutines",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_threads{job=\"miniflux\"}",
"interval": "",
"legendFormat": "{{ instance }} - OS threads",
"refId": "B"
}
],
"title": "Concurrency",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 33
},
"id": 34,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_stack_inuse_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - stack_inuse",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_stack_sys_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - stack_sys",
"refId": "B"
}
],
"title": "Memory in Stack",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 33
},
"id": 32,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_heap_alloc_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_alloc",
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_heap_sys_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_sys",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_heap_idle_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_idle",
"refId": "C"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_heap_inuse_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_inuse",
"refId": "D"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_heap_released_bytes{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }} - heap_released",
"refId": "E"
}
],
"title": "Memory in Heap",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 41
},
"id": 14,
"options": {
"legend": {
"calcs": [
"mean",
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_gc_duration_seconds{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 2,
"legendFormat": "{{instance}}: {{quantile}}",
"metric": "go_gc_duration_seconds",
"refId": "A",
"step": 4
}
],
"title": "GC Duration Quantiles",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 41
},
"id": 30,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "10.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "go_memstats_mallocs_total{job=\"miniflux\"} - go_memstats_frees_total{job=\"miniflux\"}",
"format": "time_series",
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{ instance }}",
"refId": "A"
}
],
"title": "Number of Live Objects",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": [],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": "prometheus",
"value": "354cc25c-f240-4f6f-a2a9-2d68c22df64e"
},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"multi": false,
"name": "DS_PROMETHEUS",
"options": [],
"query": "prometheus",
"queryValue": "",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
}
]
},
"time": {
"from": "now-24h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"timezone": "",
"title": "Miniflux",
"uid": "vSaPgcFMk",
"version": 3,
"weekStart": ""
} v2-2.3.0/contrib/sysvinit/ 0000775 0000000 0000000 00000000000 15201231005 0015360 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/sysvinit/README.md 0000664 0000000 0000000 00000000202 15201231005 0016631 0 ustar 00root root 0000000 0000000
System-V init for e.g. http://devuan.org
Assumes an executable `/usr/local/bin/miniflux`.
Configure in `etc/default/miniflux`
v2-2.3.0/contrib/sysvinit/etc/ 0000775 0000000 0000000 00000000000 15201231005 0016133 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/sysvinit/etc/default/ 0000775 0000000 0000000 00000000000 15201231005 0017557 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/sysvinit/etc/default/miniflux 0000664 0000000 0000000 00000000516 15201231005 0021337 0 ustar 00root root 0000000 0000000 # sourced by /etc/init.d/miniflux
# see cluster port in pg_lsclusters and ls -Al /var/run/postgresql/
export DATABASE_URL='host=/var/run/postgresql/ port=5433 user=miniflux password= dbname=miniflux sslmode=disable'
export LISTEN_ADDR='127.0.0.1:8081'
export BASE_URL='https:// and path/'
v2-2.3.0/contrib/sysvinit/etc/init.d/ 0000775 0000000 0000000 00000000000 15201231005 0017320 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/sysvinit/etc/init.d/miniflux 0000775 0000000 0000000 00000006230 15201231005 0021102 0 ustar 00root root 0000000 0000000 #! /bin/sh
### BEGIN INIT INFO
# Provides: miniflux
# Required-Start: $syslog $network
# Required-Stop: $syslog
# Should-Start: postgresql
# Should-Stop: postgresql
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: A rss reader
# Description: A RSS reader
### END INIT INFO
# Author: Danny Boisvert
# Do NOT "set -e"
# PATH should only include /usr/* if it runs after the mountnfs.sh script
PATH=/sbin:/usr/sbin:/bin:/usr/bin
DESC="Miniflux"
NAME=miniflux
SERVICEVERBOSE=yes
PIDFILE=/var/run/$NAME.pid
SCRIPTNAME=/etc/init.d/$NAME
WORKINGDIR=/usr/local/bin
DAEMON=$WORKINGDIR/$NAME
DAEMON_ARGS=""
USER=nobody
# Read configuration variable file if it is present
[ -r /etc/default/$NAME ] && . /etc/default/$NAME
# Exit if the package is not installed
[ -x "$DAEMON" ] || exit 0
# Load the VERBOSE setting and other rcS variables
. /lib/init/vars.sh
# Define LSB log_* functions.
# Depend on lsb-base (>= 3.2-14) to ensure that this file is present
# and status_of_proc is working.
. /lib/lsb/init-functions
#
# Function that starts the daemon/service
#
do_start()
{
# Return
# 0 if daemon has been started
# 1 if daemon was already running
# 2 if daemon could not be started
sh -c "USER=$USER start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile \\
--test --chdir $WORKINGDIR --chuid $USER \\
--exec $DAEMON -- $DAEMON_ARGS > /dev/null \\
|| return 1"
sh -c "USER=$USER start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile \\
--background --chdir $WORKINGDIR --chuid $USER \\
--exec $DAEMON -- $DAEMON_ARGS \\
|| return 2"
}
#
# Function that stops the daemon/service
#
do_stop()
{
# Return
# 0 if daemon has been stopped
# 1 if daemon was already stopped
# 2 if daemon could not be stopped
# other if a failure occurred
start-stop-daemon --stop --quiet --retry=TERM/1/KILL/5 --pidfile $PIDFILE --name $NAME
RETVAL="$?"
[ "$RETVAL" = 2 ] && return 2
start-stop-daemon --stop --quiet --oknodo --retry=0/1/KILL/5 --exec $DAEMON
[ "$?" = 2 ] && return 2
# Many daemons don't delete their pidfiles when they exit.
rm -f $PIDFILE
return "$RETVAL"
}
case "$1" in
start)
[ "$SERVICEVERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME"
do_start
case "$?" in
0|1) [ "$SERVICEVERBOSE" != no ] && log_end_msg 0 ;;
2) [ "$SERVICEVERBOSE" != no ] && log_end_msg 1 ;;
esac
;;
stop)
[ "$SERVICEVERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME"
do_stop
case "$?" in
0|1) [ "$SERVICEVERBOSE" != no ] && log_end_msg 0 ;;
2) [ "$SERVICEVERBOSE" != no ] && log_end_msg 1 ;;
esac
;;
status)
status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $?
;;
restart|force-reload)
log_daemon_msg "Restarting $DESC" "$NAME"
do_stop
case "$?" in
0|1)
do_start
case "$?" in
0) log_end_msg 0 ;;
1) log_end_msg 1 ;; # Old process is still running
*) log_end_msg 1 ;; # Failed to start
esac
;;
*)
# Failed to stop
log_end_msg 1
;;
esac
;;
*)
echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2
exit 3
;;
esac
v2-2.3.0/contrib/thunder_client/ 0000775 0000000 0000000 00000000000 15201231005 0016477 5 ustar 00root root 0000000 0000000 v2-2.3.0/contrib/thunder_client/README.md 0000664 0000000 0000000 00000000451 15201231005 0017756 0 ustar 00root root 0000000 0000000 Miniflux API Collection for Thunder Client VS Code Extension
============================================================
Official website: https://www.thunderclient.com
This folder contains the API endpoints collection for Miniflux. You can import it locally to interact with the Miniflux API.
v2-2.3.0/contrib/thunder_client/collection.json 0000664 0000000 0000000 00000062715 15201231005 0021540 0 ustar 00root root 0000000 0000000 {
"client": "Thunder Client",
"collectionName": "Miniflux v2",
"dateExported": "2023-07-31T01:53:38.743Z",
"version": "1.1",
"folders": [],
"requests": [
{
"_id": "d23fb9ba-c0c1-46ff-93f4-c5ed24ecd56e",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Discover Subscriptions",
"url": "/v1/discover",
"method": "POST",
"sortNum": 20000,
"created": "2023-07-31T01:20:12.275Z",
"modified": "2023-07-31T01:29:39.751Z",
"headers": [],
"params": [],
"body": {
"type": "json",
"raw": "\n{\n \"url\": \"https://miniflux.app/\"\n}",
"form": []
},
"tests": []
},
{
"_id": "29cfc679-31d4-4d8c-b843-ab92a74dfa85",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Feeds",
"url": "/v1/feeds",
"method": "GET",
"sortNum": 50000,
"created": "2023-07-31T01:20:12.276Z",
"modified": "2023-07-31T01:20:12.276Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "52a88df8-41c7-47c2-a635-8c93d7d29f40",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Category Feeds",
"url": "/v1/categories/1/feeds",
"method": "GET",
"sortNum": 60000,
"created": "2023-07-31T01:20:12.277Z",
"modified": "2023-07-31T01:20:12.277Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "a5c2cb48-a4cf-4edc-a0e0-927d9f711843",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Feed",
"url": "/v1/feeds/{feedID}",
"method": "GET",
"sortNum": 70000,
"created": "2023-07-31T01:20:12.279Z",
"modified": "2023-07-31T01:31:11.478Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "fb55b058-c2ba-4785-be92-a98f0596e86e",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Feed Icon ",
"url": "/v1/feeds/{feedID}/icon",
"method": "GET",
"sortNum": 80000,
"created": "2023-07-31T01:20:12.280Z",
"modified": "2023-07-31T01:31:18.174Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "c0ec9a45-263e-4627-a13b-b5df901a6456",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Create Feed ",
"url": "/v1/feeds",
"method": "POST",
"sortNum": 90000,
"created": "2023-07-31T01:20:12.281Z",
"modified": "2023-07-31T01:31:31.415Z",
"headers": [],
"params": [],
"body": {
"type": "json",
"raw": "{\n \"feed_url\": \"https://miniflux.app/feed.xml\",\n \"category_id\": 1\n}",
"form": []
},
"tests": []
},
{
"_id": "f4c078a2-c031-4753-a7a4-4987439a61d0",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Update Feed",
"url": "/v1/feeds/{feedID}",
"method": "PUT",
"sortNum": 100000,
"created": "2023-07-31T01:20:12.282Z",
"modified": "2023-07-31T01:31:48.115Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "1",
"isPath": true
}
],
"body": {
"type": "json",
"raw": "{\n \"title\": \"Updated - New Feed Title\",\n \"category_id\": 1\n}",
"form": []
},
"tests": []
},
{
"_id": "1e47aeab-09ce-439b-907f-f9347b98b160",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Refresh Feed",
"url": "/v1/feeds/{feedID}/refresh",
"method": "PUT",
"sortNum": 110000,
"created": "2023-07-31T01:20:12.283Z",
"modified": "2023-07-31T01:31:58.778Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "4f643fa6-042d-4e95-8194-4cb0af7102bf",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Refresh All Feeds",
"url": "/v1/feeds/refresh",
"method": "PUT",
"sortNum": 115000,
"created": "2023-07-31T01:20:12.312Z",
"modified": "2023-07-31T01:20:12.312Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "d829f651-e9b9-41f9-aa9e-bd830d5e6389",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Remove Feed",
"url": "/v1/feeds/{feedID}",
"method": "DELETE",
"sortNum": 120000,
"created": "2023-07-31T01:20:12.284Z",
"modified": "2023-07-31T01:32:16.723Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "deafbf1a-d9e0-420f-a749-1bdde56772cb",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Feed Entries",
"url": "/v1/feeds/{feedID}/entries",
"method": "GET",
"sortNum": 130000,
"created": "2023-07-31T01:20:12.285Z",
"modified": "2023-07-31T01:32:52.812Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "2",
"isPath": true
}
],
"tests": []
},
{
"_id": "0052e903-75fc-48ec-8fd5-6e8784ed401a",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Entry",
"url": "/v1/entries/{entryID}",
"method": "GET",
"sortNum": 140000,
"created": "2023-07-31T01:20:12.286Z",
"modified": "2023-07-31T01:33:30.417Z",
"headers": [],
"params": [
{
"name": "entryID",
"value": "19",
"isPath": true
}
],
"tests": []
},
{
"_id": "1a055ace-2629-4298-9ea0-1bd17d59a4d6",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Fetch original article",
"url": "/v1/entries/{entryID}/fetch-content",
"method": "GET",
"sortNum": 150000,
"created": "2023-07-31T01:20:12.287Z",
"modified": "2023-07-31T01:33:41.014Z",
"headers": [],
"params": [
{
"name": "entryID",
"value": "19",
"isPath": true
}
],
"tests": []
},
{
"_id": "f272d1e6-ebbb-4c58-a159-4412ad657136",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Category Entries",
"url": "/v1/categories/{categoryID}/entries",
"method": "GET",
"sortNum": 160000,
"created": "2023-07-31T01:20:12.288Z",
"modified": "2023-07-31T01:20:12.288Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "856ed091-318a-4a76-b7ce-6475106dd6b5",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Mark All Feed Entries as Read",
"url": "/v1/feeds/{feedID}/mark-all-as-read",
"method": "PUT",
"sortNum": 180000,
"created": "2023-07-31T01:20:12.290Z",
"modified": "2023-07-31T01:46:57.443Z",
"headers": [],
"params": [
{
"name": "feedID",
"value": "2",
"isPath": true
}
],
"tests": []
},
{
"_id": "67749962-d646-45d5-8b78-a8eeaa7cb971",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Entries",
"url": "/v1/entries",
"method": "GET",
"sortNum": 190000,
"created": "2023-07-31T01:20:12.291Z",
"modified": "2023-07-31T01:20:12.291Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "b55ae165-2abe-41f0-8b8a-14d826238d20",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Change Entries Status",
"url": "/v1/entries",
"method": "PUT",
"sortNum": 200000,
"created": "2023-07-31T01:20:12.292Z",
"modified": "2023-07-31T01:46:46.133Z",
"headers": [],
"params": [],
"body": {
"type": "json",
"raw": "{\n \"entry_ids\": [19, 20],\n \"status\": \"read\"\n}",
"form": []
},
"tests": []
},
{
"_id": "710dfc55-fc4e-48ab-989e-3ed78019d6c3",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Toggle Entry Bookmark",
"url": "/v1/entries/{entryID}/bookmark",
"method": "PUT",
"sortNum": 210000,
"created": "2023-07-31T01:20:12.293Z",
"modified": "2023-07-31T01:45:51.933Z",
"headers": [],
"params": [
{
"name": "entryID",
"value": "19",
"isPath": true
}
],
"tests": []
},
{
"_id": "19edbe55-0a0a-4102-bde0-73ed6d8515f6",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Save Entry to Third-Party Service",
"url": "/v1/entries/{entryID}/save",
"method": "POST",
"sortNum": 215000,
"created": "2023-07-31T01:20:12.313Z",
"modified": "2023-07-31T01:20:12.313Z",
"headers": [],
"params": [
{
"name": "entryID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "13d2cf52-aa08-4f7f-a83d-ffcb1e1190cd",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Categories",
"url": "/v1/categories",
"method": "GET",
"sortNum": 220000,
"created": "2023-07-31T01:20:12.294Z",
"modified": "2023-07-31T01:20:12.294Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "1547dabe-2bcb-4e06-acaa-fb393d1027e2",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Create Category ",
"url": "/v1/categories",
"method": "POST",
"sortNum": 230000,
"created": "2023-07-31T01:20:12.295Z",
"modified": "2023-07-31T01:20:12.295Z",
"headers": [],
"params": [],
"body": {
"type": "json",
"raw": "{\n \"title\": \"My category\"\n}",
"form": []
},
"tests": []
},
{
"_id": "e8dac503-19dc-434d-832f-eac4364785d8",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Update Category",
"url": "/v1/categories/{categoryID}",
"method": "PUT",
"sortNum": 232500,
"created": "2023-07-31T01:20:12.296Z",
"modified": "2023-07-31T01:42:55.831Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "3",
"isPath": true
}
],
"body": {
"type": "json",
"raw": "\n{\n \"title\": \"My new title\"\n}",
"form": []
},
"tests": []
},
{
"_id": "86d74247-7f12-4a6e-91b3-fad9e7b6b1fb",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Delete Category",
"url": "/v1/categories/{categoryID}",
"method": "DELETE",
"sortNum": 235000,
"created": "2023-07-31T01:20:12.298Z",
"modified": "2023-07-31T01:44:21.486Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "3",
"isPath": true
}
],
"tests": []
},
{
"_id": "668dde80-ed03-4fa6-ad2a-9cacd0ec31eb",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Mark Category Entries as Read",
"url": "/v1/categories/{categoryID}/mark-all-as-read",
"method": "PUT",
"sortNum": 237500,
"created": "2023-07-31T01:20:12.299Z",
"modified": "2023-07-31T01:43:50.637Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "39ada469-765e-4584-ab00-9d263bd526a1",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Category Feeds",
"url": "/v1/categories/{categoryID}/feeds",
"method": "GET",
"sortNum": 243750,
"created": "2023-07-31T01:50:23.959Z",
"modified": "2023-07-31T01:50:51.443Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "ec389c41-185f-4b57-a373-c6ff952b4282",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Refresh Category Feeds",
"url": "/v1/categories/{categoryID}/refresh",
"method": "PUT",
"sortNum": 250000,
"created": "2023-07-31T01:20:12.297Z",
"modified": "2023-07-31T01:43:23.102Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "bc4a7578-c95e-4436-bbfa-61ccc4a8fc71",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Category Entries",
"url": "/v1/categories/{categoryID}/entries",
"method": "GET",
"sortNum": 257500,
"created": "2023-07-31T01:51:15.403Z",
"modified": "2023-07-31T01:51:35.106Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "fa935fb3-3ed6-4ee3-b995-6c054766d109",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Category Entry",
"url": "/v1/categories/{categoryID}/entries/{entryID}",
"method": "GET",
"sortNum": 258750,
"created": "2023-07-31T01:51:46.699Z",
"modified": "2023-07-31T01:52:12.155Z",
"headers": [],
"params": [
{
"name": "categoryID",
"value": "1",
"isPath": true
},
{
"name": "entryID",
"value": "19",
"isPath": true
}
],
"tests": []
},
{
"_id": "cb6968e9-8d13-4410-9ad5-85847b73d7eb",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "OPML Export",
"url": "/v1/export",
"method": "GET",
"sortNum": 280000,
"created": "2023-07-31T01:20:12.300Z",
"modified": "2023-07-31T01:20:12.300Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "169a64e1-08dd-4760-b405-a748a5286b38",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "OPML Import",
"url": "/v1/import",
"method": "POST",
"sortNum": 290000,
"created": "2023-07-31T01:20:12.301Z",
"modified": "2023-07-31T01:41:31.218Z",
"headers": [],
"params": [],
"body": {
"type": "xml",
"raw": "\n\n \n Miniflux\n Sun, 30 Jul 2023 18:41:08 PDT\n \n \n \n \n \n \n",
"form": []
},
"tests": []
},
{
"_id": "bfb7264a-7b46-49fe-b451-fb6d9b03f0b2",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Create User",
"url": "/v1/users",
"method": "POST",
"sortNum": 300000,
"created": "2023-07-31T01:20:12.302Z",
"modified": "2023-07-31T01:20:12.302Z",
"headers": [],
"params": [],
"body": {
"type": "json",
"raw": "{\n \"username\": \"bob\",\n \"password\": \"test123\",\n \"is_admin\": false\n}",
"form": []
},
"tests": []
},
{
"_id": "93c1dcc2-bf09-4e8e-86ba-0c042147a48f",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Update User",
"url": "/v1/users/{userID}",
"method": "PUT",
"sortNum": 310000,
"created": "2023-07-31T01:20:12.303Z",
"modified": "2023-07-31T01:40:09.576Z",
"headers": [],
"params": [
{
"name": "userID",
"value": "2",
"isPath": true
}
],
"body": {
"type": "json",
"raw": "{\n \"username\": \"joe\"\n}",
"form": []
},
"tests": []
},
{
"_id": "19cf34c1-eb0a-4442-a682-2e94c4f5e594",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Current User",
"url": "/v1/me",
"method": "GET",
"sortNum": 320000,
"created": "2023-07-31T01:20:12.304Z",
"modified": "2023-07-31T01:20:12.304Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "4a700f7c-8762-4cab-aab1-2d8066884d69",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get User by ID",
"url": "/v1/users/{userID}",
"method": "GET",
"sortNum": 330000,
"created": "2023-07-31T01:20:12.305Z",
"modified": "2023-07-31T01:39:38.472Z",
"headers": [],
"params": [
{
"name": "userID",
"value": "1",
"isPath": true
}
],
"tests": []
},
{
"_id": "66cb0985-5ed4-4b1e-9029-8605b7f5f74e",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get User by username",
"url": "/v1/users/{username}",
"method": "GET",
"sortNum": 335000,
"created": "2023-07-31T01:47:53.649Z",
"modified": "2023-07-31T01:48:10.655Z",
"headers": [],
"params": [
{
"name": "username",
"value": "admin",
"isPath": true
}
],
"tests": []
},
{
"_id": "3d4b227a-83a2-4d87-a0ed-ce9d5497aea6",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Get Users",
"url": "/v1/users",
"method": "GET",
"sortNum": 340000,
"created": "2023-07-31T01:20:12.306Z",
"modified": "2023-07-31T01:20:12.306Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "90138dea-799a-4b44-ad68-fce6ec5898a6",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Delete User",
"url": "/v1/users/{userID}",
"method": "DELETE",
"sortNum": 350000,
"created": "2023-07-31T01:20:12.307Z",
"modified": "2023-07-31T01:40:38.124Z",
"headers": [],
"params": [
{
"name": "userID",
"value": "2",
"isPath": true
}
],
"tests": []
},
{
"_id": "4b3bf7ca-bc55-423b-a3ee-6279c10a0d85",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Fetch Read/Unread Counters",
"url": "/v1/feeds/counters",
"method": "GET",
"sortNum": 370000,
"created": "2023-07-31T01:20:12.309Z",
"modified": "2023-07-31T01:20:12.309Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "7721682f-31e3-4d71-8df9-02e30e4729d7",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Healthcheck",
"url": "/healthcheck",
"method": "GET",
"sortNum": 380000,
"created": "2023-07-31T01:20:12.310Z",
"modified": "2023-07-31T01:20:12.310Z",
"headers": [],
"params": [],
"tests": []
},
{
"_id": "64410254-b17a-43e4-984d-10b9b13c5818",
"colId": "fc35618a-f39f-40a0-a443-d4ae568baa8e",
"containerId": "",
"name": "Version",
"url": "/version",
"method": "GET",
"sortNum": 390000,
"created": "2023-07-31T01:20:12.311Z",
"modified": "2023-07-31T01:20:12.311Z",
"headers": [],
"params": [],
"tests": []
}
],
"settings": {
"auth": {
"type": "basic",
"basic": {
"username": "admin",
"password": "test123"
}
},
"options": {
"baseUrl": "http://localhost:8080"
}
}
} v2-2.3.0/go.mod 0000664 0000000 0000000 00000003211 15201231005 0013133 0 ustar 00root root 0000000 0000000 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.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.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.43.0
golang.org/x/text v0.37.0
)
require (
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
)
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.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.44.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
v2-2.3.0/go.sum 0000664 0000000 0000000 00000034620 15201231005 0013170 0 ustar 00root root 0000000 0000000 github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.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.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.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=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.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=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/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=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/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=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/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=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
v2-2.3.0/internal/ 0000775 0000000 0000000 00000000000 15201231005 0013644 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/api/ 0000775 0000000 0000000 00000000000 15201231005 0014415 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/api/api.go 0000664 0000000 0000000 00000010727 15201231005 0015524 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"net/http"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/worker"
)
type handler struct {
store *storage.Storage
pool *worker.Pool
}
// NewHandler returns an http.Handler that handles API v1 calls.
// The returned handler expects the base path to be stripped from the request URL.
func NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {
handler := &handler{store: store, pool: pool}
middleware := newMiddleware(store)
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/users", handler.createUserHandler)
mux.HandleFunc("GET /v1/users", handler.usersHandler)
mux.HandleFunc("GET /v1/users/{identifier}", handler.dispatchUserLookupHandler)
mux.HandleFunc("PUT /v1/users/{userID}", handler.updateUserHandler)
mux.HandleFunc("DELETE /v1/users/{userID}", handler.removeUserHandler)
mux.HandleFunc("PUT /v1/users/{userID}/mark-all-as-read", handler.markUserAsReadHandler)
mux.HandleFunc("GET /v1/me", handler.currentUserHandler)
mux.HandleFunc("POST /v1/categories", handler.createCategoryHandler)
mux.HandleFunc("GET /v1/categories", handler.getCategoriesHandler)
mux.HandleFunc("PUT /v1/categories/{categoryID}", handler.updateCategoryHandler)
mux.HandleFunc("DELETE /v1/categories/{categoryID}", handler.removeCategoryHandler)
mux.HandleFunc("PUT /v1/categories/{categoryID}/mark-all-as-read", handler.markCategoryAsReadHandler)
mux.HandleFunc("GET /v1/categories/{categoryID}/feeds", handler.getCategoryFeedsHandler)
mux.HandleFunc("PUT /v1/categories/{categoryID}/refresh", handler.refreshCategoryHandler)
mux.HandleFunc("GET /v1/categories/{categoryID}/entries", handler.getCategoryEntriesHandler)
mux.HandleFunc("GET /v1/categories/{categoryID}/entries/{entryID}", handler.getCategoryEntryHandler)
mux.HandleFunc("POST /v1/discover", handler.discoverSubscriptionsHandler)
mux.HandleFunc("POST /v1/feeds", handler.createFeedHandler)
mux.HandleFunc("GET /v1/feeds", handler.getFeedsHandler)
mux.HandleFunc("GET /v1/feeds/counters", handler.fetchCountersHandler)
mux.HandleFunc("PUT /v1/feeds/refresh", handler.refreshAllFeedsHandler)
mux.HandleFunc("PUT /v1/feeds/{feedID}/refresh", handler.refreshFeedHandler)
mux.HandleFunc("GET /v1/feeds/{feedID}", handler.getFeedHandler)
mux.HandleFunc("PUT /v1/feeds/{feedID}", handler.updateFeedHandler)
mux.HandleFunc("DELETE /v1/feeds/{feedID}", handler.removeFeedHandler)
mux.HandleFunc("GET /v1/feeds/{feedID}/icon", handler.getIconByFeedIDHandler)
mux.HandleFunc("PUT /v1/feeds/{feedID}/mark-all-as-read", handler.markFeedAsReadHandler)
mux.HandleFunc("GET /v1/export", handler.exportFeedsHandler)
mux.HandleFunc("POST /v1/import", handler.importFeedsHandler)
mux.HandleFunc("GET /v1/feeds/{feedID}/entries", handler.getFeedEntriesHandler)
mux.HandleFunc("POST /v1/feeds/{feedID}/entries/import", handler.importFeedEntryHandler)
mux.HandleFunc("GET /v1/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler)
mux.HandleFunc("GET /v1/entries", handler.getEntriesHandler)
mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusHandler)
mux.HandleFunc("GET /v1/entries/{entryID}", handler.getEntryHandler)
mux.HandleFunc("PUT /v1/entries/{entryID}", handler.updateEntryHandler)
mux.HandleFunc("PUT /v1/entries/{entryID}/bookmark", handler.toggleStarredHandler)
mux.HandleFunc("PUT /v1/entries/{entryID}/star", handler.toggleStarredHandler)
mux.HandleFunc("POST /v1/entries/{entryID}/save", handler.saveEntryHandler)
mux.HandleFunc("GET /v1/entries/{entryID}/fetch-content", handler.fetchContentHandler)
mux.HandleFunc("PUT /v1/flush-history", handler.flushHistoryHandler)
mux.HandleFunc("DELETE /v1/flush-history", handler.flushHistoryHandler)
mux.HandleFunc("GET /v1/icons/{iconID}", handler.getIconByIconIDHandler)
mux.HandleFunc("GET /v1/enclosures/{enclosureID}", handler.getEnclosureByIDHandler)
mux.HandleFunc("PUT /v1/enclosures/{enclosureID}", handler.updateEnclosureByIDHandler)
mux.HandleFunc("GET /v1/integrations/status", handler.getIntegrationsStatusHandler)
mux.HandleFunc("GET /v1/version", handler.versionHandler)
mux.HandleFunc("POST /v1/api-keys", handler.createAPIKeyHandler)
mux.HandleFunc("GET /v1/api-keys", handler.getAPIKeysHandler)
mux.HandleFunc("DELETE /v1/api-keys/{apiKeyID}", handler.deleteAPIKeyHandler)
return middleware.withCORSHeaders(middleware.validateAPIKeyAuth(middleware.validateBasicAuth(mux)))
}
v2-2.3.0/internal/api/api_integration_test.go 0000664 0000000 0000000 00000254517 15201231005 0021175 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"bytes"
"errors"
"fmt"
"io"
"math/rand/v2"
"os"
"strings"
"testing"
miniflux "miniflux.app/v2/client"
"miniflux.app/v2/internal/model"
)
const skipIntegrationTestsMessage = `Set TEST_MINIFLUX_* environment variables to run the API integration tests`
type integrationTestConfig struct {
testBaseURL string
testAdminUsername string
testAdminPassword string
testRegularUsername string
testRegularPassword string
testFeedURL string
testFeedTitle string
testSubscriptionTitle string
testWebsiteURL string
}
func newIntegrationTestConfig() *integrationTestConfig {
getDefaultEnvValues := func(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
return &integrationTestConfig{
testBaseURL: getDefaultEnvValues("TEST_MINIFLUX_BASE_URL", ""),
testAdminUsername: getDefaultEnvValues("TEST_MINIFLUX_ADMIN_USERNAME", ""),
testAdminPassword: getDefaultEnvValues("TEST_MINIFLUX_ADMIN_PASSWORD", ""),
testRegularUsername: getDefaultEnvValues("TEST_MINIFLUX_REGULAR_USERNAME_PREFIX", "regular_test_user"),
testRegularPassword: getDefaultEnvValues("TEST_MINIFLUX_REGULAR_PASSWORD", "regular_test_user_password"),
testFeedURL: getDefaultEnvValues("TEST_MINIFLUX_FEED_URL", "https://miniflux.app/feed.xml"),
testFeedTitle: getDefaultEnvValues("TEST_MINIFLUX_FEED_TITLE", "Miniflux"),
testSubscriptionTitle: getDefaultEnvValues("TEST_MINIFLUX_SUBSCRIPTION_TITLE", "Miniflux Releases"),
testWebsiteURL: getDefaultEnvValues("TEST_MINIFLUX_WEBSITE_URL", "https://miniflux.app/"),
}
}
func (c *integrationTestConfig) isConfigured() bool {
return c.testBaseURL != "" && c.testAdminUsername != "" && c.testAdminPassword != "" && c.testFeedURL != "" && c.testFeedTitle != "" && c.testSubscriptionTitle != "" && c.testWebsiteURL != ""
}
func (c *integrationTestConfig) genRandomUsername() string {
return fmt.Sprintf("%s_%10d", c.testRegularUsername, rand.Int())
}
func TestIncorrectEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient("incorrect url")
if _, err := client.Users(); err == nil {
t.Fatal(`Using an incorrect URL should raise an error`)
}
client = miniflux.NewClient("")
if _, err := client.Users(); err == nil {
t.Fatal(`Using an empty URL should raise an error`)
}
}
func TestHealthcheckEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL)
if err := client.Healthcheck(); err != nil {
t.Fatal(err)
}
}
func TestVersionEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
version, err := client.Version()
if err != nil {
t.Fatal(err)
}
if version.Version == "" {
t.Fatal(`Version should not be empty`)
}
if version.Commit == "" {
t.Fatal(`Commit should not be empty`)
}
if version.BuildDate == "" {
t.Fatal(`Build date should not be empty`)
}
if version.GoVersion == "" {
t.Fatal(`Go version should not be empty`)
}
if version.Compiler == "" {
t.Fatal(`Compiler should not be empty`)
}
if version.Arch == "" {
t.Fatal(`Arch should not be empty`)
}
if version.OS == "" {
t.Fatal(`OS should not be empty`)
}
}
func TestInvalidCredentials(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, "invalid", "invalid")
_, err := client.Users()
if err == nil {
t.Fatal(`Using bad credentials should raise an error`)
}
if err != miniflux.ErrNotAuthorized {
t.Fatal(`A "Not Authorized" error should be raised`)
}
}
func TestGetMeEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
user, err := client.Me()
if err != nil {
t.Fatal(err)
}
if user.Username != testConfig.testAdminUsername {
t.Fatalf(`Invalid username, got %q instead of %q`, user.Username, testConfig.testAdminUsername)
}
}
func TestGetUsersEndpointAsAdmin(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
users, err := client.Users()
if err != nil {
t.Fatal(err)
}
if len(users) == 0 {
t.Fatal(`Users should not be empty`)
}
if users[0].ID == 0 {
t.Fatalf(`Invalid userID, got "%v"`, users[0].ID)
}
if users[0].Username != testConfig.testAdminUsername {
t.Fatalf(`Invalid username, got "%v" instead of "%v"`, users[0].Username, testConfig.testAdminUsername)
}
if users[0].Password != "" {
t.Fatalf(`Invalid password, got "%v"`, users[0].Password)
}
if users[0].Language != "en_US" {
t.Fatalf(`Invalid language, got "%v"`, users[0].Language)
}
if users[0].Theme != "light_serif" {
t.Fatalf(`Invalid theme, got "%v"`, users[0].Theme)
}
if users[0].Timezone != "UTC" {
t.Fatalf(`Invalid timezone, got "%v"`, users[0].Timezone)
}
if !users[0].IsAdmin {
t.Fatalf(`Invalid role, got "%v"`, users[0].IsAdmin)
}
if users[0].EntriesPerPage != 100 {
t.Fatalf(`Invalid entries per page, got "%v"`, users[0].EntriesPerPage)
}
if users[0].DisplayMode != "standalone" {
t.Fatalf(`Invalid web app display mode, got "%v"`, users[0].DisplayMode)
}
if users[0].GestureNav != "tap" {
t.Fatalf(`Invalid gesture navigation, got "%v"`, users[0].GestureNav)
}
if users[0].DefaultReadingSpeed != 265 {
t.Fatalf(`Invalid default reading speed, got "%v"`, users[0].DefaultReadingSpeed)
}
if users[0].CJKReadingSpeed != 500 {
t.Fatalf(`Invalid cjk reading speed, got "%v"`, users[0].CJKReadingSpeed)
}
}
func TestGetUsersEndpointAsRegularUser(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)
_, err = regularUserClient.Users()
if err == nil {
t.Fatal(`Regular users should not have access to the users endpoint`)
}
}
func TestCreateUserEndpointAsAdmin(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
username := testConfig.genRandomUsername()
regularTestUser, err := client.CreateUser(username, testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer client.DeleteUser(regularTestUser.ID)
if regularTestUser.Username != username {
t.Fatalf(`Invalid username, got "%v" instead of "%v"`, regularTestUser.Username, username)
}
if regularTestUser.Password != "" {
t.Fatalf(`Invalid password, got "%v"`, regularTestUser.Password)
}
if regularTestUser.Language != "en_US" {
t.Fatalf(`Invalid language, got "%v"`, regularTestUser.Language)
}
if regularTestUser.Theme != "light_serif" {
t.Fatalf(`Invalid theme, got "%v"`, regularTestUser.Theme)
}
if regularTestUser.Timezone != "UTC" {
t.Fatalf(`Invalid timezone, got "%v"`, regularTestUser.Timezone)
}
if regularTestUser.IsAdmin {
t.Fatalf(`Invalid role, got "%v"`, regularTestUser.IsAdmin)
}
if regularTestUser.EntriesPerPage != 100 {
t.Fatalf(`Invalid entries per page, got "%v"`, regularTestUser.EntriesPerPage)
}
if regularTestUser.DisplayMode != "standalone" {
t.Fatalf(`Invalid web app display mode, got "%v"`, regularTestUser.DisplayMode)
}
if regularTestUser.GestureNav != "tap" {
t.Fatalf(`Invalid gesture navigation, got "%v"`, regularTestUser.GestureNav)
}
if regularTestUser.DefaultReadingSpeed != 265 {
t.Fatalf(`Invalid default reading speed, got "%v"`, regularTestUser.DefaultReadingSpeed)
}
if regularTestUser.CJKReadingSpeed != 500 {
t.Fatalf(`Invalid cjk reading speed, got "%v"`, regularTestUser.CJKReadingSpeed)
}
}
func TestCreateUserEndpointAsRegularUser(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)
_, err = regularUserClient.CreateUser(regularTestUser.Username, testConfig.testRegularPassword, false)
if err == nil {
t.Fatal(`Regular users should not have access to the create user endpoint`)
}
}
func TestCannotCreateDuplicateUser(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.CreateUser(testConfig.testAdminUsername, testConfig.testAdminPassword, true)
if err == nil {
t.Fatal(`Duplicated users should not be allowed`)
}
}
func TestRemoveUserEndpointAsAdmin(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
user, err := client.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
if err := client.DeleteUser(user.ID); err != nil {
t.Fatal(err)
}
}
func TestRemoveUserEndpointAsRegularUser(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)
err = regularUserClient.DeleteUser(regularTestUser.ID)
if err == nil {
t.Fatal(`Regular users should not have access to the remove user endpoint`)
}
}
func TestGetUserByIDEndpointAsAdmin(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
user, err := client.Me()
if err != nil {
t.Fatal(err)
}
userByID, err := client.UserByID(user.ID)
if err != nil {
t.Fatal(err)
}
if userByID.ID != user.ID {
t.Errorf(`Invalid userID, got "%v" instead of "%v"`, userByID.ID, user.ID)
}
if userByID.Username != user.Username {
t.Errorf(`Invalid username, got "%v" instead of "%v"`, userByID.Username, user.Username)
}
if userByID.Password != "" {
t.Errorf(`The password field must be empty, got "%v"`, userByID.Password)
}
if userByID.Language != user.Language {
t.Errorf(`Invalid language, got "%v"`, userByID.Language)
}
if userByID.Theme != user.Theme {
t.Errorf(`Invalid theme, got "%v"`, userByID.Theme)
}
if userByID.Timezone != user.Timezone {
t.Errorf(`Invalid timezone, got "%v"`, userByID.Timezone)
}
if userByID.IsAdmin != user.IsAdmin {
t.Errorf(`Invalid role, got "%v"`, userByID.IsAdmin)
}
if userByID.EntriesPerPage != user.EntriesPerPage {
t.Errorf(`Invalid entries per page, got "%v"`, userByID.EntriesPerPage)
}
if userByID.DisplayMode != user.DisplayMode {
t.Errorf(`Invalid web app display mode, got "%v"`, userByID.DisplayMode)
}
if userByID.GestureNav != user.GestureNav {
t.Errorf(`Invalid gesture navigation, got "%v"`, userByID.GestureNav)
}
if userByID.DefaultReadingSpeed != user.DefaultReadingSpeed {
t.Errorf(`Invalid default reading speed, got "%v"`, userByID.DefaultReadingSpeed)
}
if userByID.CJKReadingSpeed != user.CJKReadingSpeed {
t.Errorf(`Invalid cjk reading speed, got "%v"`, userByID.CJKReadingSpeed)
}
if userByID.EntryDirection != user.EntryDirection {
t.Errorf(`Invalid entry direction, got "%v"`, userByID.EntryDirection)
}
if userByID.EntryOrder != user.EntryOrder {
t.Errorf(`Invalid entry order, got "%v"`, userByID.EntryOrder)
}
}
func TestGetUserByIDEndpointAsRegularUser(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)
_, err = regularUserClient.UserByID(regularTestUser.ID)
if err == nil {
t.Fatal(`Regular users should not have access to the user by ID endpoint`)
}
}
func TestGetUserByUsernameEndpointAsAdmin(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
user, err := client.Me()
if err != nil {
t.Fatal(err)
}
userByUsername, err := client.UserByUsername(user.Username)
if err != nil {
t.Fatal(err)
}
if userByUsername.ID != user.ID {
t.Errorf(`Invalid userID, got "%v" instead of "%v"`, userByUsername.ID, user.ID)
}
if userByUsername.Username != user.Username {
t.Errorf(`Invalid username, got "%v" instead of "%v"`, userByUsername.Username, user.Username)
}
if userByUsername.Password != "" {
t.Errorf(`The password field must be empty, got "%v"`, userByUsername.Password)
}
if userByUsername.Language != user.Language {
t.Errorf(`Invalid language, got "%v"`, userByUsername.Language)
}
if userByUsername.Theme != user.Theme {
t.Errorf(`Invalid theme, got "%v"`, userByUsername.Theme)
}
if userByUsername.Timezone != user.Timezone {
t.Errorf(`Invalid timezone, got "%v"`, userByUsername.Timezone)
}
if userByUsername.IsAdmin != user.IsAdmin {
t.Errorf(`Invalid role, got "%v"`, userByUsername.IsAdmin)
}
if userByUsername.EntriesPerPage != user.EntriesPerPage {
t.Errorf(`Invalid entries per page, got "%v"`, userByUsername.EntriesPerPage)
}
if userByUsername.DisplayMode != user.DisplayMode {
t.Errorf(`Invalid web app display mode, got "%v"`, userByUsername.DisplayMode)
}
if userByUsername.GestureNav != user.GestureNav {
t.Errorf(`Invalid gesture navigation, got "%v"`, userByUsername.GestureNav)
}
if userByUsername.DefaultReadingSpeed != user.DefaultReadingSpeed {
t.Errorf(`Invalid default reading speed, got "%v"`, userByUsername.DefaultReadingSpeed)
}
if userByUsername.CJKReadingSpeed != user.CJKReadingSpeed {
t.Errorf(`Invalid cjk reading speed, got "%v"`, userByUsername.CJKReadingSpeed)
}
if userByUsername.EntryDirection != user.EntryDirection {
t.Errorf(`Invalid entry direction, got "%v"`, userByUsername.EntryDirection)
}
}
func TestGetUserByUsernameEndpointAsRegularUser(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)
_, err = regularUserClient.UserByUsername(regularTestUser.Username)
if err == nil {
t.Fatal(`Regular users should not have access to the user by username endpoint`)
}
}
func TestUpdateUserEndpointByChangingDefaultTheme(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)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: new("dark_serif"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedUser.Theme != "dark_serif" {
t.Fatalf(`Invalid theme, got "%v"`, updatedUser.Theme)
}
}
func TestUpdateUserEndpointByChangingExternalFonts(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)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: new(" fonts.example.org "),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedUser.ExternalFontHosts != "fonts.example.org" {
t.Fatalf(`Invalid external font hosts, got "%v"`, updatedUser.ExternalFontHosts)
}
}
func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(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)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: new("'self' *"),
}
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
t.Fatal(`Updating the user with an invalid external font host should raise an error`)
}
}
func TestUpdateUserEndpointByChangingCustomJS(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)
userUpdateRequest := &miniflux.UserModificationRequest{
CustomJS: new("alert('Hello, World!');"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedUser.CustomJS != "alert('Hello, World!');" {
t.Fatalf(`Invalid custom JS, got %q`, updatedUser.CustomJS)
}
}
func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(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)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: new("invalid_theme"),
}
_, err = regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
if err == nil {
t.Fatal(`Updating the user with an invalid theme should raise an error`)
}
}
func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
adminUser, err := adminClient.Me()
if err != nil {
t.Fatal(err)
}
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)
userUpdateRequest := &miniflux.UserModificationRequest{
Theme: new("dark_serif"),
}
_, err = regularUserClient.UpdateUser(adminUser.ID, userUpdateRequest)
if err == nil {
t.Fatal(`Regular users should not be able to update other users`)
}
}
func TestAPIKeysEndpoint(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)
apiKeys, err := regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 0 {
t.Fatalf(`Expected no API keys, got %d`, len(apiKeys))
}
// Create an API key for the user.
apiKey, err := regularUserClient.CreateAPIKey("Test API Key")
if err != nil {
t.Fatal(err)
}
if apiKey.ID == 0 {
t.Fatalf(`Invalid API key ID, got "%v"`, apiKey.ID)
}
if apiKey.UserID != regularTestUser.ID {
t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKey.UserID, regularTestUser.ID)
}
if apiKey.Token == "" {
t.Fatalf(`Invalid API key token, got "%v"`, apiKey.Token)
}
if apiKey.Description != "Test API Key" {
t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKey.Description)
}
// Create a duplicate API key with the same description.
if _, err := regularUserClient.CreateAPIKey("Test API Key"); err == nil {
t.Fatal(`Creating a duplicate API key with the same description should raise an error`)
}
// Fetch the API keys again.
apiKeys, err = regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 1 {
t.Fatalf(`Expected 1 API key, got %d`, len(apiKeys))
}
if apiKeys[0].ID != apiKey.ID {
t.Fatalf(`Invalid API key ID, got "%v" instead of "%v"`, apiKeys[0].ID, apiKey.ID)
}
if apiKeys[0].UserID != regularTestUser.ID {
t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKeys[0].UserID, regularTestUser.ID)
}
if apiKeys[0].Token != apiKey.Token {
t.Fatalf(`Invalid API key token, got "%v" instead of "%v"`, apiKeys[0].Token, apiKey.Token)
}
if apiKeys[0].Description != "Test API Key" {
t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKeys[0].Description)
}
// Create a new client using the API key.
apiKeyClient := miniflux.NewClient(testConfig.testBaseURL, apiKey.Token)
// Fetch the user using the API key client.
user, err := apiKeyClient.Me()
if err != nil {
t.Fatal(err)
}
// Verify the user matches the regular test user.
if user.ID != regularTestUser.ID {
t.Fatalf(`Expected user ID %d, got %d`, regularTestUser.ID, user.ID)
}
// Delete the API key.
if err := regularUserClient.DeleteAPIKey(apiKey.ID); err != nil {
t.Fatal(err)
}
// Verify the API key is deleted.
apiKeys, err = regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 0 {
t.Fatalf(`Expected no API keys after deletion, got %d`, len(apiKeys))
}
// Try to delete the API key again, it should return an error.
err = regularUserClient.DeleteAPIKey(apiKey.ID)
if err == nil {
t.Fatal(`Deleting a non-existent API key should raise an error`)
}
if !errors.Is(err, miniflux.ErrNotFound) {
t.Fatalf(`Expected "not found" error, got %v`, err)
}
// Try to create an API key with an empty description.
if _, err := regularUserClient.CreateAPIKey(""); err == nil {
t.Fatal(`Creating an API key with an empty description should raise an error`)
}
}
func TestMarkUserAsReadEndpoint(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)
}
if err := regularUserClient.MarkAllAsRead(regularTestUser.ID); err != nil {
t.Fatal(err)
}
results, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatal(err)
}
for _, entry := range results.Entries {
if entry.Status != miniflux.EntryStatusRead {
t.Errorf(`Status for entry %d was %q instead of %q`, entry.ID, entry.Status, miniflux.EntryStatusRead)
}
}
}
func TestCannotMarkUserAsReadAsOtherUser(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
adminUser, err := adminClient.Me()
if err != nil {
t.Fatal(err)
}
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)
if err := regularUserClient.MarkAllAsRead(adminUser.ID); err == nil {
t.Fatalf(`Non-admin users should not be able to mark another user as read`)
}
}
func TestCreateCategoryEndpoint(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)
categoryName := "My category"
category, err := regularUserClient.CreateCategory(categoryName)
if err != nil {
t.Fatal(err)
}
if category.ID == 0 {
t.Errorf(`Invalid categoryID, got "%v"`, category.ID)
}
if category.UserID <= 0 {
t.Errorf(`Invalid userID, got "%v"`, category.UserID)
}
if category.Title != categoryName {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, category.Title, categoryName)
}
if category.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, category.HideGlobally)
}
}
func TestCreateCategoryWithEmptyTitle(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.CreateCategory("")
if err == nil {
t.Fatalf(`Creating a category with an empty title should raise an error`)
}
}
func TestCannotCreateDuplicatedCategory(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)
categoryName := "My category"
if _, err := regularUserClient.CreateCategory(categoryName); err != nil {
t.Fatal(err)
}
if _, err = regularUserClient.CreateCategory(categoryName); err == nil {
t.Fatalf(`Duplicated categories should not be allowed`)
}
}
func TestCreateCategoryWithOptions(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)
newCategory, err := regularUserClient.CreateCategoryWithOptions(&miniflux.CategoryCreationRequest{
Title: "My category",
HideGlobally: true,
})
if err != nil {
t.Fatalf(`Creating a category with options should not raise an error: %v`, err)
}
categories, err := regularUserClient.Categories()
if err != nil {
t.Fatal(err)
}
for _, category := range categories {
if category.ID == newCategory.ID {
if category.Title != newCategory.Title {
t.Errorf(`Invalid title, got %q instead of %q`, category.Title, newCategory.Title)
}
if category.HideGlobally != true {
t.Errorf(`Invalid hide globally value, got "%v"`, category.HideGlobally)
}
break
}
}
}
func TestUpdateCategoryEndpoint(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)
categoryName := "My category"
category, err := regularUserClient.CreateCategory(categoryName)
if err != nil {
t.Fatal(err)
}
updatedCategory, err := regularUserClient.UpdateCategory(category.ID, "new title")
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != category.ID {
t.Errorf(`Invalid categoryID, got "%v"`, updatedCategory.ID)
}
if updatedCategory.UserID != regularTestUser.ID {
t.Errorf(`Invalid userID, got "%v"`, updatedCategory.UserID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
}
if updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
}
func TestUpdateCategoryWithOptions(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)
newCategory, err := regularUserClient.CreateCategoryWithOptions(&miniflux.CategoryCreationRequest{
Title: "My category",
})
if err != nil {
t.Fatalf(`Creating a category with options should not raise an error: %v`, err)
}
updatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
Title: new("new title"),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got "%v"`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
}
if updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: new(true),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got "%v"`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
}
if !updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
HideGlobally: new(false),
})
if err != nil {
t.Fatal(err)
}
if updatedCategory.ID != newCategory.ID {
t.Errorf(`Invalid categoryID, got %d`, updatedCategory.ID)
}
if updatedCategory.Title != "new title" {
t.Errorf(`Invalid title, got %q instead of %q`, updatedCategory.Title, "new title")
}
if updatedCategory.HideGlobally {
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
}
}
func TestUpdateInexistingCategory(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.UpdateCategory(123456789, "new title")
if err == nil {
t.Fatalf(`Updating an inexisting category should raise an error`)
}
}
func TestDeleteCategoryEndpoint(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)
categoryName := "My category"
category, err := regularUserClient.CreateCategory(categoryName)
if err != nil {
t.Fatal(err)
}
if err := regularUserClient.DeleteCategory(category.ID); err != nil {
t.Fatal(err)
}
}
func TestCannotDeleteInexistingCategory(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
err := client.DeleteCategory(123456789)
if err == nil {
t.Fatalf(`Deleting an inexisting category should raise an error`)
}
}
func TestCannotDeleteCategoryOfAnotherUser(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)
category, err := regularUserClient.CreateCategory("My category")
if err != nil {
t.Fatal(err)
}
err = adminClient.DeleteCategory(category.ID)
if err == nil {
t.Fatalf(`Regular users should not be able to delete categories of other users`)
}
}
func TestGetCategoriesEndpoint(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)
category, err := regularUserClient.CreateCategory("My category")
if err != nil {
t.Fatal(err)
}
categories, err := regularUserClient.Categories()
if err != nil {
t.Fatal(err)
}
if len(categories) != 2 {
t.Fatalf(`Invalid number of categories, got %d instead of %d`, len(categories), 1)
}
if categories[0].UserID != regularTestUser.ID {
t.Fatalf(`Invalid userID, got %d`, categories[0].UserID)
}
if categories[0].Title != "All" {
t.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, "All")
}
if categories[0].FeedCount != nil {
t.Errorf(`Expected FeedCount to be nil, got %d`, *categories[0].FeedCount)
}
if categories[0].TotalUnread != nil {
t.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[0].TotalUnread)
}
if categories[1].ID != category.ID {
t.Fatalf(`Invalid categoryID, got %d`, categories[0].ID)
}
if categories[1].UserID != regularTestUser.ID {
t.Fatalf(`Invalid userID, got %d`, categories[0].UserID)
}
if categories[1].Title != "My category" {
t.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, "My category")
}
if categories[1].FeedCount != nil {
t.Errorf(`Expected FeedCount to be nil, got %d`, *categories[1].FeedCount)
}
if categories[1].TotalUnread != nil {
t.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[1].TotalUnread)
}
categories, err = regularUserClient.CategoriesWithCounters()
if err != nil {
t.Fatal(err)
}
if len(categories) != 2 {
t.Fatalf(`Invalid number of categories, got %d instead of %d`, len(categories), 1)
}
if categories[1].FeedCount == nil {
t.Fatalf(`Expected FeedCount to be not nil`)
}
if categories[1].TotalUnread == nil {
t.Fatalf(`Expected TotalUnread to be not nil`)
}
expectedCounterValue := 0
if *categories[1].FeedCount != expectedCounterValue {
t.Errorf(`Expected FeedCount to be %d, got %d`, expectedCounterValue, *categories[1].FeedCount)
}
if *categories[1].TotalUnread != expectedCounterValue {
t.Errorf(`Expected TotalUnread to be %d, got %d`, expectedCounterValue, *categories[1].TotalUnread)
}
}
func TestMarkCategoryAsReadEndpoint(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)
category, err := regularUserClient.CreateCategory("My category")
if err != nil {
t.Fatal(err)
}
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
CategoryID: category.ID,
})
if err != nil {
t.Fatal(err)
}
if err := regularUserClient.MarkCategoryAsRead(category.ID); err != nil {
t.Fatal(err)
}
results, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatal(err)
}
for _, entry := range results.Entries {
if entry.Status != miniflux.EntryStatusRead {
t.Errorf(`Status for entry %d was %q instead of %q`, entry.ID, entry.Status, miniflux.EntryStatusRead)
}
}
}
func TestCreateFeedEndpoint(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)
category, err := regularUserClient.CreateCategory("My category")
if err != nil {
t.Fatal(err)
}
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
CategoryID: category.ID,
})
if err != nil {
t.Fatal(err)
}
if feedID == 0 {
t.Errorf(`Invalid feedID, got "%v"`, feedID)
}
}
func TestCannotCreateDuplicatedFeed(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)
}
if feedID == 0 {
t.Fatalf(`Invalid feedID, got "%v"`, feedID)
}
_, err = regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err == nil {
t.Fatalf(`Duplicated feeds should not be allowed`)
}
}
func TestCreateFeedWithInexistingCategory(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)
_, err = regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
CategoryID: 123456789,
})
if err == nil {
t.Fatalf(`Creating a feed with an inexisting category should raise an error`)
}
}
func TestCreateFeedWithEmptyFeedURL(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: "",
})
if err == nil {
t.Fatalf(`Creating a feed with an empty feed URL should raise an error`)
}
}
func TestCreateFeedWithInvalidFeedURL(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: "invalid_feed_url",
})
if err == nil {
t.Fatalf(`Creating a feed with an invalid feed URL should raise an error`)
}
}
func TestCreateDisabledFeed(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,
Disabled: true,
})
if err != nil {
t.Fatal(err)
}
feed, err := regularUserClient.Feed(feedID)
if err != nil {
t.Fatal(err)
}
if !feed.Disabled {
t.Fatalf(`The feed should be disabled`)
}
}
func TestCreateFeedWithDisabledHTTPCache(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,
IgnoreHTTPCache: true,
})
if err != nil {
t.Fatal(err)
}
feed, err := regularUserClient.Feed(feedID)
if err != nil {
t.Fatal(err)
}
if !feed.IgnoreHTTPCache {
t.Fatalf(`The feed should ignore the HTTP cache`)
}
}
func TestCreateFeedWithScraperRule(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,
ScraperRules: "article",
})
if err != nil {
t.Fatal(err)
}
feed, err := regularUserClient.Feed(feedID)
if err != nil {
t.Fatal(err)
}
if feed.ScraperRules != "article" {
t.Fatalf(`The feed should have the scraper rules set to "article"`)
}
}
func TestUpdateFeedEndpoint(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)
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
FeedURL: new("https://example.org/feed.xml"),
}
updatedFeed, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedFeed.FeedURL != "https://example.org/feed.xml" {
t.Fatalf(`Invalid feed URL, got "%v"`, updatedFeed.FeedURL)
}
}
func TestCannotHaveDuplicateFeedWhenUpdatingFeed(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)
if _, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{FeedURL: testConfig.testFeedURL}); err != nil {
t.Fatal(err)
}
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: "https://github.com/miniflux/v2/commits.atom",
})
if err != nil {
t.Fatal(err)
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
FeedURL: new(testConfig.testFeedURL),
}
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
t.Fatalf(`Duplicated feeds should not be allowed`)
}
}
func TestUpdateFeedWithInvalidCategory(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)
}
feedUpdateRequest := &miniflux.FeedModificationRequest{
CategoryID: new(int64(123456789)),
}
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
t.Fatalf(`Updating a feed with an inexisting category should raise an error`)
}
}
func TestMarkFeedAsReadEndpoint(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)
}
if err := regularUserClient.MarkFeedAsRead(feedID); err != nil {
t.Fatal(err)
}
results, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get updated entries: %v`, err)
}
for _, entry := range results.Entries {
if entry.Status != miniflux.EntryStatusRead {
t.Errorf(`Status for entry %d was %q instead of %q`, entry.ID, entry.Status, miniflux.EntryStatusRead)
}
}
}
func TestFetchCountersEndpoint(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)
}
counters, err := regularUserClient.FetchCounters()
if err != nil {
t.Fatal(err)
}
if value, ok := counters.ReadCounters[feedID]; ok && value != 0 {
t.Errorf(`Invalid read counter, got %d`, value)
}
if value, ok := counters.UnreadCounters[feedID]; !ok || value == 0 {
t.Errorf(`Invalid unread counter, got %d`, value)
}
}
func TestDeleteFeedEndpoint(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)
}
if err := regularUserClient.DeleteFeed(feedID); err != nil {
t.Fatal(err)
}
}
func TestRefreshAllFeedsEndpoint(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)
if err := regularUserClient.RefreshAllFeeds(); err != nil {
t.Fatal(err)
}
}
func TestRefreshFeedEndpoint(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)
}
if err := regularUserClient.RefreshFeed(feedID); err != nil {
t.Fatal(err)
}
}
func TestGetFeedEndpoint(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)
}
feed, err := regularUserClient.Feed(feedID)
if err != nil {
t.Fatal(err)
}
if feed.ID != feedID {
t.Fatalf(`Invalid feedID, got %d`, feed.ID)
}
if feed.FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, feed.FeedURL)
}
if feed.SiteURL != testConfig.testWebsiteURL {
t.Fatalf(`Invalid site URL, got %q`, feed.SiteURL)
}
if feed.Title != testConfig.testFeedTitle {
t.Fatalf(`Invalid title, got %q`, feed.Title)
}
}
func TestGetFeedIcon(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)
}
icon, err := regularUserClient.FeedIcon(feedID)
if err != nil {
t.Fatal(err)
}
if icon == nil {
t.Fatalf(`Invalid icon, got nil`)
}
if icon.MimeType == "" {
t.Fatalf(`Invalid mime type, got %q`, icon.MimeType)
}
if len(icon.Data) == 0 {
t.Fatalf(`Invalid data, got empty`)
}
icon, err = regularUserClient.Icon(icon.ID)
if err != nil {
t.Fatal(err)
}
if icon == nil {
t.Fatalf(`Invalid icon, got nil`)
}
if icon.MimeType == "" {
t.Fatalf(`Invalid mime type, got %q`, icon.MimeType)
}
if len(icon.Data) == 0 {
t.Fatalf(`Invalid data, got empty`)
}
}
func TestGetFeedIconWithInexistingFeedID(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.FeedIcon(123456789)
if err == nil {
t.Fatalf(`Fetching the icon of an inexisting feed should raise an error`)
}
}
func TestGetFeedsEndpoint(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)
}
feeds, err := regularUserClient.Feeds()
if err != nil {
t.Fatal(err)
}
if len(feeds) != 1 {
t.Fatalf(`Invalid number of feeds, got %d`, len(feeds))
}
if feeds[0].ID != feedID {
t.Fatalf(`Invalid feedID, got %d`, feeds[0].ID)
}
if feeds[0].FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, feeds[0].FeedURL)
}
}
func TestGetCategoryFeedsEndpoint(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)
category, err := regularUserClient.CreateCategory("My category")
if err != nil {
t.Fatal(err)
}
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
CategoryID: category.ID,
})
if err != nil {
t.Fatal(err)
}
feeds, err := regularUserClient.CategoryFeeds(category.ID)
if err != nil {
t.Fatal(err)
}
if len(feeds) != 1 {
t.Fatalf(`Invalid number of feeds, got %d`, len(feeds))
}
if feeds[0].ID != feedID {
t.Fatalf(`Invalid feedID, got %d`, feeds[0].ID)
}
if feeds[0].FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, feeds[0].FeedURL)
}
}
func TestExportEndpoint(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)
if _, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{FeedURL: testConfig.testFeedURL}); err != nil {
t.Fatal(err)
}
exportedData, err := regularUserClient.Export()
if err != nil {
t.Fatal(err)
}
if len(exportedData) == 0 {
t.Fatalf(`Invalid exported data, got empty`)
}
if !strings.HasPrefix(string(exportedData), "
`
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
bytesReader := bytes.NewReader([]byte(data))
if err := regularUserClient.Import(io.NopCloser(bytesReader)); err != nil {
t.Fatal(err)
}
}
func TestDiscoverSubscriptionsEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
subscriptions, err := client.Discover(testConfig.testWebsiteURL)
if err != nil {
t.Fatal(err)
}
if len(subscriptions) == 0 {
t.Fatalf(`Invalid number of subscriptions, got %d`, len(subscriptions))
}
if subscriptions[0].Title != testConfig.testSubscriptionTitle {
t.Fatalf(`Invalid title, got %q`, subscriptions[0].Title)
}
if subscriptions[0].URL != testConfig.testFeedURL {
t.Fatalf(`Invalid URL, got %q`, subscriptions[0].URL)
}
}
func TestDiscoverSubscriptionsWithInvalidURL(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
_, err := client.Discover("invalid_url")
if err == nil {
t.Fatalf(`Discovering subscriptions with an invalid URL should raise an error`)
}
}
func TestDiscoverSubscriptionsWithNoSubscription(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
if _, err := client.Discover(testConfig.testBaseURL); err != miniflux.ErrNotFound {
t.Fatalf(`Discovering subscriptions with no subscription should raise a 404 error`)
}
}
func TestGetAllFeedEntriesEndpoint(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)
}
results, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatal(err)
}
if len(results.Entries) == 0 {
t.Fatalf(`Invalid number of entries, got %d`, len(results.Entries))
}
if results.Total == 0 {
t.Fatalf(`Invalid total, got %d`, results.Total)
}
if results.Entries[0].FeedID != feedID {
t.Fatalf(`Invalid feedID, got %d`, results.Entries[0].FeedID)
}
if results.Entries[0].Feed.FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, results.Entries[0].Feed.FeedURL)
}
if results.Entries[0].Title == "" {
t.Fatalf(`Invalid title, got empty`)
}
}
func TestGetAllCategoryEntriesEndpoint(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)
category, err := regularUserClient.CreateCategory("My category")
if err != nil {
t.Fatal(err)
}
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
CategoryID: category.ID,
})
if err != nil {
t.Fatal(err)
}
results, err := regularUserClient.CategoryEntries(category.ID, nil)
if err != nil {
t.Fatal(err)
}
if len(results.Entries) == 0 {
t.Fatalf(`Invalid number of entries, got %d`, len(results.Entries))
}
if results.Total == 0 {
t.Fatalf(`Invalid total, got %d`, results.Total)
}
if results.Entries[0].FeedID != feedID {
t.Fatalf(`Invalid feedID, got %d`, results.Entries[0].FeedID)
}
if results.Entries[0].Feed.FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, results.Entries[0].Feed.FeedURL)
}
if results.Entries[0].Title == "" {
t.Fatalf(`Invalid title, got empty`)
}
}
func TestGetAllEntriesEndpointWithFilter(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 len(feedEntries.Entries) == 0 {
t.Fatalf(`Invalid number of entries, got %d`, len(feedEntries.Entries))
}
if feedEntries.Total == 0 {
t.Fatalf(`Invalid total, got %d`, feedEntries.Total)
}
if feedEntries.Entries[0].FeedID != feedID {
t.Fatalf(`Invalid feedID, got %d`, feedEntries.Entries[0].FeedID)
}
if feedEntries.Entries[0].Feed.FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, feedEntries.Entries[0].Feed.FeedURL)
}
if feedEntries.Entries[0].Title == "" {
t.Fatalf(`Invalid title, got empty`)
}
recentEntries, err := regularUserClient.Entries(&miniflux.Filter{Order: "published_at", Direction: "desc"})
if err != nil {
t.Fatal(err)
}
if len(recentEntries.Entries) == 0 {
t.Fatalf(`Invalid number of entries, got %d`, len(recentEntries.Entries))
}
if recentEntries.Total == 0 {
t.Fatalf(`Invalid total, got %d`, recentEntries.Total)
}
if feedEntries.Entries[0].Title == recentEntries.Entries[0].Title {
t.Fatalf(`Invalid order, got the same title`)
}
searchedEntries, err := regularUserClient.Entries(&miniflux.Filter{Search: "2.0.8"})
if err != nil {
t.Fatal(err)
}
if searchedEntries.Total != 1 {
t.Fatalf(`Invalid total, got %d`, searchedEntries.Total)
}
if _, err := regularUserClient.Entries(&miniflux.Filter{Status: "invalid"}); err == nil {
t.Fatal(`Using invalid status should raise an error`)
}
if _, err = regularUserClient.Entries(&miniflux.Filter{Direction: "invalid"}); err == nil {
t.Fatal(`Using invalid direction should raise an error`)
}
if _, err = regularUserClient.Entries(&miniflux.Filter{Order: "invalid"}); err == nil {
t.Fatal(`Using invalid order should raise an error`)
}
}
func TestGetGlobalEntriesEndpoint(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,
HideGlobally: true,
})
if err != nil {
t.Fatal(err)
}
feedIDEntry, err := regularUserClient.Feed(feedID)
if err != nil {
t.Fatal(err)
}
if feedIDEntry.HideGlobally != true {
t.Fatalf(`Expected feed to have globally_hidden set to true, was false.`)
}
/* Not filtering on GloballyVisible should return all entries */
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if len(feedEntries.Entries) == 0 {
t.Fatalf(`Expected entries but response contained none.`)
}
/* Feed is hidden globally, so this should be empty */
globallyVisibleEntries, err := regularUserClient.Entries(&miniflux.Filter{GloballyVisible: true})
if err != nil {
t.Fatal(err)
}
if len(globallyVisibleEntries.Entries) != 0 {
t.Fatalf(`Expected no entries, got %d`, len(globallyVisibleEntries.Entries))
}
}
func TestUpdateEnclosureEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
var enclosure *miniflux.Enclosure
for _, entry := range result.Entries {
if len(entry.Enclosures) > 0 {
enclosure = entry.Enclosures[0]
break
}
}
if enclosure == nil {
t.Skip(`Skipping test, missing enclosure in feed.`)
}
err = regularUserClient.UpdateEnclosure(enclosure.ID, &miniflux.EnclosureUpdateRequest{
MediaProgression: 20,
})
if err != nil {
t.Fatal(err)
}
updatedEnclosure, err := regularUserClient.Enclosure(enclosure.ID)
if err != nil {
t.Fatal(err)
}
if updatedEnclosure.MediaProgression != 20 {
t.Fatalf(`Failed to update media_progression, expected %d but got %d`, 20, updatedEnclosure.MediaProgression)
}
}
func TestGetEnclosureEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
var expectedEnclosure *miniflux.Enclosure
for _, entry := range result.Entries {
if len(entry.Enclosures) > 0 {
expectedEnclosure = entry.Enclosures[0]
break
}
}
if expectedEnclosure == nil {
t.Skip(`Skipping test, missing enclosure in feed.`)
}
enclosure, err := regularUserClient.Enclosure(expectedEnclosure.ID)
if err != nil {
t.Fatal(err)
}
if enclosure.ID != expectedEnclosure.ID {
t.Fatalf(`Invalid enclosureID, got %d while expecting %d`, enclosure.ID, expectedEnclosure.ID)
}
if _, err = regularUserClient.Enclosure(99999); err == nil {
t.Fatalf(`Fetching an inexisting enclosure should raise an error`)
}
}
func TestGetEntryEndpoints(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
entry, err := regularUserClient.FeedEntry(feedID, result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if entry.ID != result.Entries[0].ID {
t.Fatalf(`Invalid entryID, got %d`, entry.ID)
}
if entry.FeedID != feedID {
t.Fatalf(`Invalid feedID, got %d`, entry.FeedID)
}
if entry.Feed.FeedURL != testConfig.testFeedURL {
t.Fatalf(`Invalid feed URL, got %q`, entry.Feed.FeedURL)
}
entry, err = regularUserClient.Entry(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if entry.ID != result.Entries[0].ID {
t.Fatalf(`Invalid entryID, got %d`, entry.ID)
}
entry, err = regularUserClient.CategoryEntry(result.Entries[0].Feed.Category.ID, result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if entry.ID != result.Entries[0].ID {
t.Fatalf(`Invalid entryID, got %d`, entry.ID)
}
}
func TestUpdateEntryStatusEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusRead); err != nil {
t.Fatal(err)
}
entry, err := regularUserClient.Entry(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if entry.Status != miniflux.EntryStatusRead {
t.Fatalf(`Invalid status, got %q`, entry.Status)
}
}
func TestUpdateEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
entryUpdateRequest := &miniflux.EntryModificationRequest{
Title: new("New title"),
Content: new("New content"),
}
updatedEntry, err := regularUserClient.UpdateEntry(result.Entries[0].ID, entryUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedEntry.Title != "New title" {
t.Errorf(`Invalid title, got %q`, updatedEntry.Title)
}
if updatedEntry.Content != "New content" {
t.Errorf(`Invalid content, got %q`, updatedEntry.Content)
}
entry, err := regularUserClient.Entry(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if entry.Title != "New title" {
t.Errorf(`Invalid title, got %q`, entry.Title)
}
if entry.Content != "New content" {
t.Errorf(`Invalid content, got %q`, entry.Content)
}
}
func TestToggleStarredEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, &miniflux.Filter{Limit: 1})
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
if err := regularUserClient.ToggleStarred(result.Entries[0].ID); err != nil {
t.Fatal(err)
}
entry, err := regularUserClient.Entry(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if !entry.Starred {
t.Fatalf(`The entry should be starred`)
}
}
func TestSaveEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, &miniflux.Filter{Limit: 1})
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
if err := regularUserClient.SaveEntry(result.Entries[0].ID); !errors.Is(err, miniflux.ErrBadRequest) {
t.Fatalf(`Saving an entry should raise a bad request error because no integration is configured`)
}
}
func TestFetchIntegrationsStatusEndpoint(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)
hasIntegrations, err := regularUserClient.IntegrationsStatus()
if err != nil {
t.Fatalf("Failed to fetch integrations status: %v", err)
}
if hasIntegrations {
t.Fatalf("New user should not have integrations configured")
}
}
func TestFetchContentEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, &miniflux.Filter{Limit: 1})
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
content, err := regularUserClient.FetchEntryOriginalContent(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if content == "" {
t.Fatalf(`Invalid content, got empty`)
}
}
func TestFlushHistoryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, &miniflux.Filter{Limit: 3})
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID, result.Entries[1].ID}, miniflux.EntryStatusRead); err != nil {
t.Fatal(err)
}
if err := regularUserClient.FlushHistory(); err != nil {
t.Fatal(err)
}
readEntries, err := regularUserClient.Entries(&miniflux.Filter{Status: miniflux.EntryStatusRead})
if err != nil {
t.Fatal(err)
}
if readEntries.Total != 0 {
t.Fatalf(`Invalid total, got %d`, readEntries.Total)
}
}
func TestImportFeedEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
client := miniflux.NewClient(
testConfig.testBaseURL,
testConfig.testAdminUsername,
testConfig.testAdminPassword,
)
// Create a feed
feedID, err := client.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
defer client.DeleteFeed(feedID)
payload := map[string]any{
"title": "Imported Entry",
"url": "https://example.org/imported-entry",
"content": "Hello world",
"external_id": "integration-test-entry-1",
"status": model.EntryStatusUnread,
"starred": false,
"published_at": 0,
}
// First import
firstID, err := client.ImportFeedEntry(feedID, payload)
if err != nil {
t.Fatal(err)
}
if firstID == 0 {
t.Fatal("expected non-zero entry ID on first import")
}
// Second import (same payload)
secondID, err := client.ImportFeedEntry(feedID, payload)
if err != nil {
t.Fatal(err)
}
if secondID != firstID {
t.Fatalf("expected same entry ID on re-import, got %d and %d", firstID, secondID)
}
}
v2-2.3.0/internal/api/api_key_handlers.go 0000664 0000000 0000000 00000003471 15201231005 0020252 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func (h *handler) createAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var apiKeyCreationRequest model.APIKeyCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&apiKeyCreationRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if validationErr := validator.ValidateAPIKeyCreation(h.store, userID, &apiKeyCreationRequest); validationErr != nil {
response.JSONBadRequest(w, r, validationErr.Error())
return
}
apiKey, err := h.store.CreateAPIKey(userID, apiKeyCreationRequest.Description)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSONCreated(w, r, apiKey)
}
func (h *handler) getAPIKeysHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
apiKeys, err := h.store.APIKeys(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, apiKeys)
}
func (h *handler) deleteAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
apiKeyID := request.RouteInt64Param(r, "apiKeyID")
if apiKeyID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid API key ID"))
return
}
if err := h.store.DeleteAPIKey(userID, apiKeyID); err != nil {
if errors.Is(err, storage.ErrAPIKeyNotFound) {
response.JSONNotFound(w, r)
return
}
response.JSONServerError(w, r, err)
return
}
response.NoContent(w, r)
}
v2-2.3.0/internal/api/api_test.go 0000664 0000000 0000000 00000007055 15201231005 0016563 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"encoding/json"
"net/http"
"net/http/httptest"
"runtime"
"testing"
"miniflux.app/v2/internal/version"
)
func TestNewHandlerHandlesOptionsRequests(t *testing.T) {
handler := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodOptions, "/v1/users", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if got := w.Code; got != http.StatusNoContent {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusNoContent)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf(`Unexpected Access-Control-Allow-Origin header, got %q`, got)
}
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "GET, POST, PUT, DELETE, OPTIONS" {
t.Fatalf(`Unexpected Access-Control-Allow-Methods header, got %q`, got)
}
if got := w.Header().Get("Access-Control-Allow-Headers"); got != "X-Auth-Token, Authorization, Content-Type, Accept" {
t.Fatalf(`Unexpected Access-Control-Allow-Headers header, got %q`, got)
}
if got := w.Header().Get("Access-Control-Max-Age"); got != "3600" {
t.Fatalf(`Unexpected Access-Control-Max-Age header, got %q`, got)
}
}
func TestVersionHandler(t *testing.T) {
h := &handler{}
r := httptest.NewRequest(http.MethodGet, "/v1/version", nil)
w := httptest.NewRecorder()
h.versionHandler(w, r)
if got := w.Code; got != http.StatusOK {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusOK)
}
if got := w.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf(`Unexpected Content-Type header, got %q`, got)
}
var responseBody versionResponse
if err := json.NewDecoder(w.Body).Decode(&responseBody); err != nil {
t.Fatalf("Unexpected JSON decoding error: %v", err)
}
if responseBody.Version != version.Version {
t.Fatalf(`Unexpected version, got %q instead of %q`, responseBody.Version, version.Version)
}
if responseBody.Commit != version.Commit {
t.Fatalf(`Unexpected commit, got %q instead of %q`, responseBody.Commit, version.Commit)
}
if responseBody.BuildDate != version.BuildDate {
t.Fatalf(`Unexpected build date, got %q instead of %q`, responseBody.BuildDate, version.BuildDate)
}
if responseBody.GoVersion != runtime.Version() {
t.Fatalf(`Unexpected Go version, got %q instead of %q`, responseBody.GoVersion, runtime.Version())
}
if responseBody.Compiler != runtime.Compiler {
t.Fatalf(`Unexpected compiler, got %q instead of %q`, responseBody.Compiler, runtime.Compiler)
}
if responseBody.Arch != runtime.GOARCH {
t.Fatalf(`Unexpected architecture, got %q instead of %q`, responseBody.Arch, runtime.GOARCH)
}
if responseBody.OS != runtime.GOOS {
t.Fatalf(`Unexpected OS, got %q instead of %q`, responseBody.OS, runtime.GOOS)
}
}
func TestNewHandlerSupportsBasePathStripping(t *testing.T) {
scenarios := []struct {
name string
prefix string
path string
}{
{name: "empty base path", prefix: "", path: "/v1/users"},
{name: "non empty base path", prefix: "/base", path: "/base/v1/users"},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
handler := http.StripPrefix(scenario.prefix, NewHandler(nil, nil))
r := httptest.NewRequest(http.MethodOptions, scenario.path, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if got := w.Code; got != http.StatusNoContent {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusNoContent)
}
})
}
}
v2-2.3.0/internal/api/category_handlers.go 0000664 0000000 0000000 00000011502 15201231005 0020440 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/validator"
)
func (h *handler) createCategoryHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var categoryCreationRequest model.CategoryCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryCreationRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryCreationRequest); validationErr != nil {
response.JSONBadRequest(w, r, validationErr.Error())
return
}
category, err := h.store.CreateCategory(userID, &categoryCreationRequest)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSONCreated(w, r, category)
}
func (h *handler) updateCategoryHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
category, err := h.store.Category(userID, categoryID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if category == nil {
response.JSONNotFound(w, r)
return
}
var categoryModificationRequest model.CategoryModificationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&categoryModificationRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationErr != nil {
response.JSONBadRequest(w, r, validationErr.Error())
return
}
categoryModificationRequest.Patch(category)
if err := h.store.UpdateCategory(category); err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSONCreated(w, r, category)
}
func (h *handler) markCategoryAsReadHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
category, err := h.store.Category(userID, categoryID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if category == nil {
response.JSONNotFound(w, r)
return
}
if err = h.store.MarkCategoryAsRead(userID, categoryID, time.Now()); err != nil {
response.JSONServerError(w, r, err)
return
}
response.NoContent(w, r)
}
func (h *handler) getCategoriesHandler(w http.ResponseWriter, r *http.Request) {
var categories model.Categories
var err error
includeCounts := request.QueryStringParam(r, "counts", "false")
if includeCounts == "true" {
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))
}
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, categories)
}
func (h *handler) removeCategoryHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
if !h.store.CategoryIDExists(userID, categoryID) {
response.JSONNotFound(w, r)
return
}
if err := h.store.RemoveCategory(userID, categoryID); err != nil {
response.JSONServerError(w, r, err)
return
}
response.NoContent(w, r)
}
func (h *handler) refreshCategoryHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
batchBuilder := h.store.NewBatchBuilder()
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithUserID(userID)
batchBuilder.WithCategoryID(categoryID)
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
if err != nil {
response.JSONServerError(w, r, err)
return
}
slog.Info(
"Triggered a manual refresh of all feeds for a given category from the API",
slog.Int64("user_id", userID),
slog.Int64("category_id", categoryID),
slog.Int("nb_jobs", len(jobs)),
)
go h.pool.Push(jobs)
response.NoContent(w, r)
}
v2-2.3.0/internal/api/enclosure_handlers.go 0000664 0000000 0000000 00000004214 15201231005 0020624 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/validator"
)
func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request) {
enclosureID := request.RouteInt64Param(r, "enclosureID")
if enclosureID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid enclosure ID"))
return
}
enclosure, err := h.store.GetEnclosure(enclosureID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if enclosure == nil {
response.JSONNotFound(w, r)
return
}
userID := request.UserID(r)
if enclosure.UserID != userID {
response.JSONNotFound(w, r)
return
}
enclosure.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
response.JSON(w, r, enclosure)
}
func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Request) {
enclosureID := request.RouteInt64Param(r, "enclosureID")
if enclosureID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid enclosure ID"))
return
}
var enclosureUpdateRequest model.EnclosureUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&enclosureUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if err := validator.ValidateEnclosureUpdateRequest(&enclosureUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
enclosure, err := h.store.GetEnclosure(enclosureID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if enclosure == nil {
response.JSONNotFound(w, r)
return
}
userID := request.UserID(r)
if enclosure.UserID != userID {
response.JSONNotFound(w, r)
return
}
enclosure.MediaProgression = enclosureUpdateRequest.MediaProgression
if err := h.store.UpdateEnclosure(enclosure); err != nil {
response.JSONServerError(w, r, err)
return
}
response.NoContent(w, r)
}
v2-2.3.0/internal/api/entry_handlers.go 0000664 0000000 0000000 00000035531 15201231005 0017774 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"strconv"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/processor"
"miniflux.app/v2/internal/reader/readingtime"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b *storage.EntryQueryBuilder) {
entry, err := b.GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if entry == nil {
response.JSONNotFound(w, r)
return
}
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content)
entry.Enclosures.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
response.JSON(w, r, entry)
}
func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithFeedID(feedID)
builder.WithEntryID(entryID)
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request) {
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithCategoryID(categoryID)
builder.WithEntryID(entryID)
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
h.getEntryFromBuilder(w, r, builder)
}
func (h *handler) getFeedEntriesHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
h.findEntries(w, r, feedID, 0)
}
func (h *handler) getCategoryEntriesHandler(w http.ResponseWriter, r *http.Request) {
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
h.findEntries(w, r, 0, categoryID)
}
func (h *handler) getEntriesHandler(w http.ResponseWriter, r *http.Request) {
h.findEntries(w, r, 0, 0)
}
func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int64, categoryID int64) {
statuses := request.QueryStringParamList(r, "status")
for _, status := range statuses {
if err := validator.ValidateEntryStatus(status); err != nil {
response.JSONBadRequest(w, r, err)
return
}
}
order := request.QueryStringParam(r, "order", model.DefaultSortingOrder)
if err := validator.ValidateEntryOrder(order); err != nil {
response.JSONBadRequest(w, r, err)
return
}
direction := request.QueryStringParam(r, "direction", model.DefaultSortingDirection)
if err := validator.ValidateDirection(direction); err != nil {
response.JSONBadRequest(w, r, err)
return
}
limit := request.QueryIntParam(r, "limit", 100)
offset := request.QueryIntParam(r, "offset", 0)
if err := validator.ValidateRange(offset, limit); err != nil {
response.JSONBadRequest(w, r, err)
return
}
userID := request.UserID(r)
categoryID = request.QueryInt64Param(r, "category_id", categoryID)
if categoryID > 0 && !h.store.CategoryIDExists(userID, categoryID) {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
feedID = request.QueryInt64Param(r, "feed_id", feedID)
if feedID > 0 && !h.store.FeedExists(userID, feedID) {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
tags := request.QueryStringParamList(r, "tags")
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithFeedID(feedID)
builder.WithCategoryID(categoryID)
builder.WithStatuses(statuses)
builder.WithSorting(order, direction)
builder.WithOffset(offset)
builder.WithLimit(limit)
builder.WithTags(tags)
builder.WithEnclosures()
if request.HasQueryParam(r, "globally_visible") {
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
if globallyVisible {
builder.WithGloballyVisible()
}
}
configureFilters(builder, r)
entries, count, err := builder.GetEntriesWithCount()
if err != nil {
response.JSONServerError(w, r, err)
return
}
for i := range entries {
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entries[i].Content)
}
response.JSON(w, r, &entriesResponse{Total: count, Entries: entries})
}
func (h *handler) setEntryStatusHandler(w http.ResponseWriter, r *http.Request) {
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
response.JSONServerError(w, r, err)
return
}
response.NoContent(w, r)
}
func (h *handler) toggleStarredHandler(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
if err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {
response.JSONServerError(w, r, err)
return
}
response.NoContent(w, r)
}
func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
if !h.store.HasSaveEntry(request.UserID(r)) {
response.JSONBadRequest(w, r, errors.New("no third-party integration enabled"))
return
}
entry, err := builder.GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if entry == nil {
response.JSONNotFound(w, r)
return
}
settings, err := h.store.Integration(request.UserID(r))
if err != nil {
response.JSONServerError(w, r, err)
return
}
go integration.SendEntry(entry, settings)
response.JSONAccepted(w, r)
}
func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {
var entryUpdateRequest model.EntryUpdateRequest
if err := json_parser.NewDecoder(r.Body).Decode(&entryUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if err := validator.ValidateEntryModification(&entryUpdateRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
loggedUserID := request.UserID(r)
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entry, err := entryBuilder.GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if entry == nil {
response.JSONNotFound(w, r)
return
}
user, err := h.store.UserByID(loggedUserID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
if entryUpdateRequest.Content != nil {
sanitizedContent := sanitizer.SanitizeHTML(entry.URL, *entryUpdateRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
entryUpdateRequest.Content = &sanitizedContent
}
entryUpdateRequest.Patch(entry)
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSONCreated(w, r, entry)
}
func (h *handler) importFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.RouteInt64Param(r, "feedID")
if feedID <= 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
if !h.store.FeedExists(userID, feedID) {
response.JSONBadRequest(w, r, errors.New("feed does not exist"))
return
}
var importRequest entryImportRequest
if err := json_parser.NewDecoder(r.Body).Decode(&importRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if importRequest.URL == "" {
response.JSONBadRequest(w, r, errors.New("url is required"))
return
}
if importRequest.Status == "" {
importRequest.Status = model.EntryStatusRead
}
if err := validator.ValidateEntryStatus(importRequest.Status); err != nil {
response.JSONBadRequest(w, r, err)
return
}
entry := model.NewEntry()
entry.URL = importRequest.URL
entry.CommentsURL = importRequest.CommentsURL
entry.Author = importRequest.Author
entry.Tags = importRequest.Tags
if importRequest.PublishedAt > 0 {
entry.Date = time.Unix(importRequest.PublishedAt, 0).UTC()
} else {
entry.Date = time.Now().UTC()
}
if importRequest.Title == "" {
entry.Title = entry.URL
} else {
entry.Title = importRequest.Title
}
hashInput := importRequest.ExternalID
if hashInput == "" {
hashInput = importRequest.URL
}
entry.Hash = crypto.HashFromBytes([]byte(hashInput))
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
if importRequest.Content != "" {
entry.Content = sanitizer.SanitizeHTML(entry.URL, importRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
}
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
if errors.Is(err, storage.ErrEntryTombstoned) {
response.JSONBadRequest(w, r, err)
return
}
if err != nil {
response.JSONServerError(w, r, err)
return
}
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, importRequest.Status); err != nil {
response.JSONServerError(w, r, err)
return
}
entry.Status = importRequest.Status
if importRequest.Starred {
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
response.JSONServerError(w, r, err)
return
}
entry.Starred = true
}
if created {
response.JSONCreated(w, r, entryIDResponse{ID: entry.ID})
} else {
response.JSON(w, r, entryIDResponse{ID: entry.ID})
}
}
func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
entryID := request.RouteInt64Param(r, "entryID")
if entryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
return
}
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
entryBuilder.WithEntryID(entryID)
entry, err := entryBuilder.GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if entry == nil {
response.JSONNotFound(w, r)
return
}
user, err := h.store.UserByID(loggedUserID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
feedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)
feedBuilder.WithFeedID(entry.FeedID)
feed, err := feedBuilder.GetFeed()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if feed == nil {
response.JSONNotFound(w, r)
return
}
if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
response.JSONServerError(w, r, err)
return
}
shouldUpdateContent := request.QueryBoolParam(r, "update_content", false)
if shouldUpdateContent {
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
response.JSONServerError(w, r, err)
return
}
}
response.JSON(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content), ReadingTime: entry.ReadingTime})
}
func (h *handler) flushHistoryHandler(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
go h.store.FlushHistory(loggedUserID)
response.JSONAccepted(w, r)
}
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
if beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0); beforeEntryID > 0 {
builder.BeforeEntryID(beforeEntryID)
}
if afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0); afterEntryID > 0 {
builder.AfterEntryID(afterEntryID)
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "before", 0); beforePublishedTimestamp > 0 {
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "after", 0); afterPublishedTimestamp > 0 {
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforePublishedTimestamp := request.QueryInt64Param(r, "published_before", 0); beforePublishedTimestamp > 0 {
builder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))
}
if afterPublishedTimestamp := request.QueryInt64Param(r, "published_after", 0); afterPublishedTimestamp > 0 {
builder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))
}
if beforeChangedTimestamp := request.QueryInt64Param(r, "changed_before", 0); beforeChangedTimestamp > 0 {
builder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))
}
if afterChangedTimestamp := request.QueryInt64Param(r, "changed_after", 0); afterChangedTimestamp > 0 {
builder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))
}
if categoryID := request.QueryInt64Param(r, "category_id", 0); categoryID > 0 {
builder.WithCategoryID(categoryID)
}
if request.HasQueryParam(r, "starred") {
starred, err := strconv.ParseBool(r.URL.Query().Get("starred"))
if err == nil {
builder.WithStarred(starred)
}
}
if searchQuery := request.QueryStringParam(r, "search", ""); searchQuery != "" {
builder.WithSearchQuery(searchQuery)
}
}
v2-2.3.0/internal/api/feed_handlers.go 0000664 0000000 0000000 00000014466 15201231005 0017542 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/model"
feedHandler "miniflux.app/v2/internal/reader/handler"
"miniflux.app/v2/internal/validator"
)
func (h *handler) createFeedHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var feedCreationRequest model.FeedCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&feedCreationRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
// Make the feed category optional for clients who don't support categories.
if feedCreationRequest.CategoryID == 0 {
category, err := h.store.FirstCategory(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
feedCreationRequest.CategoryID = category.ID
}
if validationErr := validator.ValidateFeedCreation(h.store, userID, &feedCreationRequest); validationErr != nil {
response.JSONBadRequest(w, r, validationErr.Error())
return
}
feed, localizedError := feedHandler.CreateFeed(h.store, userID, &feedCreationRequest)
if localizedError != nil {
response.JSONServerError(w, r, localizedError.Error())
return
}
response.JSONCreated(w, r, &feedCreationResponse{FeedID: feed.ID})
}
func (h *handler) refreshFeedHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
userID := request.UserID(r)
if !h.store.FeedExists(userID, feedID) {
response.JSONNotFound(w, r)
return
}
localizedError := feedHandler.RefreshFeed(h.store, userID, feedID, false)
if localizedError != nil {
response.JSONServerError(w, r, localizedError.Error())
return
}
response.NoContent(w, r)
}
func (h *handler) refreshAllFeedsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
batchBuilder := h.store.NewBatchBuilder()
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithUserID(userID)
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
if err != nil {
response.JSONServerError(w, r, err)
return
}
slog.Info(
"Triggered a manual refresh of all feeds from the API",
slog.Int64("user_id", userID),
slog.Int("nb_jobs", len(jobs)),
)
go h.pool.Push(jobs)
response.NoContent(w, r)
}
func (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
var feedModificationRequest model.FeedModificationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&feedModificationRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
userID := request.UserID(r)
originalFeed, err := h.store.FeedByID(userID, feedID)
if err != nil {
response.JSONNotFound(w, r)
return
}
if originalFeed == nil {
response.JSONNotFound(w, r)
return
}
if validationErr := validator.ValidateFeedModification(h.store, userID, originalFeed.ID, &feedModificationRequest); validationErr != nil {
response.JSONBadRequest(w, r, validationErr.Error())
return
}
feedModificationRequest.Patch(originalFeed)
originalFeed.ResetErrorCounter()
if err := h.store.UpdateFeed(originalFeed); err != nil {
response.JSONServerError(w, r, err)
return
}
originalFeed, err = h.store.FeedByID(userID, feedID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSONCreated(w, r, originalFeed)
}
func (h *handler) markFeedAsReadHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
if !h.store.FeedExists(userID, feedID) {
response.JSONNotFound(w, r)
return
}
if err := h.store.MarkFeedAsRead(userID, feedID, time.Now()); err != nil {
response.JSONServerError(w, r, err)
return
}
response.NoContent(w, r)
}
func (h *handler) getCategoryFeedsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
if categoryID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
return
}
category, err := h.store.Category(userID, categoryID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if category == nil {
response.JSONNotFound(w, r)
return
}
feeds, err := h.store.FeedsByCategoryWithCounters(userID, categoryID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, feeds)
}
func (h *handler) getFeedsHandler(w http.ResponseWriter, r *http.Request) {
feeds, err := h.store.Feeds(request.UserID(r))
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, feeds)
}
func (h *handler) fetchCountersHandler(w http.ResponseWriter, r *http.Request) {
counters, err := h.store.FetchCounters(request.UserID(r))
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, counters)
}
func (h *handler) getFeedHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
feed, err := h.store.FeedByID(request.UserID(r), feedID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if feed == nil {
response.JSONNotFound(w, r)
return
}
response.JSON(w, r, feed)
}
func (h *handler) removeFeedHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
userID := request.UserID(r)
if !h.store.FeedExists(userID, feedID) {
response.JSONNotFound(w, r)
return
}
if err := h.store.RemoveFeed(userID, feedID); err != nil {
response.JSONServerError(w, r, err)
return
}
response.NoContent(w, r)
}
v2-2.3.0/internal/api/icon_handlers.go 0000664 0000000 0000000 00000002502 15201231005 0017553 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"errors"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
)
func (h *handler) getIconByFeedIDHandler(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if feedID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
return
}
icon, err := h.store.IconByFeedID(request.UserID(r), feedID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if icon == nil {
response.JSONNotFound(w, r)
return
}
response.JSON(w, r, &feedIconResponse{
ID: icon.ID,
MimeType: icon.MimeType,
Data: icon.DataURL(),
})
}
func (h *handler) getIconByIconIDHandler(w http.ResponseWriter, r *http.Request) {
iconID := request.RouteInt64Param(r, "iconID")
if iconID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid icon ID"))
return
}
icon, err := h.store.IconByID(iconID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if icon == nil {
response.JSONNotFound(w, r)
return
}
response.JSON(w, r, &feedIconResponse{
ID: icon.ID,
MimeType: icon.MimeType,
Data: icon.DataURL(),
})
}
v2-2.3.0/internal/api/messages.go 0000664 0000000 0000000 00000003040 15201231005 0016550 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"miniflux.app/v2/internal/model"
)
type feedIconResponse struct {
ID int64 `json:"id"`
MimeType string `json:"mime_type"`
Data string `json:"data"`
}
type entriesResponse struct {
Total int `json:"total"`
Entries model.Entries `json:"entries"`
}
type integrationsStatusResponse struct {
HasIntegrations bool `json:"has_integrations"`
}
type entryIDResponse struct {
ID int64 `json:"id"`
}
type entryContentResponse struct {
Content string `json:"content"`
ReadingTime int `json:"reading_time"`
}
type entryImportRequest struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Author string `json:"author"`
CommentsURL string `json:"comments_url"`
PublishedAt int64 `json:"published_at"`
Status string `json:"status"`
Starred bool `json:"starred"`
Tags []string `json:"tags"`
ExternalID string `json:"external_id"`
}
type feedCreationResponse struct {
FeedID int64 `json:"feed_id"`
}
type importFeedsResponse struct {
Message string `json:"message"`
}
type versionResponse struct {
Version string `json:"version"`
Commit string `json:"commit"`
BuildDate string `json:"build_date"`
GoVersion string `json:"go_version"`
Compiler string `json:"compiler"`
Arch string `json:"arch"`
OS string `json:"os"`
}
v2-2.3.0/internal/api/middleware.go 0000664 0000000 0000000 00000012412 15201231005 0017061 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"context"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/storage"
)
type middleware struct {
store *storage.Storage
}
func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s}
}
func (m *middleware) withCORSHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "X-Auth-Token, Authorization, Content-Type, Accept")
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Max-Age", "3600")
response.NoContent(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func (m *middleware) validateAPIKeyAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
token := r.Header.Get("X-Auth-Token")
if token == "" {
slog.Debug("[API] Skipped API token authentication because no API Key has been provided",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
next.ServeHTTP(w, r)
return
}
user, err := m.store.UserByAPIKey(token)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
slog.Warn("[API] No user found with the provided API key",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
response.JSONUnauthorized(w, r)
return
}
slog.Info("[API] User authenticated successfully with the API Token Authentication",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", user.Username),
slog.String("request_uri", r.RequestURI),
)
m.store.SetLastLogin(user.ID)
m.store.SetAPIKeyUsedTimestamp(user.ID, token)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *middleware) validateBasicAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if request.IsAuthenticated(r) {
next.ServeHTTP(w, r)
return
}
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
clientIP := request.ClientIP(r)
username, password, authOK := r.BasicAuth()
if !authOK {
slog.Warn("[API] No Basic HTTP Authentication header sent with the request",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
response.JSONUnauthorized(w, r)
return
}
if username == "" || password == "" {
slog.Warn("[API] Empty username or password provided during Basic HTTP Authentication",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
response.JSONUnauthorized(w, r)
return
}
if err := m.store.CheckPassword(username, password); err != nil {
slog.Warn("[API] Invalid username or password provided during Basic HTTP Authentication",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
response.JSONUnauthorized(w, r)
return
}
user, err := m.store.UserByUsername(username)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
slog.Warn("[API] User not found while using Basic HTTP Authentication",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
response.JSONUnauthorized(w, r)
return
}
slog.Info("[API] User authenticated successfully with the Basic HTTP Authentication",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
m.store.SetLastLogin(user.ID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
v2-2.3.0/internal/api/opml_handlers.go 0000664 0000000 0000000 00000001676 15201231005 0017605 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/reader/opml"
)
func (h *handler) exportFeedsHandler(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(h.store)
opmlExport, err := opmlHandler.Export(request.UserID(r))
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.XML(w, r, opmlExport)
}
func (h *handler) importFeedsHandler(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(h.store)
err := opmlHandler.Import(request.UserID(r), r.Body)
defer r.Body.Close()
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSONCreated(w, r, importFeedsResponse{Message: "Feeds imported successfully"})
}
v2-2.3.0/internal/api/subscription_handlers.go 0000664 0000000 0000000 00000004750 15201231005 0021356 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"net/http"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/reader/fetcher"
"miniflux.app/v2/internal/reader/subscription"
"miniflux.app/v2/internal/validator"
)
func (h *handler) discoverSubscriptionsHandler(w http.ResponseWriter, r *http.Request) {
var subscriptionDiscoveryRequest model.SubscriptionDiscoveryRequest
if err := json_parser.NewDecoder(r.Body).Decode(&subscriptionDiscoveryRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if validationErr := validator.ValidateSubscriptionDiscovery(&subscriptionDiscoveryRequest); validationErr != nil {
response.JSONBadRequest(w, r, validationErr.Error())
return
}
var rssbridgeURL string
var rssbridgeToken string
intg, err := h.store.Integration(request.UserID(r))
if err == nil && intg != nil && intg.RSSBridgeEnabled {
rssbridgeURL = intg.RSSBridgeURL
rssbridgeToken = intg.RSSBridgeToken
}
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
requestBuilder.WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL)
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
requestBuilder.UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy)
requestBuilder.WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(subscriptionDiscoveryRequest.Cookie)
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
subscriptionDiscoveryRequest.URL,
rssbridgeURL,
rssbridgeToken,
)
if localizedError != nil {
response.JSONServerError(w, r, localizedError.Error())
return
}
if len(subscriptions) == 0 {
response.JSONNotFound(w, r)
return
}
response.JSON(w, r, subscriptions)
}
v2-2.3.0/internal/api/user_handlers.go 0000664 0000000 0000000 00000013661 15201231005 0017611 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/validator"
)
func (h *handler) currentUserHandler(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, user)
}
func (h *handler) createUserHandler(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
response.JSONForbidden(w, r)
return
}
var userCreationRequest model.UserCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&userCreationRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
if validationErr := validator.ValidateUserCreationWithPassword(h.store, &userCreationRequest); validationErr != nil {
response.JSONBadRequest(w, r, validationErr.Error())
return
}
user, err := h.store.CreateUser(&userCreationRequest)
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSONCreated(w, r, user)
}
func (h *handler) updateUserHandler(w http.ResponseWriter, r *http.Request) {
userID := request.RouteInt64Param(r, "userID")
if userID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
return
}
var userModificationRequest model.UserModificationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&userModificationRequest); err != nil {
response.JSONBadRequest(w, r, err)
return
}
originalUser, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if originalUser == nil {
response.JSONNotFound(w, r)
return
}
if !request.IsAdminUser(r) {
if originalUser.ID != request.UserID(r) {
response.JSONForbidden(w, r)
return
}
if userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin {
response.JSONBadRequest(w, r, errors.New("only administrators can change permissions of standard users"))
return
}
}
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
response.JSONBadRequest(w, r, validationErr.Error())
return
}
userModificationRequest.Patch(originalUser)
if err = h.store.UpdateUser(originalUser); err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSONCreated(w, r, originalUser)
}
func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request) {
userID := request.RouteInt64Param(r, "userID")
if userID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
return
}
if userID != request.UserID(r) {
response.JSONForbidden(w, r)
return
}
if _, err := h.store.UserByID(userID); err != nil {
response.JSONNotFound(w, r)
return
}
if err := h.store.MarkAllAsRead(userID); err != nil {
response.JSONServerError(w, r, err)
return
}
response.NoContent(w, r)
}
func (h *handler) getIntegrationsStatusHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
if _, err := h.store.UserByID(userID); err != nil {
response.JSONNotFound(w, r)
return
}
hasIntegrations := h.store.HasSaveEntry(userID)
response.JSON(w, r, integrationsStatusResponse{HasIntegrations: hasIntegrations})
}
func (h *handler) usersHandler(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
response.JSONForbidden(w, r)
return
}
users, err := h.store.Users()
if err != nil {
response.JSONServerError(w, r, err)
return
}
users.UseTimezone(request.UserTimezone(r))
response.JSON(w, r, users)
}
func (h *handler) dispatchUserLookupHandler(w http.ResponseWriter, r *http.Request) {
identifier := request.RouteStringParam(r, "identifier")
userID := request.RouteInt64Param(r, "identifier")
if userID > 0 {
r.SetPathValue("userID", identifier)
h.userByIDHandler(w, r)
return
}
r.SetPathValue("username", identifier)
h.userByUsernameHandler(w, r)
}
func (h *handler) userByIDHandler(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
response.JSONForbidden(w, r)
return
}
userID := request.RouteInt64Param(r, "userID")
if userID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
return
}
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
user.UseTimezone(request.UserTimezone(r))
response.JSON(w, r, user)
}
func (h *handler) userByUsernameHandler(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
response.JSONForbidden(w, r)
return
}
username := request.RouteStringParam(r, "username")
user, err := h.store.UserByUsername(username)
if err != nil {
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
response.JSON(w, r, user)
}
func (h *handler) removeUserHandler(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
response.JSONForbidden(w, r)
return
}
userID := request.RouteInt64Param(r, "userID")
if userID == 0 {
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
return
}
user, err := h.store.UserByID(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
if user == nil {
response.JSONNotFound(w, r)
return
}
if user.ID == request.UserID(r) {
response.JSONBadRequest(w, r, errors.New("you cannot remove yourself"))
return
}
go func() {
if err := h.store.RemoveUser(user.ID); err != nil {
slog.Error("Unable to delete user",
slog.Int64("user_id", user.ID),
slog.Any("error", err),
)
}
}()
response.NoContent(w, r)
}
v2-2.3.0/internal/api/version_handler.go 0000664 0000000 0000000 00000001152 15201231005 0020125 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
"net/http"
"runtime"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/version"
)
func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {
response.JSON(w, r, &versionResponse{
Version: version.Version,
Commit: version.Commit,
BuildDate: version.BuildDate,
GoVersion: runtime.Version(),
Compiler: runtime.Compiler,
Arch: runtime.GOARCH,
OS: runtime.GOOS,
})
}
v2-2.3.0/internal/cli/ 0000775 0000000 0000000 00000000000 15201231005 0014413 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/cli/ask_credentials.go 0000664 0000000 0000000 00000002250 15201231005 0020074 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"bufio"
"errors"
"fmt"
"os"
"strings"
"golang.org/x/term"
)
func askCredentials() (string, string) {
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
printErrorAndExit(errors.New("this is not an interactive terminal, exiting"))
}
fmt.Print("Enter Username: ")
reader := bufio.NewReader(os.Stdin)
username, err := reader.ReadString('\n')
if err != nil {
printErrorAndExit(fmt.Errorf("unable to read username: %w", err))
}
fmt.Print("Enter Password: ")
state, err := term.GetState(fd)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to get terminal state: %w", err))
}
defer func() {
if restoreErr := term.Restore(fd, state); restoreErr != nil {
printErrorAndExit(fmt.Errorf("unable to restore terminal state: %w", restoreErr))
}
}()
bytePassword, err := term.ReadPassword(fd)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to read password: %w", err))
}
fmt.Print("\n")
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
}
v2-2.3.0/internal/cli/cleanup_tasks.go 0000664 0000000 0000000 00000003750 15201231005 0017603 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"log/slog"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/metric"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
)
func runCleanupTasks(store *storage.Storage) {
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 {
slog.Error("Unable to archive read entries", slog.Any("error", err))
} else {
slog.Info("Archiving read entries completed",
slog.Int64("read_entries_archived", rowsAffected),
)
if config.Opts.HasMetricsCollector() {
metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusRead).Observe(time.Since(startTime).Seconds())
}
}
startTime = time.Now()
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
slog.Error("Unable to archive unread entries", slog.Any("error", err))
} else {
slog.Info("Archiving unread entries completed",
slog.Int64("unread_entries_archived", rowsAffected),
)
if config.Opts.HasMetricsCollector() {
metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusUnread).Observe(time.Since(startTime).Seconds())
}
}
if nbIcons, err := store.CleanupOrphanIcons(); err != nil {
slog.Error("Unable to clean orphan icons", slog.Any("error", err))
} else {
slog.Info("Orphan icons cleanup completed",
slog.Int64("orphan_icons_removed", nbIcons),
)
}
}
v2-2.3.0/internal/cli/cli.go 0000664 0000000 0000000 00000015750 15201231005 0015521 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"flag"
"fmt"
"io"
"log/slog"
"os"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/database"
"miniflux.app/v2/internal/proxyrotator"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui/static"
"miniflux.app/v2/internal/version"
)
const (
flagInfoHelp = "Show build information"
flagVersionHelp = "Show application version"
flagMigrateHelp = "Run SQL migrations"
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
flagCreateAdminHelp = "Create an admin user from an interactive terminal"
flagResetPasswordHelp = "Reset user password"
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
flagDebugModeHelp = "Show debug logs"
flagConfigFileHelp = "Load configuration file"
flagConfigDumpHelp = "Print parsed configuration values"
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" tries to guess the health check endpoint).`
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archive old entries)"
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
flagResetNextCheckAtHelp = "Reset the next check time for all feeds"
)
// Parse parses command line arguments.
func Parse() {
var (
err error
flagInfo bool
flagVersion bool
flagMigrate bool
flagFlushSessions bool
flagCreateAdmin bool
flagResetPassword bool
flagResetFeedErrors bool
flagResetFeedNextCheckAt bool
flagDebugMode bool
flagConfigFile string
flagConfigDump bool
flagHealthCheck string
flagRefreshFeeds bool
flagRunCleanupTasks bool
flagExportUserFeeds string
)
flag.BoolVar(&flagInfo, "info", false, flagInfoHelp)
flag.BoolVar(&flagInfo, "i", false, flagInfoHelp)
flag.BoolVar(&flagVersion, "version", false, flagVersionHelp)
flag.BoolVar(&flagVersion, "v", false, flagVersionHelp)
flag.BoolVar(&flagMigrate, "migrate", false, flagMigrateHelp)
flag.BoolVar(&flagFlushSessions, "flush-sessions", false, flagFlushSessionsHelp)
flag.BoolVar(&flagCreateAdmin, "create-admin", false, flagCreateAdminHelp)
flag.BoolVar(&flagResetPassword, "reset-password", false, flagResetPasswordHelp)
flag.BoolVar(&flagResetFeedErrors, "reset-feed-errors", false, flagResetFeedErrorsHelp)
flag.BoolVar(&flagResetFeedNextCheckAt, "reset-feed-next-check-at", false, flagResetNextCheckAtHelp)
flag.BoolVar(&flagDebugMode, "debug", false, flagDebugModeHelp)
flag.StringVar(&flagConfigFile, "config-file", "", flagConfigFileHelp)
flag.StringVar(&flagConfigFile, "c", "", flagConfigFileHelp)
flag.BoolVar(&flagConfigDump, "config-dump", false, flagConfigDumpHelp)
flag.StringVar(&flagHealthCheck, "healthcheck", "", flagHealthCheckHelp)
flag.BoolVar(&flagRefreshFeeds, "refresh-feeds", false, flagRefreshFeedsHelp)
flag.BoolVar(&flagRunCleanupTasks, "run-cleanup-tasks", false, flagRunCleanupTasksHelp)
flag.StringVar(&flagExportUserFeeds, "export-user-feeds", "", flagExportUserFeedsHelp)
flag.Parse()
cfg := config.NewConfigParser()
if flagConfigFile != "" {
config.Opts, err = cfg.ParseFile(flagConfigFile)
if err != nil {
printErrorAndExit(err)
}
}
config.Opts, err = cfg.ParseEnvironmentVariables()
if err != nil {
printErrorAndExit(err)
}
if err := config.Opts.Validate(); err != nil {
printErrorAndExit(err)
}
if flagConfigDump {
fmt.Print(config.Opts)
return
}
if flagInfo {
info()
return
}
if flagVersion {
fmt.Println(version.Version)
return
}
if flagDebugMode {
config.Opts.SetLogLevel("debug")
}
logFile := config.Opts.LogFile()
var logFileHandler io.Writer
switch logFile {
case "stdout":
logFileHandler = os.Stdout
case "stderr":
logFileHandler = os.Stderr
default:
logFileHandler, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to open log file: %v", err))
}
defer logFileHandler.(*os.File).Close()
}
if err := InitializeDefaultLogger(config.Opts.LogLevel(), logFileHandler, config.Opts.LogFormat(), config.Opts.LogDateTime()); err != nil {
printErrorAndExit(err)
}
if flagHealthCheck != "" {
doHealthCheck(flagHealthCheck)
return
}
if config.Opts.IsDefaultDatabaseURL() {
slog.Info("The default value for DATABASE_URL is used")
}
if err := static.GenerateBinaryBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate binary files bundle: %v", err))
}
if err := static.GenerateStylesheetsBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err))
}
if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
}
db, err := database.NewConnectionPool(
config.Opts.DatabaseURL(),
config.Opts.DatabaseMinConns(),
config.Opts.DatabaseMaxConns(),
config.Opts.DatabaseConnectionLifetime(),
)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to connect to database: %v", err))
}
defer db.Close()
store := storage.NewStorage(db)
if err := store.Ping(); err != nil {
printErrorAndExit(err)
}
if flagMigrate {
if err := database.Migrate(db); err != nil {
printErrorAndExit(err)
}
return
}
if flagResetFeedErrors {
if err := store.ResetFeedErrors(); err != nil {
printErrorAndExit(err)
}
return
}
if flagResetFeedNextCheckAt {
if err := store.ResetNextCheckAt(); err != nil {
printErrorAndExit(err)
}
return
}
if flagExportUserFeeds != "" {
exportUserFeeds(store, flagExportUserFeeds)
return
}
if flagFlushSessions {
flushSessions(store)
return
}
if flagCreateAdmin {
createAdminUserFromInteractiveTerminal(store)
return
}
if flagResetPassword {
resetPassword(store)
return
}
// Run migrations and start the daemon.
if config.Opts.RunMigrations() {
if err := database.Migrate(db); err != nil {
printErrorAndExit(err)
}
}
if err := database.IsSchemaUpToDate(db); err != nil {
printErrorAndExit(err)
}
if config.Opts.CreateAdmin() {
createAdminUserFromEnvironmentVariables(store)
}
if config.Opts.HasHTTPClientProxiesConfigured() {
slog.Info("Initializing proxy rotation", slog.Int("proxies_count", len(config.Opts.HTTPClientProxies())))
proxyrotator.ProxyRotatorInstance, err = proxyrotator.NewProxyRotator(config.Opts.HTTPClientProxies())
if err != nil {
printErrorAndExit(fmt.Errorf("unable to initialize proxy rotator: %v", err))
}
}
if flagRefreshFeeds {
refreshFeeds(store)
return
}
if flagRunCleanupTasks {
runCleanupTasks(store)
return
}
startDaemon(store)
}
func printErrorAndExit(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
v2-2.3.0/internal/cli/create_admin.go 0000664 0000000 0000000 00000002635 15201231005 0017363 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"log/slog"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func createAdminUserFromEnvironmentVariables(store *storage.Storage) {
createAdminUser(store, config.Opts.AdminUsername(), config.Opts.AdminPassword())
}
func createAdminUserFromInteractiveTerminal(store *storage.Storage) {
username, password := askCredentials()
createAdminUser(store, username, password)
}
func createAdminUser(store *storage.Storage, username, password string) {
userCreationRequest := &model.UserCreationRequest{
Username: username,
Password: password,
IsAdmin: true,
}
if store.UserExists(userCreationRequest.Username) {
slog.Info("Skipping admin user creation because it already exists",
slog.String("username", userCreationRequest.Username),
)
return
}
if validationErr := validator.ValidateUserCreationWithPassword(store, userCreationRequest); validationErr != nil {
printErrorAndExit(validationErr.Error())
}
if user, err := store.CreateUser(userCreationRequest); err != nil {
printErrorAndExit(err)
} else {
slog.Info("Created new admin user",
slog.String("username", user.Username),
slog.Int64("user_id", user.ID),
)
}
}
v2-2.3.0/internal/cli/daemon.go 0000664 0000000 0000000 00000005213 15201231005 0016206 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/server"
"miniflux.app/v2/internal/metric"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/systemd"
"miniflux.app/v2/internal/worker"
)
func startDaemon(store *storage.Storage) {
slog.Debug("Starting daemon...")
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt)
signal.Notify(stop, syscall.SIGTERM)
pool := worker.NewPool(store, config.Opts.WorkerPoolSize())
if config.Opts.HasSchedulerService() && !config.Opts.HasMaintenanceMode() {
runScheduler(store, pool)
}
var httpServers []*http.Server
if config.Opts.HasHTTPService() {
httpServers = server.StartWebServer(store, pool)
}
metricsCtx, cancelMetrics := context.WithCancel(context.Background())
if config.Opts.HasMetricsCollector() {
collector := metric.NewCollector(store, config.Opts.MetricsRefreshInterval())
go collector.GatherStorageMetrics(metricsCtx)
}
if systemd.HasNotifySocket() {
slog.Debug("Sending readiness notification to Systemd")
if err := systemd.SdNotify(systemd.SdNotifyReady); err != nil {
slog.Error("Unable to send readiness notification to systemd", slog.Any("error", err))
}
if config.Opts.HasWatchdog() && systemd.HasSystemdWatchdog() {
slog.Debug("Activating Systemd watchdog")
go func() {
interval, err := systemd.WatchdogInterval()
if err != nil {
slog.Error("Unable to get watchdog interval from systemd", slog.Any("error", err))
return
}
for {
if err := store.Ping(); err != nil {
slog.Error("Unable to ping database", slog.Any("error", err))
} else {
systemd.SdNotify(systemd.SdNotifyWatchdog)
}
time.Sleep(interval / 3)
}
}()
}
}
<-stop
slog.Debug("Shutting down the process")
cancelMetrics()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if len(httpServers) > 0 {
slog.Debug("Shutting down HTTP servers...")
for _, server := range httpServers {
if server != nil {
if err := server.Shutdown(ctx); err != nil {
slog.Error("HTTP server shutdown error", slog.Any("error", err), slog.String("addr", server.Addr))
}
}
}
slog.Debug("All HTTP servers shut down.")
} else {
slog.Debug("No HTTP servers to shut down.")
}
slog.Debug("Shutting down worker pool...")
pool.Shutdown()
slog.Debug("Worker pool shut down.")
slog.Debug("Process gracefully stopped")
}
v2-2.3.0/internal/cli/export_feeds.go 0000664 0000000 0000000 00000001357 15201231005 0017437 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"fmt"
"miniflux.app/v2/internal/reader/opml"
"miniflux.app/v2/internal/storage"
)
func exportUserFeeds(store *storage.Storage, username string) {
user, err := store.UserByUsername(username)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to find user: %w", err))
}
if user == nil {
printErrorAndExit(fmt.Errorf("user %q not found", username))
}
opmlHandler := opml.NewHandler(store)
opmlExport, err := opmlHandler.Export(user.ID)
if err != nil {
printErrorAndExit(fmt.Errorf("unable to export feeds: %w", err))
}
fmt.Println(opmlExport)
}
v2-2.3.0/internal/cli/flush_sessions.go 0000664 0000000 0000000 00000000634 15201231005 0020014 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"fmt"
"miniflux.app/v2/internal/storage"
)
func flushSessions(store *storage.Storage) {
fmt.Println("Flushing all sessions (disconnect users)")
if err := store.FlushAllSessions(); err != nil {
printErrorAndExit(err)
}
}
v2-2.3.0/internal/cli/health_check.go 0000664 0000000 0000000 00000001632 15201231005 0017346 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"fmt"
"log/slog"
"net/http"
"time"
"miniflux.app/v2/internal/config"
)
func doHealthCheck(healthCheckEndpoint string) {
if healthCheckEndpoint == "auto" {
healthCheckEndpoint = "http://" + config.Opts.ListenAddr()[0] + config.Opts.BasePath() + "/healthcheck"
}
slog.Debug("Executing health check request", slog.String("endpoint", healthCheckEndpoint))
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(healthCheckEndpoint)
if err != nil {
printErrorAndExit(fmt.Errorf(`health check failure: %v`, err))
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
printErrorAndExit(fmt.Errorf(`health check failed with status code %d`, resp.StatusCode))
}
slog.Debug(`Health check is passing`)
}
v2-2.3.0/internal/cli/info.go 0000664 0000000 0000000 00000001045 15201231005 0015675 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"fmt"
"runtime"
"miniflux.app/v2/internal/version"
)
func info() {
fmt.Println("Version:", version.Version)
fmt.Println("Commit:", version.Commit)
fmt.Println("Build Date:", version.BuildDate)
fmt.Println("Go Version:", runtime.Version())
fmt.Println("Compiler:", runtime.Compiler)
fmt.Println("Arch:", runtime.GOARCH)
fmt.Println("OS:", runtime.GOOS)
}
v2-2.3.0/internal/cli/logger.go 0000664 0000000 0000000 00000002072 15201231005 0016222 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"io"
"log/slog"
)
func InitializeDefaultLogger(logLevel string, logFile io.Writer, logFormat string, logTime bool) error {
var programLogLevel = new(slog.LevelVar)
switch logLevel {
case "debug":
programLogLevel.Set(slog.LevelDebug)
case "info":
programLogLevel.Set(slog.LevelInfo)
case "warning":
programLogLevel.Set(slog.LevelWarn)
case "error":
programLogLevel.Set(slog.LevelError)
}
logHandlerOptions := &slog.HandlerOptions{Level: programLogLevel}
if !logTime {
logHandlerOptions.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
}
var logger *slog.Logger
switch logFormat {
case "json":
logger = slog.New(slog.NewJSONHandler(logFile, logHandlerOptions))
default:
logger = slog.New(slog.NewTextHandler(logFile, logHandlerOptions))
}
slog.SetDefault(logger)
return nil
}
v2-2.3.0/internal/cli/refresh_feeds.go 0000664 0000000 0000000 00000004011 15201231005 0017542 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"log/slog"
"sync"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
feedHandler "miniflux.app/v2/internal/reader/handler"
"miniflux.app/v2/internal/storage"
)
func refreshFeeds(store *storage.Storage) {
var wg sync.WaitGroup
startTime := time.Now()
// Generate a batch of feeds for any user that has feeds to refresh.
batchBuilder := store.NewBatchBuilder()
batchBuilder.WithBatchSize(config.Opts.BatchSize())
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
if err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
return
}
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
nbJobs := len(jobs)
var jobQueue = make(chan model.Job, nbJobs)
slog.Info("Starting a pool of workers",
slog.Int("nb_workers", config.Opts.WorkerPoolSize()),
)
for i := range config.Opts.WorkerPoolSize() {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for job := range jobQueue {
slog.Info("Refreshing feed",
slog.Int64("feed_id", job.FeedID),
slog.Int64("user_id", job.UserID),
slog.Int("worker_id", workerID),
)
if localizedError := feedHandler.RefreshFeed(store, job.UserID, job.FeedID, false); localizedError != nil {
slog.Warn("Unable to refresh feed",
slog.Int64("feed_id", job.FeedID),
slog.Int64("user_id", job.UserID),
slog.Any("error", localizedError.Error()),
)
}
}
}(i)
}
for _, job := range jobs {
jobQueue <- job
}
close(jobQueue)
wg.Wait()
slog.Info("Refreshed a batch of feeds",
slog.Int("nb_feeds", nbJobs),
slog.String("duration", time.Since(startTime).String()),
)
}
v2-2.3.0/internal/cli/reset_password.go 0000664 0000000 0000000 00000001671 15201231005 0020013 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"errors"
"fmt"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func resetPassword(store *storage.Storage) {
username, password := askCredentials()
user, err := store.UserByUsername(username)
if err != nil {
printErrorAndExit(err)
}
if user == nil {
printErrorAndExit(errors.New("user not found"))
}
userModificationRequest := &model.UserModificationRequest{
Password: &password,
}
if validationErr := validator.ValidateUserModification(store, user.ID, userModificationRequest); validationErr != nil {
printErrorAndExit(validationErr.Error())
}
user.Password = password
if err := store.UpdateUser(user); err != nil {
printErrorAndExit(err)
}
fmt.Println("Password changed!")
}
v2-2.3.0/internal/cli/scheduler.go 0000664 0000000 0000000 00000003036 15201231005 0016722 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package cli // import "miniflux.app/v2/internal/cli"
import (
"log/slog"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/worker"
)
func runScheduler(store *storage.Storage, pool *worker.Pool) {
slog.Debug(`Starting background scheduler...`)
go feedScheduler(
store,
pool,
config.Opts.PollingFrequency(),
config.Opts.BatchSize(),
config.Opts.PollingParsingErrorLimit(),
config.Opts.PollingLimitPerHost(),
)
go cleanupScheduler(
store,
config.Opts.CleanupFrequency(),
)
}
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency time.Duration, batchSize, errorLimit, limitPerHost int) {
for range time.Tick(frequency) {
// Generate a batch of feeds for any user that has feeds to refresh.
batchBuilder := store.NewBatchBuilder()
batchBuilder.WithBatchSize(batchSize)
batchBuilder.WithErrorLimit(errorLimit)
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(limitPerHost)
if jobs, err := batchBuilder.FetchJobs(); err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
} else if len(jobs) > 0 {
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
pool.Push(jobs)
}
}
}
func cleanupScheduler(store *storage.Storage, frequency time.Duration) {
for range time.Tick(frequency) {
runCleanupTasks(store)
}
}
v2-2.3.0/internal/config/ 0000775 0000000 0000000 00000000000 15201231005 0015111 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/config/config.go 0000664 0000000 0000000 00000000631 15201231005 0016705 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import "miniflux.app/v2/internal/version"
// Opts holds parsed configuration options.
var Opts *configOptions
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
v2-2.3.0/internal/config/options.go 0000664 0000000 0000000 00000067032 15201231005 0017143 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"maps"
"net"
"net/url"
"slices"
"strings"
"time"
)
type optionPair struct {
Key string
Value string
}
type configValueType int
const (
stringType configValueType = iota
stringListType
boolType
intType
int64Type
urlType
secondType
minuteType
hourType
dayType
secretFileType
bytesType
)
type configValue struct {
parsedStringValue string
parsedBoolValue bool
parsedIntValue int
parsedInt64Value int64
parsedDuration time.Duration
parsedStringList []string
parsedURLValue *url.URL
parsedBytesValue []byte
rawValue string
valueType configValueType
secret bool
targetKey string
validator func(string) error
}
type configOptions struct {
rootURL string
basePath string
youTubeEmbedDomain string
options map[string]*configValue
}
// NewConfigOptions creates a new instance of ConfigOptions with default values.
func NewConfigOptions() *configOptions {
return &configOptions{
rootURL: "http://localhost",
basePath: "",
youTubeEmbedDomain: "www.youtube-nocookie.com",
options: map[string]*configValue{
"ADMIN_PASSWORD": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
secret: true,
},
"ADMIN_PASSWORD_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "ADMIN_PASSWORD",
},
"ADMIN_USERNAME": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"ADMIN_USERNAME_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "ADMIN_USERNAME",
},
"AUTH_PROXY_HEADER": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"AUTH_PROXY_USER_CREATION": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"BASE_URL": {
parsedStringValue: "http://localhost",
rawValue: "http://localhost",
valueType: stringType,
},
"BATCH_SIZE": {
parsedIntValue: 100,
rawValue: "100",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"CERT_DOMAIN": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"CERT_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"CLEANUP_ARCHIVE_BATCH_SIZE": {
parsedIntValue: 10000,
rawValue: "10000",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"CLEANUP_ARCHIVE_READ_DAYS": {
parsedDuration: time.Hour * 24 * 60,
rawValue: "60",
valueType: dayType,
},
"CLEANUP_ARCHIVE_UNREAD_DAYS": {
parsedDuration: time.Hour * 24 * 180,
rawValue: "180",
valueType: dayType,
},
"CLEANUP_FREQUENCY_HOURS": {
parsedDuration: time.Hour * 24,
rawValue: "24",
valueType: hourType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"CLEANUP_REMOVE_SESSIONS_DAYS": {
parsedDuration: time.Hour * 24 * 30,
rawValue: "30",
valueType: dayType,
},
"CREATE_ADMIN": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"DATABASE_CONNECTION_LIFETIME": {
parsedDuration: time.Minute * 5,
rawValue: "5",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterThan(rawValue, 0)
},
},
"DATABASE_MAX_CONNS": {
parsedIntValue: 20,
rawValue: "20",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"DATABASE_MIN_CONNS": {
parsedIntValue: 1,
rawValue: "1",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 0)
},
},
"DATABASE_URL": {
parsedStringValue: "user=postgres password=postgres dbname=miniflux2 sslmode=disable",
rawValue: "user=postgres password=postgres dbname=miniflux2 sslmode=disable",
valueType: stringType,
secret: true,
},
"DATABASE_URL_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "DATABASE_URL",
},
"DISABLE_API": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"DISABLE_HSTS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"DISABLE_HTTP_SERVICE": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"DISABLE_LOCAL_AUTH": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"DISABLE_SCHEDULER_SERVICE": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FETCHER_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FETCH_BILIBILI_WATCH_TIME": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FETCH_NEBULA_WATCH_TIME": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FETCH_ODYSEE_WATCH_TIME": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FETCH_YOUTUBE_WATCH_TIME": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"FORCE_REFRESH_INTERVAL": {
parsedDuration: 30 * time.Minute,
rawValue: "30",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterThan(rawValue, 0)
},
},
"HTTP_CLIENT_MAX_BODY_SIZE": {
parsedInt64Value: 15,
rawValue: "15",
valueType: int64Type,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"HTTP_CLIENT_PROXIES": {
parsedStringList: []string{},
rawValue: "",
valueType: stringListType,
secret: true,
},
"HTTP_CLIENT_PROXY": {
parsedURLValue: nil,
rawValue: "",
valueType: urlType,
secret: true,
},
"HTTP_CLIENT_TIMEOUT": {
parsedDuration: 20 * time.Second,
rawValue: "20",
valueType: secondType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"HTTP_CLIENT_USER_AGENT": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"HTTP_SERVER_TIMEOUT": {
parsedDuration: 300 * time.Second,
rawValue: "300",
valueType: secondType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"HTTPS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"INTEGRATION_ALLOW_PRIVATE_NETWORKS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"INVIDIOUS_INSTANCE": {
parsedStringValue: "yewtu.be",
rawValue: "yewtu.be",
valueType: stringType,
},
"KEY_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"LISTEN_ADDR": {
parsedStringList: []string{"127.0.0.1:8080"},
rawValue: "127.0.0.1:8080",
valueType: stringListType,
},
"LOG_DATE_TIME": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"LOG_FILE": {
parsedStringValue: "stderr",
rawValue: "stderr",
valueType: stringType,
},
"LOG_FORMAT": {
parsedStringValue: "text",
rawValue: "text",
valueType: stringType,
validator: func(rawValue string) error {
return validateChoices(rawValue, []string{"text", "json"})
},
},
"LOG_LEVEL": {
parsedStringValue: "info",
rawValue: "info",
valueType: stringType,
validator: func(rawValue string) error {
return validateChoices(rawValue, []string{"debug", "info", "warning", "error"})
},
},
"MAINTENANCE_MESSAGE": {
parsedStringValue: "Miniflux is currently under maintenance",
rawValue: "Miniflux is currently under maintenance",
valueType: stringType,
},
"MAINTENANCE_MODE": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"MEDIA_PROXY_CUSTOM_URL": {
rawValue: "",
valueType: urlType,
},
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": {
parsedDuration: 120 * time.Second,
rawValue: "120",
valueType: secondType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"MEDIA_PROXY_MODE": {
parsedStringValue: "http-only",
rawValue: "http-only",
valueType: stringType,
validator: func(rawValue string) error {
return validateChoices(rawValue, []string{"none", "http-only", "all"})
},
},
"MEDIA_PROXY_PRIVATE_KEY": {
valueType: bytesType,
secret: true,
},
"MEDIA_PROXY_RESOURCE_TYPES": {
parsedStringList: []string{"image"},
rawValue: "image",
valueType: stringListType,
validator: func(rawValue string) error {
return validateListChoices(strings.Split(rawValue, ","), []string{"image", "video", "audio"})
},
},
"METRICS_ALLOWED_NETWORKS": {
parsedStringList: []string{"127.0.0.1/8"},
rawValue: "127.0.0.1/8",
valueType: stringListType,
},
"METRICS_COLLECTOR": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"METRICS_PASSWORD": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
secret: true,
},
"METRICS_PASSWORD_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "METRICS_PASSWORD",
},
"METRICS_REFRESH_INTERVAL": {
parsedDuration: 60 * time.Second,
rawValue: "60",
valueType: secondType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"METRICS_USERNAME": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"METRICS_USERNAME_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "METRICS_USERNAME",
},
"OAUTH2_CLIENT_ID": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
secret: true,
},
"OAUTH2_CLIENT_ID_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "OAUTH2_CLIENT_ID",
},
"OAUTH2_CLIENT_SECRET": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
secret: true,
},
"OAUTH2_CLIENT_SECRET_FILE": {
parsedStringValue: "",
rawValue: "",
valueType: secretFileType,
targetKey: "OAUTH2_CLIENT_SECRET",
},
"OAUTH2_OIDC_DISCOVERY_ENDPOINT": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"OAUTH2_OIDC_PROVIDER_NAME": {
parsedStringValue: "OpenID Connect",
rawValue: "OpenID Connect",
valueType: stringType,
},
"OAUTH2_PROVIDER": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
validator: func(rawValue string) error {
return validateChoices(rawValue, []string{"oidc", "google"})
},
},
"OAUTH2_REDIRECT_URL": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
},
"OAUTH2_USER_CREATION": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"POLLING_FREQUENCY": {
parsedDuration: 60 * time.Minute,
rawValue: "60",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"POLLING_LIMIT_PER_HOST": {
parsedIntValue: 0,
rawValue: "0",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 0)
},
},
"POLLING_PARSING_ERROR_LIMIT": {
parsedIntValue: 3,
rawValue: "3",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 0)
},
},
"POLLING_SCHEDULER": {
parsedStringValue: "round_robin",
rawValue: "round_robin",
valueType: stringType,
validator: func(rawValue string) error {
return validateChoices(rawValue, []string{"round_robin", "entry_frequency"})
},
},
"PORT": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
validator: func(rawValue string) error {
return validateRange(rawValue, 1, 65535)
},
},
"RUN_MIGRATIONS": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"SCHEDULER_ENTRY_FREQUENCY_FACTOR": {
parsedIntValue: 1,
rawValue: "1",
valueType: intType,
},
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": {
parsedDuration: 24 * time.Hour,
rawValue: "1440",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL": {
parsedDuration: 5 * time.Minute,
rawValue: "5",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL": {
parsedDuration: 1440 * time.Minute,
rawValue: "1440",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL": {
parsedDuration: 60 * time.Minute,
rawValue: "60",
valueType: minuteType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"TRUSTED_REVERSE_PROXY_NETWORKS": {
parsedStringList: []string{},
rawValue: "",
valueType: stringListType,
validator: func(rawValue string) error {
for ip := range strings.SplitSeq(rawValue, ",") {
if _, _, err := net.ParseCIDR(ip); err != nil {
return err
}
}
return nil
},
},
"WATCHDOG": {
parsedBoolValue: true,
rawValue: "1",
valueType: boolType,
},
"WEBAUTHN": {
parsedBoolValue: false,
rawValue: "0",
valueType: boolType,
},
"WORKER_POOL_SIZE": {
parsedIntValue: 16,
rawValue: "16",
valueType: intType,
validator: func(rawValue string) error {
return validateGreaterOrEqualThan(rawValue, 1)
},
},
"YOUTUBE_API_KEY": {
parsedStringValue: "",
rawValue: "",
valueType: stringType,
secret: true,
},
"YOUTUBE_EMBED_URL_OVERRIDE": {
parsedStringValue: "https://www.youtube-nocookie.com/embed/",
rawValue: "https://www.youtube-nocookie.com/embed/",
valueType: stringType,
},
},
}
}
func (c *configOptions) AdminPassword() string {
return c.options["ADMIN_PASSWORD"].parsedStringValue
}
func (c *configOptions) AdminUsername() string {
return c.options["ADMIN_USERNAME"].parsedStringValue
}
func (c *configOptions) AuthProxyHeader() string {
return c.options["AUTH_PROXY_HEADER"].parsedStringValue
}
func (c *configOptions) AuthProxyUserCreation() bool {
return c.options["AUTH_PROXY_USER_CREATION"].parsedBoolValue
}
func (c *configOptions) BasePath() string {
return c.basePath
}
func (c *configOptions) BaseURL() string {
return c.options["BASE_URL"].parsedStringValue
}
func (c *configOptions) RootURL() string {
return c.rootURL
}
func (c *configOptions) BatchSize() int {
return c.options["BATCH_SIZE"].parsedIntValue
}
func (c *configOptions) CertDomain() string {
return c.options["CERT_DOMAIN"].parsedStringValue
}
func (c *configOptions) CertFile() string {
return c.options["CERT_FILE"].parsedStringValue
}
func (c *configOptions) CleanupArchiveBatchSize() int {
return c.options["CLEANUP_ARCHIVE_BATCH_SIZE"].parsedIntValue
}
func (c *configOptions) CleanupArchiveReadInterval() time.Duration {
return c.options["CLEANUP_ARCHIVE_READ_DAYS"].parsedDuration
}
func (c *configOptions) CleanupArchiveUnreadInterval() time.Duration {
return c.options["CLEANUP_ARCHIVE_UNREAD_DAYS"].parsedDuration
}
func (c *configOptions) CleanupFrequency() time.Duration {
return c.options["CLEANUP_FREQUENCY_HOURS"].parsedDuration
}
func (c *configOptions) CleanupRemoveSessionsInterval() time.Duration {
return c.options["CLEANUP_REMOVE_SESSIONS_DAYS"].parsedDuration
}
func (c *configOptions) CreateAdmin() bool {
return c.options["CREATE_ADMIN"].parsedBoolValue
}
func (c *configOptions) DatabaseConnectionLifetime() time.Duration {
return c.options["DATABASE_CONNECTION_LIFETIME"].parsedDuration
}
func (c *configOptions) DatabaseMaxConns() int {
return c.options["DATABASE_MAX_CONNS"].parsedIntValue
}
func (c *configOptions) DatabaseMinConns() int {
return c.options["DATABASE_MIN_CONNS"].parsedIntValue
}
func (c *configOptions) DatabaseURL() string {
return c.options["DATABASE_URL"].parsedStringValue
}
func (c *configOptions) DisableHSTS() bool {
return c.options["DISABLE_HSTS"].parsedBoolValue
}
func (c *configOptions) DisableHTTPService() bool {
return c.options["DISABLE_HTTP_SERVICE"].parsedBoolValue
}
func (c *configOptions) DisableLocalAuth() bool {
return c.options["DISABLE_LOCAL_AUTH"].parsedBoolValue
}
func (c *configOptions) DisableSchedulerService() bool {
return c.options["DISABLE_SCHEDULER_SERVICE"].parsedBoolValue
}
func (c *configOptions) FetchBilibiliWatchTime() bool {
return c.options["FETCH_BILIBILI_WATCH_TIME"].parsedBoolValue
}
func (c *configOptions) FetchNebulaWatchTime() bool {
return c.options["FETCH_NEBULA_WATCH_TIME"].parsedBoolValue
}
func (c *configOptions) FetchOdyseeWatchTime() bool {
return c.options["FETCH_ODYSEE_WATCH_TIME"].parsedBoolValue
}
func (c *configOptions) FetchYouTubeWatchTime() bool {
return c.options["FETCH_YOUTUBE_WATCH_TIME"].parsedBoolValue
}
func (c *configOptions) ForceRefreshInterval() time.Duration {
return c.options["FORCE_REFRESH_INTERVAL"].parsedDuration
}
func (c *configOptions) HasHTTPClientProxiesConfigured() bool {
return len(c.options["HTTP_CLIENT_PROXIES"].parsedStringList) > 0
}
func (c *configOptions) HasAPI() bool {
return !c.options["DISABLE_API"].parsedBoolValue
}
func (c *configOptions) HasHTTPService() bool {
return !c.options["DISABLE_HTTP_SERVICE"].parsedBoolValue
}
func (c *configOptions) HasHSTS() bool {
return !c.options["DISABLE_HSTS"].parsedBoolValue
}
func (c *configOptions) HasHTTPClientProxyURLConfigured() bool {
return c.options["HTTP_CLIENT_PROXY"].parsedURLValue != nil
}
func (c *configOptions) HasMaintenanceMode() bool {
return c.options["MAINTENANCE_MODE"].parsedBoolValue
}
func (c *configOptions) HasMetricsCollector() bool {
return c.options["METRICS_COLLECTOR"].parsedBoolValue
}
func (c *configOptions) HasSchedulerService() bool {
return !c.options["DISABLE_SCHEDULER_SERVICE"].parsedBoolValue
}
func (c *configOptions) HasWatchdog() bool {
return c.options["WATCHDOG"].parsedBoolValue
}
func (c *configOptions) HTTPClientMaxBodySize() int64 {
return c.options["HTTP_CLIENT_MAX_BODY_SIZE"].parsedInt64Value * 1024 * 1024
}
func (c *configOptions) HTTPClientProxies() []string {
return c.options["HTTP_CLIENT_PROXIES"].parsedStringList
}
func (c *configOptions) HTTPClientProxyURL() *url.URL {
return c.options["HTTP_CLIENT_PROXY"].parsedURLValue
}
func (c *configOptions) HTTPClientTimeout() time.Duration {
return c.options["HTTP_CLIENT_TIMEOUT"].parsedDuration
}
func (c *configOptions) HTTPClientUserAgent() string {
if c.options["HTTP_CLIENT_USER_AGENT"].parsedStringValue != "" {
return c.options["HTTP_CLIENT_USER_AGENT"].parsedStringValue
}
return defaultHTTPClientUserAgent
}
func (c *configOptions) HTTPServerTimeout() time.Duration {
return c.options["HTTP_SERVER_TIMEOUT"].parsedDuration
}
func (c *configOptions) HTTPS() bool {
return c.options["HTTPS"].parsedBoolValue
}
func (c *configOptions) FetcherAllowPrivateNetworks() bool {
return c.options["FETCHER_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) IntegrationAllowPrivateNetworks() bool {
if c == nil {
return false
}
return c.options["INTEGRATION_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
}
func (c *configOptions) InvidiousInstance() string {
return c.options["INVIDIOUS_INSTANCE"].parsedStringValue
}
func (c *configOptions) IsAuthProxyUserCreationAllowed() bool {
return c.options["AUTH_PROXY_USER_CREATION"].parsedBoolValue
}
func (c *configOptions) IsDefaultDatabaseURL() bool {
return c.options["DATABASE_URL"].rawValue == "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
}
func (c *configOptions) IsOAuth2UserCreationAllowed() bool {
return c.options["OAUTH2_USER_CREATION"].parsedBoolValue
}
func (c *configOptions) CertKeyFile() string {
return c.options["KEY_FILE"].parsedStringValue
}
func (c *configOptions) ListenAddr() []string {
return c.options["LISTEN_ADDR"].parsedStringList
}
func (c *configOptions) LogFile() string {
return c.options["LOG_FILE"].parsedStringValue
}
func (c *configOptions) LogDateTime() bool {
return c.options["LOG_DATE_TIME"].parsedBoolValue
}
func (c *configOptions) LogFormat() string {
return c.options["LOG_FORMAT"].parsedStringValue
}
func (c *configOptions) LogLevel() string {
return c.options["LOG_LEVEL"].parsedStringValue
}
func (c *configOptions) MaintenanceMessage() string {
return c.options["MAINTENANCE_MESSAGE"].parsedStringValue
}
func (c *configOptions) MaintenanceMode() bool {
return c.options["MAINTENANCE_MODE"].parsedBoolValue
}
func (c *configOptions) MediaCustomProxyURL() *url.URL {
return c.options["MEDIA_PROXY_CUSTOM_URL"].parsedURLValue
}
func (c *configOptions) MediaProxyHTTPClientTimeout() time.Duration {
return c.options["MEDIA_PROXY_HTTP_CLIENT_TIMEOUT"].parsedDuration
}
func (c *configOptions) MediaProxyMode() string {
return c.options["MEDIA_PROXY_MODE"].parsedStringValue
}
func (c *configOptions) MediaProxyPrivateKey() []byte {
return c.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue
}
func (c *configOptions) MediaProxyResourceTypes() []string {
return c.options["MEDIA_PROXY_RESOURCE_TYPES"].parsedStringList
}
func (c *configOptions) MetricsAllowedNetworks() []string {
return c.options["METRICS_ALLOWED_NETWORKS"].parsedStringList
}
func (c *configOptions) MetricsCollector() bool {
return c.options["METRICS_COLLECTOR"].parsedBoolValue
}
func (c *configOptions) MetricsPassword() string {
return c.options["METRICS_PASSWORD"].parsedStringValue
}
func (c *configOptions) MetricsRefreshInterval() time.Duration {
return c.options["METRICS_REFRESH_INTERVAL"].parsedDuration
}
func (c *configOptions) MetricsUsername() string {
return c.options["METRICS_USERNAME"].parsedStringValue
}
func (c *configOptions) OAuth2ClientID() string {
return c.options["OAUTH2_CLIENT_ID"].parsedStringValue
}
func (c *configOptions) OAuth2ClientSecret() string {
return c.options["OAUTH2_CLIENT_SECRET"].parsedStringValue
}
func (c *configOptions) OAuth2OIDCDiscoveryEndpoint() string {
return c.options["OAUTH2_OIDC_DISCOVERY_ENDPOINT"].parsedStringValue
}
func (c *configOptions) OAuth2OIDCProviderName() string {
return c.options["OAUTH2_OIDC_PROVIDER_NAME"].parsedStringValue
}
func (c *configOptions) OAuth2Provider() string {
return c.options["OAUTH2_PROVIDER"].parsedStringValue
}
func (c *configOptions) OAuth2RedirectURL() string {
return c.options["OAUTH2_REDIRECT_URL"].parsedStringValue
}
func (c *configOptions) OAuth2UserCreation() bool {
return c.options["OAUTH2_USER_CREATION"].parsedBoolValue
}
func (c *configOptions) PollingFrequency() time.Duration {
return c.options["POLLING_FREQUENCY"].parsedDuration
}
func (c *configOptions) PollingLimitPerHost() int {
return c.options["POLLING_LIMIT_PER_HOST"].parsedIntValue
}
func (c *configOptions) PollingParsingErrorLimit() int {
return c.options["POLLING_PARSING_ERROR_LIMIT"].parsedIntValue
}
func (c *configOptions) PollingScheduler() string {
return c.options["POLLING_SCHEDULER"].parsedStringValue
}
func (c *configOptions) Port() string {
return c.options["PORT"].parsedStringValue
}
func (c *configOptions) RunMigrations() bool {
return c.options["RUN_MIGRATIONS"].parsedBoolValue
}
func (c *configOptions) SetLogLevel(level string) {
c.options["LOG_LEVEL"].parsedStringValue = level
c.options["LOG_LEVEL"].rawValue = level
}
func (c *configOptions) SetHTTPSValue(value bool) {
c.options["HTTPS"].parsedBoolValue = value
if value {
c.options["HTTPS"].rawValue = "1"
} else {
c.options["HTTPS"].rawValue = "0"
}
}
func (c *configOptions) SchedulerEntryFrequencyFactor() int {
return c.options["SCHEDULER_ENTRY_FREQUENCY_FACTOR"].parsedIntValue
}
func (c *configOptions) SchedulerEntryFrequencyMaxInterval() time.Duration {
return c.options["SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL"].parsedDuration
}
func (c *configOptions) SchedulerEntryFrequencyMinInterval() time.Duration {
return c.options["SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL"].parsedDuration
}
func (c *configOptions) SchedulerRoundRobinMaxInterval() time.Duration {
return c.options["SCHEDULER_ROUND_ROBIN_MAX_INTERVAL"].parsedDuration
}
func (c *configOptions) SchedulerRoundRobinMinInterval() time.Duration {
return c.options["SCHEDULER_ROUND_ROBIN_MIN_INTERVAL"].parsedDuration
}
func (c *configOptions) TrustedReverseProxyNetworks() []string {
return c.options["TRUSTED_REVERSE_PROXY_NETWORKS"].parsedStringList
}
func (c *configOptions) Watchdog() bool {
return c.options["WATCHDOG"].parsedBoolValue
}
func (c *configOptions) WebAuthn() bool {
return c.options["WEBAUTHN"].parsedBoolValue
}
func (c *configOptions) WorkerPoolSize() int {
return c.options["WORKER_POOL_SIZE"].parsedIntValue
}
func (c *configOptions) YouTubeAPIKey() string {
return c.options["YOUTUBE_API_KEY"].parsedStringValue
}
func (c *configOptions) YouTubeEmbedUrlOverride() string {
return c.options["YOUTUBE_EMBED_URL_OVERRIDE"].parsedStringValue
}
func (c *configOptions) YouTubeEmbedDomain() string {
return c.youTubeEmbedDomain
}
func (c *configOptions) ConfigMap(redactSecret bool) []*optionPair {
sortedKeys := slices.Sorted(maps.Keys(c.options))
sortedOptions := make([]*optionPair, 0, len(sortedKeys))
for _, key := range sortedKeys {
value := c.options[key]
displayValue := value.rawValue
if displayValue != "" && redactSecret && value.secret {
displayValue = ""
}
sortedOptions = append(sortedOptions, &optionPair{Key: key, Value: displayValue})
}
return sortedOptions
}
func (c *configOptions) String() string {
var builder strings.Builder
for _, option := range c.ConfigMap(false) {
builder.WriteString(option.Key)
builder.WriteByte('=')
builder.WriteString(option.Value)
builder.WriteByte('\n')
}
return builder.String()
}
v2-2.3.0/internal/config/options_parsing_test.go 0000664 0000000 0000000 00000175623 15201231005 0021733 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"slices"
"testing"
)
func TestBaseURLOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.BaseURL() != "http://localhost" {
t.Fatalf("Expected BASE_URL to be 'http://localhost' by default")
}
if configParser.options.RootURL() != "http://localhost" {
t.Fatalf("Expected ROOT_URL to be 'http://localhost' by default")
}
if configParser.options.BasePath() != "" {
t.Fatalf("Expected BASE_PATH to be empty by default")
}
if err := configParser.parseLines([]string{"BASE_URL=https://example.com/app"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.BaseURL() != "https://example.com/app" {
t.Fatalf("Expected BASE_URL to be 'https://example.com/app', got '%s'", configParser.options.BaseURL())
}
if configParser.options.RootURL() != "https://example.com" {
t.Fatalf("Expected ROOT_URL to be 'https://example.com', got '%s'", configParser.options.RootURL())
}
if configParser.options.BasePath() != "/app" {
t.Fatalf("Expected BASE_PATH to be '/app', got '%s'", configParser.options.BasePath())
}
if err := configParser.parseLines([]string{"BASE_URL=https://example.com/app/"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.BaseURL() != "https://example.com/app" {
t.Fatalf("Expected BASE_URL to be 'https://example.com/app', got '%s'", configParser.options.BaseURL())
}
if configParser.options.RootURL() != "https://example.com" {
t.Fatalf("Expected ROOT_URL to be 'https://example.com', got '%s'", configParser.options.RootURL())
}
if configParser.options.BasePath() != "/app" {
t.Fatalf("Expected BASE_PATH to be '/app', got '%s'", configParser.options.BasePath())
}
if err := configParser.parseLines([]string{"BASE_URL=example.com/app/"}); err == nil {
t.Fatal("Expected an error due to missing scheme in BASE_URL")
}
}
func TestWatchdogOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if !configParser.options.Watchdog() {
t.Fatal("Expected WATCHDOG to be enabled by default")
}
if !configParser.options.HasSchedulerService() {
t.Fatal("Expected HAS_SCHEDULER_SERVICE to be enabled by default")
}
if err := configParser.parseLines([]string{"WATCHDOG=1"}); err != nil {
t.Fatal("Unexpected error:", err)
}
if !configParser.options.Watchdog() {
t.Fatal("Expected WATCHDOG to be enabled")
}
if !configParser.options.HasSchedulerService() {
t.Fatal("Expected HAS_SCHEDULER_SERVICE to be enabled")
}
if err := configParser.parseLines([]string{"WATCHDOG=0"}); err != nil {
t.Fatal("Unexpected error:", err)
}
if configParser.options.Watchdog() {
t.Fatal("Expected WATCHDOG to be disabled")
}
if configParser.options.HasWatchdog() {
t.Fatal("Expected HAS_WATCHDOG to be disabled")
}
}
func TestWebAuthnOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.WebAuthn() {
t.Fatalf("Expected WEBAUTHN to be disabled by default")
}
if err := configParser.parseLines([]string{"WEBAUTHN=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.WebAuthn() {
t.Fatalf("Expected WEBAUTHN to be enabled")
}
}
func TestWorkerPoolSizeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.WorkerPoolSize() != 16 {
t.Fatalf("Expected WORKER_POOL_SIZE to be 16 by default")
}
if err := configParser.parseLines([]string{"WORKER_POOL_SIZE=8"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.WorkerPoolSize() != 8 {
t.Fatalf("Expected WORKER_POOL_SIZE to be 8")
}
}
func TestYouTubeAPIKeyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.YouTubeAPIKey() != "" {
t.Fatalf("Expected YOUTUBE_API_KEY to be empty by default")
}
if err := configParser.parseLines([]string{"YOUTUBE_API_KEY=somekey"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.YouTubeAPIKey() != "somekey" {
t.Fatalf("Expected YOUTUBE_API_KEY to be 'somekey'")
}
}
func TestAdminPasswordOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.AdminPassword() != "" {
t.Fatalf("Expected ADMIN_PASSWORD to be empty by default")
}
if err := configParser.parseLines([]string{"ADMIN_PASSWORD=secret123"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.AdminPassword() != "secret123" {
t.Fatalf("Expected ADMIN_PASSWORD to be 'secret123'")
}
}
func TestAdminUsernameOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.AdminUsername() != "" {
t.Fatalf("Expected ADMIN_USERNAME to be empty by default")
}
if err := configParser.parseLines([]string{"ADMIN_USERNAME=admin"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.AdminUsername() != "admin" {
t.Fatalf("Expected ADMIN_USERNAME to be 'admin'")
}
}
func TestAuthProxyHeaderOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.AuthProxyHeader() != "" {
t.Fatalf("Expected AUTH_PROXY_HEADER to be empty by default")
}
if err := configParser.parseLines([]string{"AUTH_PROXY_HEADER=X-Forwarded-User"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.AuthProxyHeader() != "X-Forwarded-User" {
t.Fatalf("Expected AUTH_PROXY_HEADER to be 'X-Forwarded-User'")
}
}
func TestAuthProxyUserCreationOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.AuthProxyUserCreation() {
t.Fatal("Expected AUTH_PROXY_USER_CREATION to be disabled by default")
}
if configParser.options.IsAuthProxyUserCreationAllowed() {
t.Fatal("Expected HAS_AUTH_PROXY_USER_CREATION to be disabled by default")
}
if err := configParser.parseLines([]string{"AUTH_PROXY_USER_CREATION=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.AuthProxyUserCreation() {
t.Fatal("Expected AUTH_PROXY_USER_CREATION to be enabled")
}
if !configParser.options.IsAuthProxyUserCreationAllowed() {
t.Fatal("Expected HAS_AUTH_PROXY_USER_CREATION to be enabled")
}
if err := configParser.parseLines([]string{"AUTH_PROXY_USER_CREATION=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.AuthProxyUserCreation() {
t.Fatal("Expected AUTH_PROXY_USER_CREATION to be disabled")
}
if configParser.options.IsAuthProxyUserCreationAllowed() {
t.Fatal("Expected HAS_AUTH_PROXY_USER_CREATION to be disabled")
}
}
func TestBatchSizeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.BatchSize() != 100 {
t.Fatalf("Expected BATCH_SIZE to be 100 by default")
}
if err := configParser.parseLines([]string{"BATCH_SIZE=50"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.BatchSize() != 50 {
t.Fatalf("Expected BATCH_SIZE to be 50")
}
}
func TestCertDomainOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CertDomain() != "" {
t.Fatalf("Expected CERT_DOMAIN to be empty by default")
}
if err := configParser.parseLines([]string{"CERT_DOMAIN=example.com"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CertDomain() != "example.com" {
t.Fatalf("Expected CERT_DOMAIN to be 'example.com'")
}
}
func TestCertFileOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CertFile() != "" {
t.Fatalf("Expected CERT_FILE to be empty by default")
}
if err := configParser.parseLines([]string{"CERT_FILE=/path/to/cert.pem"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CertFile() != "/path/to/cert.pem" {
t.Fatalf("Expected CERT_FILE to be '/path/to/cert.pem'")
}
}
func TestCleanupArchiveBatchSizeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CleanupArchiveBatchSize() != 10000 {
t.Fatalf("Expected CLEANUP_ARCHIVE_BATCH_SIZE to be 10000 by default")
}
if err := configParser.parseLines([]string{"CLEANUP_ARCHIVE_BATCH_SIZE=5000"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CleanupArchiveBatchSize() != 5000 {
t.Fatalf("Expected CLEANUP_ARCHIVE_BATCH_SIZE to be 5000")
}
}
func TestCreateAdminOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CreateAdmin() {
t.Fatalf("Expected CREATE_ADMIN to be disabled by default")
}
if err := configParser.parseLines([]string{"CREATE_ADMIN=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.CreateAdmin() {
t.Fatalf("Expected CREATE_ADMIN to be enabled")
}
if err := configParser.parseLines([]string{"CREATE_ADMIN=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CreateAdmin() {
t.Fatalf("Expected CREATE_ADMIN to be disabled")
}
}
func TestDatabaseMaxConnsOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DatabaseMaxConns() != 20 {
t.Fatalf("Expected DATABASE_MAX_CONNS to be 20 by default")
}
if err := configParser.parseLines([]string{"DATABASE_MAX_CONNS=10"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DatabaseMaxConns() != 10 {
t.Fatalf("Expected DATABASE_MAX_CONNS to be 10")
}
}
func TestDatabaseMinConnsOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DatabaseMinConns() != 1 {
t.Fatalf("Expected DATABASE_MIN_CONNS to be 1 by default")
}
if err := configParser.parseLines([]string{"DATABASE_MIN_CONNS=2"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DatabaseMinConns() != 2 {
t.Fatalf("Expected DATABASE_MIN_CONNS to be 2")
}
}
func TestDatabaseURLOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DatabaseURL() != "user=postgres password=postgres dbname=miniflux2 sslmode=disable" {
t.Fatal("Expected DATABASE_URL to have default value")
}
if !configParser.options.IsDefaultDatabaseURL() {
t.Fatal("Expected DATABASE_URL to be the default value")
}
if err := configParser.parseLines([]string{"DATABASE_URL=postgres://user:pass@localhost/db"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DatabaseURL() != "postgres://user:pass@localhost/db" {
t.Fatal("Expected DATABASE_URL to be 'postgres://user:pass@localhost/db'")
}
if configParser.options.IsDefaultDatabaseURL() {
t.Fatal("Expected DATABASE_URL to not be the default value")
}
}
func TestDisableHSTSOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DisableHSTS() {
t.Fatal("Expected DISABLE_HSTS to be disabled by default")
}
if !configParser.options.HasHSTS() {
t.Fatal("Expected HAS_HSTS to be enabled by default")
}
if err := configParser.parseLines([]string{"DISABLE_HSTS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.DisableHSTS() {
t.Fatal("Expected DISABLE_HSTS to be enabled")
}
if configParser.options.HasHSTS() {
t.Fatal("Expected HAS_HSTS to be disabled")
}
if err := configParser.parseLines([]string{"DISABLE_HSTS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DisableHSTS() {
t.Fatal("Expected DISABLE_HSTS to be disabled")
}
if !configParser.options.HasHSTS() {
t.Fatal("Expected HAS_HSTS to be enabled")
}
}
func TestDisableHTTPServiceOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DisableHTTPService() {
t.Fatal("Expected DISABLE_HTTP_SERVICE to be disabled by default")
}
if !configParser.options.HasHTTPService() {
t.Fatal("Expected HAS_HTTP_SERVICE to be enabled by default")
}
if err := configParser.parseLines([]string{"DISABLE_HTTP_SERVICE=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.DisableHTTPService() {
t.Fatal("Expected DISABLE_HTTP_SERVICE to be enabled")
}
if configParser.options.HasHTTPService() {
t.Fatal("Expected HAS_HTTP_SERVICE to be disabled")
}
if err := configParser.parseLines([]string{"DISABLE_HTTP_SERVICE=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DisableHTTPService() {
t.Fatal("Expected DISABLE_HTTP_SERVICE to be disabled")
}
if !configParser.options.HasHTTPService() {
t.Fatal("Expected HAS_HTTP_SERVICE to be disabled")
}
}
func TestDisableLocalAuthOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DisableLocalAuth() {
t.Fatalf("Expected DISABLE_LOCAL_AUTH to be disabled by default")
}
if err := configParser.parseLines([]string{"DISABLE_LOCAL_AUTH=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.DisableLocalAuth() {
t.Fatalf("Expected DISABLE_LOCAL_AUTH to be enabled")
}
if err := configParser.parseLines([]string{"DISABLE_LOCAL_AUTH=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DisableLocalAuth() {
t.Fatalf("Expected DISABLE_LOCAL_AUTH to be disabled")
}
}
func TestDisableSchedulerServiceOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DisableSchedulerService() {
t.Fatal("Expected DISABLE_SCHEDULER_SERVICE to be disabled by default")
}
if !configParser.options.HasSchedulerService() {
t.Fatal("Expected HAS_SCHEDULER_SERVICE to be enabled by default")
}
if err := configParser.parseLines([]string{"DISABLE_SCHEDULER_SERVICE=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.DisableSchedulerService() {
t.Fatal("Expected DISABLE_SCHEDULER_SERVICE to be enabled")
}
if configParser.options.HasSchedulerService() {
t.Fatal("Expected HAS_SCHEDULER_SERVICE to be disabled")
}
if err := configParser.parseLines([]string{"DISABLE_SCHEDULER_SERVICE=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DisableSchedulerService() {
t.Fatal("Expected DISABLE_SCHEDULER_SERVICE to be disabled")
}
if !configParser.options.HasSchedulerService() {
t.Fatal("Expected HAS_SCHEDULER_SERVICE to be enabled")
}
}
func TestFetchBilibiliWatchTimeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FetchBilibiliWatchTime() {
t.Fatalf("Expected FETCH_BILIBILI_WATCH_TIME to be disabled by default")
}
if err := configParser.parseLines([]string{"FETCH_BILIBILI_WATCH_TIME=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.FetchBilibiliWatchTime() {
t.Fatalf("Expected FETCH_BILIBILI_WATCH_TIME to be enabled")
}
if err := configParser.parseLines([]string{"FETCH_BILIBILI_WATCH_TIME=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FetchBilibiliWatchTime() {
t.Fatalf("Expected FETCH_BILIBILI_WATCH_TIME to be disabled")
}
}
func TestFetchNebulaWatchTimeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FetchNebulaWatchTime() {
t.Fatalf("Expected FETCH_NEBULA_WATCH_TIME to be disabled by default")
}
if err := configParser.parseLines([]string{"FETCH_NEBULA_WATCH_TIME=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.FetchNebulaWatchTime() {
t.Fatalf("Expected FETCH_NEBULA_WATCH_TIME to be enabled")
}
if err := configParser.parseLines([]string{"FETCH_NEBULA_WATCH_TIME=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FetchNebulaWatchTime() {
t.Fatalf("Expected FETCH_NEBULA_WATCH_TIME to be disabled")
}
}
func TestFetchOdyseeWatchTimeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FetchOdyseeWatchTime() {
t.Fatalf("Expected FETCH_ODYSEE_WATCH_TIME to be disabled by default")
}
if err := configParser.parseLines([]string{"FETCH_ODYSEE_WATCH_TIME=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.FetchOdyseeWatchTime() {
t.Fatalf("Expected FETCH_ODYSEE_WATCH_TIME to be enabled")
}
if err := configParser.parseLines([]string{"FETCH_ODYSEE_WATCH_TIME=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FetchOdyseeWatchTime() {
t.Fatalf("Expected FETCH_ODYSEE_WATCH_TIME to be disabled")
}
}
func TestFetchYouTubeWatchTimeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FetchYouTubeWatchTime() {
t.Fatalf("Expected FETCH_YOUTUBE_WATCH_TIME to be disabled by default")
}
if err := configParser.parseLines([]string{"FETCH_YOUTUBE_WATCH_TIME=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.FetchYouTubeWatchTime() {
t.Fatalf("Expected FETCH_YOUTUBE_WATCH_TIME to be enabled")
}
if err := configParser.parseLines([]string{"FETCH_YOUTUBE_WATCH_TIME=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FetchYouTubeWatchTime() {
t.Fatalf("Expected FETCH_YOUTUBE_WATCH_TIME to be disabled")
}
}
func TestHTTPClientMaxBodySizeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HTTPClientMaxBodySize() != 15*1024*1024 {
t.Fatalf("Expected HTTP_CLIENT_MAX_BODY_SIZE to be 15 by default, got %d", configParser.options.HTTPClientMaxBodySize())
}
if err := configParser.parseLines([]string{"HTTP_CLIENT_MAX_BODY_SIZE=25"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
expectedValue := 25 * 1024 * 1024
currentValue := configParser.options.HTTPClientMaxBodySize()
if currentValue != int64(expectedValue) {
t.Fatalf("Expected HTTP_CLIENT_MAX_BODY_SIZE to be %d, got %d", expectedValue, currentValue)
}
}
func TestHTTPClientUserAgentOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HTTPClientUserAgent() != defaultHTTPClientUserAgent {
t.Fatalf("Expected HTTP_CLIENT_USER_AGENT to have default value")
}
if err := configParser.parseLines([]string{"HTTP_CLIENT_USER_AGENT=Custom User Agent"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.HTTPClientUserAgent() != "Custom User Agent" {
t.Fatalf("Expected HTTP_CLIENT_USER_AGENT to be 'Custom User Agent'")
}
}
func TestHTTPSOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HTTPS() {
t.Fatalf("Expected HTTPS to be disabled by default")
}
if err := configParser.parseLines([]string{"HTTPS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.HTTPS() {
t.Fatalf("Expected HTTPS to be enabled")
}
if err := configParser.parseLines([]string{"HTTPS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.HTTPS() {
t.Fatalf("Expected HTTPS to be disabled")
}
}
func TestInvidiousInstanceOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.InvidiousInstance() != "yewtu.be" {
t.Fatalf("Expected INVIDIOUS_INSTANCE to be 'yewtu.be' by default")
}
if err := configParser.parseLines([]string{"INVIDIOUS_INSTANCE=invidious.example.com"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.InvidiousInstance() != "invidious.example.com" {
t.Fatalf("Expected INVIDIOUS_INSTANCE to be 'invidious.example.com'")
}
}
func TestCertKeyFileOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CertKeyFile() != "" {
t.Fatalf("Expected KEY_FILE to be empty by default")
}
if err := configParser.parseLines([]string{"KEY_FILE=/path/to/key.pem"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CertKeyFile() != "/path/to/key.pem" {
t.Fatalf("Expected KEY_FILE to be '/path/to/key.pem'")
}
}
func TestLogDateTimeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.LogDateTime() {
t.Fatalf("Expected LOG_DATE_TIME to be disabled by default")
}
if err := configParser.parseLines([]string{"LOG_DATE_TIME=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.LogDateTime() {
t.Fatalf("Expected LOG_DATE_TIME to be enabled")
}
if err := configParser.parseLines([]string{"LOG_DATE_TIME=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.LogDateTime() {
t.Fatalf("Expected LOG_DATE_TIME to be disabled")
}
}
func TestLogFileOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.LogFile() != "stderr" {
t.Fatalf("Expected LOG_FILE to be 'stderr' by default")
}
if err := configParser.parseLines([]string{"LOG_FILE=/var/log/miniflux.log"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.LogFile() != "/var/log/miniflux.log" {
t.Fatalf("Expected LOG_FILE to be '/var/log/miniflux.log'")
}
}
func TestLogFormatOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.LogFormat() != "text" {
t.Fatalf("Expected LOG_FORMAT to be 'text' by default")
}
if err := configParser.parseLines([]string{"LOG_FORMAT=json"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.LogFormat() != "json" {
t.Fatalf("Expected LOG_FORMAT to be 'json'")
}
}
func TestLogLevelOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.LogLevel() != "info" {
t.Fatalf("Expected LOG_LEVEL to be 'info' by default")
}
if err := configParser.parseLines([]string{"LOG_LEVEL=debug"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.LogLevel() != "debug" {
t.Fatalf("Expected LOG_LEVEL to be 'debug'")
}
}
func TestMaintenanceMessageOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MaintenanceMessage() != "Miniflux is currently under maintenance" {
t.Fatalf("Expected MAINTENANCE_MESSAGE to have default value")
}
if err := configParser.parseLines([]string{"MAINTENANCE_MESSAGE=System upgrade in progress"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MaintenanceMessage() != "System upgrade in progress" {
t.Fatalf("Expected MAINTENANCE_MESSAGE to be 'System upgrade in progress'")
}
}
func TestMaintenanceModeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MaintenanceMode() {
t.Fatal("Expected MAINTENANCE_MODE to be disabled by default")
}
if configParser.options.HasMaintenanceMode() {
t.Fatal("Expected HAS_MAINTENANCE_MODE to be disabled by default")
}
if err := configParser.parseLines([]string{"MAINTENANCE_MODE=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.MaintenanceMode() {
t.Fatal("Expected MAINTENANCE_MODE to be enabled")
}
if !configParser.options.HasMaintenanceMode() {
t.Fatal("Expected HAS_MAINTENANCE_MODE to be enabled")
}
if err := configParser.parseLines([]string{"MAINTENANCE_MODE=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MaintenanceMode() {
t.Fatal("Expected MAINTENANCE_MODE to be disabled")
}
if configParser.options.HasMaintenanceMode() {
t.Fatal("Expected HAS_MAINTENANCE_MODE to be disabled")
}
}
func TestMediaProxyModeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MediaProxyMode() != "http-only" {
t.Fatalf("Expected MEDIA_PROXY_MODE to be 'http-only' by default")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_MODE=all"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MediaProxyMode() != "all" {
t.Fatalf("Expected MEDIA_PROXY_MODE to be 'all'")
}
}
func TestMetricsCollectorOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MetricsCollector() {
t.Fatal("Expected METRICS_COLLECTOR to be disabled by default")
}
if configParser.options.HasMetricsCollector() {
t.Fatal("Expected HAS_METRICS_COLLECTOR to be disabled by default")
}
if err := configParser.parseLines([]string{"METRICS_COLLECTOR=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.MetricsCollector() {
t.Fatal("Expected METRICS_COLLECTOR to be enabled")
}
if !configParser.options.HasMetricsCollector() {
t.Fatal("Expected HAS_METRICS_COLLECTOR to be enabled")
}
if err := configParser.parseLines([]string{"METRICS_COLLECTOR=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MetricsCollector() {
t.Fatal("Expected METRICS_COLLECTOR to be disabled")
}
if configParser.options.HasMetricsCollector() {
t.Fatal("Expected HAS_METRICS_COLLECTOR to be disabled")
}
}
func TestMetricsPasswordOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MetricsPassword() != "" {
t.Fatalf("Expected METRICS_PASSWORD to be empty by default")
}
if err := configParser.parseLines([]string{"METRICS_PASSWORD=secret123"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MetricsPassword() != "secret123" {
t.Fatalf("Expected METRICS_PASSWORD to be 'secret123'")
}
}
func TestMetricsUsernameOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MetricsUsername() != "" {
t.Fatalf("Expected METRICS_USERNAME to be empty by default")
}
if err := configParser.parseLines([]string{"METRICS_USERNAME=metrics_user"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MetricsUsername() != "metrics_user" {
t.Fatalf("Expected METRICS_USERNAME to be 'metrics_user'")
}
}
func TestOAuth2ClientIDOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2ClientID() != "" {
t.Fatalf("Expected OAUTH2_CLIENT_ID to be empty by default")
}
if err := configParser.parseLines([]string{"OAUTH2_CLIENT_ID=client123"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2ClientID() != "client123" {
t.Fatalf("Expected OAUTH2_CLIENT_ID to be 'client123'")
}
}
func TestOAuth2ClientSecretOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2ClientSecret() != "" {
t.Fatalf("Expected OAUTH2_CLIENT_SECRET to be empty by default")
}
if err := configParser.parseLines([]string{"OAUTH2_CLIENT_SECRET=secret456"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2ClientSecret() != "secret456" {
t.Fatalf("Expected OAUTH2_CLIENT_SECRET to be 'secret456'")
}
}
func TestOAuth2OIDCDiscoveryEndpointOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2OIDCDiscoveryEndpoint() != "" {
t.Fatalf("Expected OAUTH2_OIDC_DISCOVERY_ENDPOINT to be empty by default")
}
if err := configParser.parseLines([]string{"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid_configuration"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2OIDCDiscoveryEndpoint() != "https://example.com/.well-known/openid_configuration" {
t.Fatalf("Expected OAUTH2_OIDC_DISCOVERY_ENDPOINT to be 'https://example.com/.well-known/openid_configuration'")
}
}
func TestOAuth2OIDCProviderNameOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2OIDCProviderName() != "OpenID Connect" {
t.Fatalf("Expected OAUTH2_OIDC_PROVIDER_NAME to be 'OpenID Connect' by default")
}
if err := configParser.parseLines([]string{"OAUTH2_OIDC_PROVIDER_NAME=My Provider"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2OIDCProviderName() != "My Provider" {
t.Fatalf("Expected OAUTH2_OIDC_PROVIDER_NAME to be 'My Provider'")
}
}
func TestOAuth2ProviderOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2Provider() != "" {
t.Fatal("Expected OAUTH2_PROVIDER to be empty by default")
}
if err := configParser.parseLines([]string{"OAUTH2_PROVIDER=google"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2Provider() != "google" {
t.Fatal("Expected OAUTH2_PROVIDER to be 'google'")
}
if err := configParser.parseLines([]string{"OAUTH2_PROVIDER=oidc"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2Provider() != "oidc" {
t.Fatal("Expected OAUTH2_PROVIDER to be 'oidc'")
}
if err := configParser.parseLines([]string{"OAUTH2_PROVIDER=invalid"}); err == nil {
t.Fatal("Expected error for invalid OAUTH2_PROVIDER value")
}
}
func TestOAuth2RedirectURLOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2RedirectURL() != "" {
t.Fatalf("Expected OAUTH2_REDIRECT_URL to be empty by default")
}
if err := configParser.parseLines([]string{"OAUTH2_REDIRECT_URL=https://example.com/callback"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2RedirectURL() != "https://example.com/callback" {
t.Fatalf("Expected OAUTH2_REDIRECT_URL to be 'https://example.com/callback'")
}
}
func TestOAuth2UserCreationOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.OAuth2UserCreation() {
t.Fatal("Expected OAUTH2_USER_CREATION to be disabled by default")
}
if configParser.options.IsOAuth2UserCreationAllowed() {
t.Fatal("Expected OAUTH2_USER_CREATION to be disabled by default")
}
if err := configParser.parseLines([]string{"OAUTH2_USER_CREATION=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.OAuth2UserCreation() {
t.Fatal("Expected OAUTH2_USER_CREATION to be enabled")
}
if !configParser.options.IsOAuth2UserCreationAllowed() {
t.Fatal("Expected OAUTH2_USER_CREATION to be enabled")
}
if err := configParser.parseLines([]string{"OAUTH2_USER_CREATION=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.OAuth2UserCreation() {
t.Fatal("Expected OAUTH2_USER_CREATION to be disabled")
}
if configParser.options.IsOAuth2UserCreationAllowed() {
t.Fatal("Expected OAUTH2_USER_CREATION to be disabled")
}
}
func TestPollingLimitPerHostOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.PollingLimitPerHost() != 0 {
t.Fatalf("Expected POLLING_LIMIT_PER_HOST to be 0 by default")
}
if err := configParser.parseLines([]string{"POLLING_LIMIT_PER_HOST=5"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.PollingLimitPerHost() != 5 {
t.Fatalf("Expected POLLING_LIMIT_PER_HOST to be 5")
}
}
func TestPollingParsingErrorLimitOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.PollingParsingErrorLimit() != 3 {
t.Fatalf("Expected POLLING_PARSING_ERROR_LIMIT to be 3 by default")
}
if err := configParser.parseLines([]string{"POLLING_PARSING_ERROR_LIMIT=5"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.PollingParsingErrorLimit() != 5 {
t.Fatalf("Expected POLLING_PARSING_ERROR_LIMIT to be 5")
}
}
func TestPollingSchedulerOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.PollingScheduler() != "round_robin" {
t.Fatalf("Expected POLLING_SCHEDULER to be 'round_robin' by default")
}
if err := configParser.parseLines([]string{"POLLING_SCHEDULER=entry_frequency"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.PollingScheduler() != "entry_frequency" {
t.Fatalf("Expected POLLING_SCHEDULER to be 'entry_frequency'")
}
}
func TestPortOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.Port() != "" {
t.Fatalf("Expected PORT to be empty by default")
}
if err := configParser.parseLines([]string{"PORT=1234"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.Port() != "1234" {
t.Fatalf("Expected PORT to be '1234'")
}
addresses := configParser.options.ListenAddr()
if len(addresses) != 1 || addresses[0] != ":1234" {
t.Fatalf("Expected LISTEN_ADDR to be ':1234'")
}
}
func TestRunMigrationsOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.RunMigrations() {
t.Fatalf("Expected RUN_MIGRATIONS to be disabled by default")
}
if err := configParser.parseLines([]string{"RUN_MIGRATIONS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.RunMigrations() {
t.Fatalf("Expected RUN_MIGRATIONS to be enabled")
}
if err := configParser.parseLines([]string{"RUN_MIGRATIONS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.RunMigrations() {
t.Fatalf("Expected RUN_MIGRATIONS to be disabled")
}
}
func TestSchedulerEntryFrequencyFactorOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.SchedulerEntryFrequencyFactor() != 1 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_FACTOR to be 1 by default")
}
if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_FACTOR=2"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.SchedulerEntryFrequencyFactor() != 2 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_FACTOR to be 2")
}
}
func TestYouTubeEmbedUrlOverrideOptionParsing(t *testing.T) {
configParser := NewConfigParser()
// Test default value
if configParser.options.YouTubeEmbedUrlOverride() != "https://www.youtube-nocookie.com/embed/" {
t.Fatal("Expected YOUTUBE_EMBED_URL_OVERRIDE to have default value")
}
if configParser.options.YouTubeEmbedDomain() != "www.youtube-nocookie.com" {
t.Fatal("Expected YOUTUBE_EMBED_DOMAIN to be 'www.youtube-nocookie.com' by default")
}
// Test custom value
if err := configParser.parseLines([]string{"YOUTUBE_EMBED_URL_OVERRIDE=https://custom.youtube.com/embed/"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.YouTubeEmbedUrlOverride() != "https://custom.youtube.com/embed/" {
t.Fatal("Expected YOUTUBE_EMBED_URL_OVERRIDE to be 'https://custom.youtube.com/embed/'")
}
if configParser.options.YouTubeEmbedDomain() != "custom.youtube.com" {
t.Fatal("Expected YOUTUBE_EMBED_DOMAIN to be 'custom.youtube.com'")
}
// Test empty value resets to default
configParser = NewConfigParser()
if err := configParser.parseLines([]string{"YOUTUBE_EMBED_URL_OVERRIDE="}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.YouTubeEmbedUrlOverride() != "https://www.youtube-nocookie.com/embed/" {
t.Fatal("Expected YOUTUBE_EMBED_URL_OVERRIDE to have default value")
}
// Test invalid value
configParser = NewConfigParser()
if err := configParser.parseLines([]string{"YOUTUBE_EMBED_URL_OVERRIDE=http://example.com/%"}); err == nil {
t.Fatal("Expected error for invalid YOUTUBE_EMBED_URL_OVERRIDE")
}
}
func TestCleanupArchiveReadIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CleanupArchiveReadInterval().Hours() != 24*60 {
t.Fatalf("Expected CLEANUP_ARCHIVE_READ_DAYS to be 60 days by default")
}
if err := configParser.parseLines([]string{"CLEANUP_ARCHIVE_READ_DAYS=30"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CleanupArchiveReadInterval().Hours() != 24*30 {
t.Fatalf("Expected CLEANUP_ARCHIVE_READ_DAYS to be 30 days")
}
}
func TestCleanupArchiveUnreadIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CleanupArchiveUnreadInterval().Hours() != 24*180 {
t.Fatalf("Expected CLEANUP_ARCHIVE_UNREAD_DAYS to be 180 days by default")
}
if err := configParser.parseLines([]string{"CLEANUP_ARCHIVE_UNREAD_DAYS=90"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CleanupArchiveUnreadInterval().Hours() != 24*90 {
t.Fatalf("Expected CLEANUP_ARCHIVE_UNREAD_DAYS to be 90 days")
}
}
func TestCleanupFrequencyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CleanupFrequency().Hours() != 24 {
t.Fatalf("Expected CLEANUP_FREQUENCY_HOURS to be 24 hours by default")
}
if err := configParser.parseLines([]string{"CLEANUP_FREQUENCY_HOURS=12"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CleanupFrequency().Hours() != 12 {
t.Fatalf("Expected CLEANUP_FREQUENCY_HOURS to be 12 hours")
}
}
func TestCleanupRemoveSessionsIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.CleanupRemoveSessionsInterval().Hours() != 24*30 {
t.Fatalf("Expected CLEANUP_REMOVE_SESSIONS_DAYS to be 30 days by default")
}
if err := configParser.parseLines([]string{"CLEANUP_REMOVE_SESSIONS_DAYS=14"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.CleanupRemoveSessionsInterval().Hours() != 24*14 {
t.Fatalf("Expected CLEANUP_REMOVE_SESSIONS_DAYS to be 14 days")
}
}
func TestDatabaseConnectionLifetimeOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.DatabaseConnectionLifetime().Minutes() != 5 {
t.Fatalf("Expected DATABASE_CONNECTION_LIFETIME to be 5 minutes by default")
}
if err := configParser.parseLines([]string{"DATABASE_CONNECTION_LIFETIME=10"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.DatabaseConnectionLifetime().Minutes() != 10 {
t.Fatalf("Expected DATABASE_CONNECTION_LIFETIME to be 10 minutes")
}
}
func TestForceRefreshIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.ForceRefreshInterval().Minutes() != 30 {
t.Fatalf("Expected FORCE_REFRESH_INTERVAL to be 30 minutes by default")
}
if err := configParser.parseLines([]string{"FORCE_REFRESH_INTERVAL=15"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.ForceRefreshInterval().Minutes() != 15 {
t.Fatalf("Expected FORCE_REFRESH_INTERVAL to be 15 minutes")
}
}
func TestHTTPClientProxiesOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HasHTTPClientProxiesConfigured() {
t.Fatalf("Expected HTTP_CLIENT_PROXIES to be empty by default")
}
if err := configParser.parseLines([]string{"HTTP_CLIENT_PROXIES=proxy1.example.com:8080,proxy2.example.com:8080"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.HasHTTPClientProxiesConfigured() {
t.Fatalf("Expected HTTP_CLIENT_PROXIES to be configured")
}
proxies := configParser.options.HTTPClientProxies()
if len(proxies) != 2 || proxies[0] != "proxy1.example.com:8080" || proxies[1] != "proxy2.example.com:8080" {
t.Fatalf("Expected HTTP_CLIENT_PROXIES to contain two proxies")
}
}
func TestHTTPClientProxyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HTTPClientProxyURL() != nil {
t.Fatal("Expected HTTP_CLIENT_PROXY to be nil by default")
}
if configParser.options.HasHTTPClientProxyURLConfigured() {
t.Fatal("Expected HAS_HTTP_CLIENT_PROXY to be disabled by default")
}
if err := configParser.parseLines([]string{"HTTP_CLIENT_PROXY=http://proxy.example.com:8080"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
proxyURL := configParser.options.HTTPClientProxyURL()
if proxyURL == nil || proxyURL.String() != "http://proxy.example.com:8080" {
t.Fatal("Expected HTTP_CLIENT_PROXY to be 'http://proxy.example.com:8080'")
}
if !configParser.options.HasHTTPClientProxyURLConfigured() {
t.Fatal("Expected HAS_HTTP_CLIENT_PROXY to be enabled")
}
}
func TestHTTPClientTimeoutOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HTTPClientTimeout().Seconds() != 20 {
t.Fatalf("Expected HTTP_CLIENT_TIMEOUT to be 20 seconds by default")
}
if err := configParser.parseLines([]string{"HTTP_CLIENT_TIMEOUT=30"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.HTTPClientTimeout().Seconds() != 30 {
t.Fatalf("Expected HTTP_CLIENT_TIMEOUT to be 30 seconds")
}
}
func TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"FETCHER_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.FetcherAllowPrivateNetworks() {
t.Fatalf("Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
func TestIntegrationAllowPrivateNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled by default")
}
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if !configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be enabled")
}
if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.IntegrationAllowPrivateNetworks() {
t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled")
}
}
func TestHTTPServerTimeoutOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.HTTPServerTimeout().Seconds() != 300 {
t.Fatal("Expected HTTP_SERVER_TIMEOUT to be 300 seconds by default")
}
if err := configParser.parseLines([]string{"HTTP_SERVER_TIMEOUT=60"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.HTTPServerTimeout().Seconds() != 60 {
t.Fatal("Expected HTTP_SERVER_TIMEOUT to be 60 seconds")
}
}
func TestListenAddrOptionParsing(t *testing.T) {
configParser := NewConfigParser()
addrs := configParser.options.ListenAddr()
if len(addrs) != 1 || addrs[0] != "127.0.0.1:8080" {
t.Fatalf("Expected LISTEN_ADDR to be '127.0.0.1:8080' by default")
}
if err := configParser.parseLines([]string{"LISTEN_ADDR=0.0.0.0:8080,127.0.0.1:8081,/unix.socket"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
addrs = configParser.options.ListenAddr()
if len(addrs) != 3 || addrs[0] != "0.0.0.0:8080" || addrs[1] != "127.0.0.1:8081" || addrs[2] != "/unix.socket" {
t.Fatalf("Expected LISTEN_ADDR to contain two addresses")
}
}
func TestMediaCustomProxyURLOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MediaCustomProxyURL() != nil {
t.Fatalf("Expected MEDIA_PROXY_CUSTOM_URL to be nil by default")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_CUSTOM_URL=https://proxy.example.com"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
proxyURL := configParser.options.MediaCustomProxyURL()
if proxyURL == nil || proxyURL.String() != "https://proxy.example.com" {
t.Fatalf("Expected MEDIA_PROXY_CUSTOM_URL to be 'https://proxy.example.com'")
}
}
func TestMediaProxyHTTPClientTimeoutOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MediaProxyHTTPClientTimeout().Seconds() != 120 {
t.Fatalf("Expected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT to be 120 seconds by default")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT=60"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MediaProxyHTTPClientTimeout().Seconds() != 60 {
t.Fatalf("Expected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT to be 60 seconds")
}
}
func TestMediaProxyPrivateKeyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if len(configParser.options.MediaProxyPrivateKey()) != 0 {
t.Fatalf("Expected MEDIA_PROXY_PRIVATE_KEY to be empty by default")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_PRIVATE_KEY=secret123"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
privateKey := configParser.options.MediaProxyPrivateKey()
if string(privateKey) != "secret123" {
t.Fatalf("Expected MEDIA_PROXY_PRIVATE_KEY to be 'secret123'")
}
}
func TestMediaProxyResourceTypesOptionParsing(t *testing.T) {
configParser := NewConfigParser()
resourceTypes := configParser.options.MediaProxyResourceTypes()
if len(resourceTypes) != 1 || resourceTypes[0] != "image" {
t.Fatalf("Expected MEDIA_PROXY_RESOURCE_TYPES to have default values")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=image,video"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
resourceTypes = configParser.options.MediaProxyResourceTypes()
if len(resourceTypes) != 2 || resourceTypes[0] != "image" || resourceTypes[1] != "video" {
t.Fatalf("Expected MEDIA_PROXY_RESOURCE_TYPES to contain image and video")
}
if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=image,invalid,video"}); err == nil {
t.Fatal("Expected error due to invalid resource type")
}
}
func TestMetricsAllowedNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
networks := configParser.options.MetricsAllowedNetworks()
if len(networks) != 1 || networks[0] != "127.0.0.1/8" {
t.Fatalf("Expected METRICS_ALLOWED_NETWORKS to have default values")
}
if err := configParser.parseLines([]string{"METRICS_ALLOWED_NETWORKS=10.0.0.0/8,192.168.0.0/16"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
networks = configParser.options.MetricsAllowedNetworks()
if len(networks) != 2 || networks[0] != "10.0.0.0/8" || networks[1] != "192.168.0.0/16" {
t.Fatalf("Expected METRICS_ALLOWED_NETWORKS to contain specified networks")
}
}
func TestMetricsRefreshIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.MetricsRefreshInterval().Seconds() != 60 {
t.Fatalf("Expected METRICS_REFRESH_INTERVAL to be 60 seconds by default")
}
if err := configParser.parseLines([]string{"METRICS_REFRESH_INTERVAL=120"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.MetricsRefreshInterval().Seconds() != 120 {
t.Fatalf("Expected METRICS_REFRESH_INTERVAL to be 120 seconds")
}
}
func TestPollingFrequencyOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.PollingFrequency().Minutes() != 60 {
t.Fatalf("Expected POLLING_FREQUENCY to be 60 minutes by default")
}
if err := configParser.parseLines([]string{"POLLING_FREQUENCY=30"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.PollingFrequency().Minutes() != 30 {
t.Fatalf("Expected POLLING_FREQUENCY to be 30 minutes")
}
}
func TestSchedulerEntryFrequencyMaxIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.SchedulerEntryFrequencyMaxInterval().Hours() != 24 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL to be 24 hours by default")
}
if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL=720"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.SchedulerEntryFrequencyMaxInterval().Hours() != 12 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL to be 12 hours")
}
}
func TestSchedulerEntryFrequencyMinIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.SchedulerEntryFrequencyMinInterval().Minutes() != 5 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL to be 5 minutes by default")
}
if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL=10"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.SchedulerEntryFrequencyMinInterval().Minutes() != 10 {
t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL to be 10 minutes")
}
}
func TestSchedulerRoundRobinMaxIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.SchedulerRoundRobinMaxInterval().Hours() != 24 {
t.Fatalf("Expected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL to be 24 hours by default")
}
if err := configParser.parseLines([]string{"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL=60"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.SchedulerRoundRobinMaxInterval().Hours() != 1 {
t.Fatalf("Expected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL to be 1 hour")
}
}
func TestSchedulerRoundRobinMinIntervalOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.SchedulerRoundRobinMinInterval().Minutes() != 60 {
t.Fatalf("Expected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL to be 60 minutes by default")
}
if err := configParser.parseLines([]string{"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL=30"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.SchedulerRoundRobinMinInterval().Minutes() != 30 {
t.Fatalf("Expected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL to be 30 minutes")
}
}
func TestTrustedReverseProxyNetworksOptionParsing(t *testing.T) {
configParser := NewConfigParser()
// Test default value
defaultNetworks := configParser.options.TrustedReverseProxyNetworks()
if len(defaultNetworks) != 0 {
t.Fatalf("Expected 0 allowed networks by default, got %d", len(defaultNetworks))
}
// Test valid value
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8,192.168.1.0/24"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
allowedNetworks := configParser.options.TrustedReverseProxyNetworks()
if len(allowedNetworks) != 2 {
t.Fatalf("Expected 2 allowed networks, got %d", len(allowedNetworks))
}
if !slices.Contains(allowedNetworks, "10.0.0.0/8") {
t.Errorf("Expected 10.0.0.0/8 in allowed networks")
}
if !slices.Contains(allowedNetworks, "192.168.1.0/24") {
t.Errorf("Expected 192.168.1.0/24 in allowed networks")
}
// Test invalid value
if err := configParser.parseLines([]string{"TRUSTED_REVERSE_PROXY_NETWORKS=127.0.0.1"}); err == nil {
t.Fatal("Expected error when parsing invalid CIDR notation IP 127.0.0.1, got nil")
}
}
func TestYouTubeEmbedDomainOptionParsing(t *testing.T) {
configParser := NewConfigParser()
if configParser.options.YouTubeEmbedDomain() != "www.youtube-nocookie.com" {
t.Fatalf("Expected YouTubeEmbedDomain to be 'www.youtube-nocookie.com' by default")
}
// YouTube embed domain is derived from YOUTUBE_EMBED_URL_OVERRIDE
if err := configParser.parseLines([]string{"YOUTUBE_EMBED_URL_OVERRIDE=https://custom.youtube.com/embed/"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if configParser.options.YouTubeEmbedDomain() != "custom.youtube.com" {
t.Fatalf("Expected YouTubeEmbedDomain to be 'custom.youtube.com'")
}
}
func TestSetLogLevelFunction(t *testing.T) {
configParser := NewConfigParser()
// Test default log level
if configParser.options.LogLevel() != "info" {
t.Fatalf("Expected LOG_LEVEL to be 'info' by default, got '%s'", configParser.options.LogLevel())
}
// Test setting log level to debug
configParser.options.SetLogLevel("debug")
if configParser.options.LogLevel() != "debug" {
t.Fatalf("Expected LOG_LEVEL to be 'debug' after SetLogLevel('debug'), got '%s'", configParser.options.LogLevel())
}
if configParser.options.options["LOG_LEVEL"].rawValue != "debug" {
t.Fatalf("Expected LOG_LEVEL RawValue to be 'debug', got '%s'", configParser.options.options["LOG_LEVEL"].rawValue)
}
// Test setting log level to warning
configParser.options.SetLogLevel("warning")
if configParser.options.LogLevel() != "warning" {
t.Fatalf("Expected LOG_LEVEL to be 'warning' after SetLogLevel('warning'), got '%s'", configParser.options.LogLevel())
}
if configParser.options.options["LOG_LEVEL"].rawValue != "warning" {
t.Fatalf("Expected LOG_LEVEL RawValue to be 'warning', got '%s'", configParser.options.options["LOG_LEVEL"].rawValue)
}
}
func TestSetHTTPSValueFunction(t *testing.T) {
configParser := NewConfigParser()
// Test setting HTTPS to true
configParser.options.SetHTTPSValue(true)
if !configParser.options.HTTPS() {
t.Fatalf("Expected HTTPS to be true after SetHTTPSValue(true)")
}
// Test setting HTTPS to false
configParser.options.SetHTTPSValue(false)
if configParser.options.HTTPS() {
t.Fatalf("Expected HTTPS to be false after SetHTTPSValue(false)")
}
// Test setting HTTPS to true again
configParser.options.SetHTTPSValue(true)
if !configParser.options.HTTPS() {
t.Fatalf("Expected HTTPS to be true after second SetHTTPSValue(true)")
}
}
func TestConfigMap(t *testing.T) {
configMap := NewConfigOptions().ConfigMap(false)
if len(configMap) == 0 {
t.Fatal("Expected ConfigMap to contain configuration options")
}
// The first option should be "ADMIN_PASSWORD"
if configMap[0].Key != "ADMIN_PASSWORD" {
t.Fatalf("Expected first config option to be 'ADMIN_PASSWORD', got '%s'", configMap[0].Key)
}
}
func TestConfigMapWithRedactedSecrets(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"ADMIN_PASSWORD=secret123"}); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
configMap := configParser.options.ConfigMap(true)
if len(configMap) == 0 {
t.Fatal("Expected ConfigMap to contain configuration options")
}
// The first option should be "ADMIN_PASSWORD"
if configMap[0].Key != "ADMIN_PASSWORD" {
t.Fatalf("Expected first config option to be 'ADMIN_PASSWORD', got '%s'", configMap[0].Key)
}
// The value should be redacted
if configMap[0].Value != "" {
t.Fatalf("Expected ADMIN_PASSWORD value to be redacted, got '%s'", configMap[0].Value)
}
}
func TestValidateOIDCProviderRequiresDiscoveryEndpoint(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"OAUTH2_PROVIDER=oidc"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
err := configParser.options.Validate()
if err == nil {
t.Fatal("Expected error when OIDC provider is set without discovery endpoint")
}
if err.Error() != "OAUTH2_OIDC_DISCOVERY_ENDPOINT must be configured when using the OIDC provider" {
t.Fatalf("Unexpected error message: %v", err)
}
}
func TestValidateOIDCProviderWithDiscoveryEndpoint(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"OAUTH2_PROVIDER=oidc",
"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid-configuration",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateDisableLocalAuthWithoutAlternative(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"DISABLE_LOCAL_AUTH=1"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when local auth is disabled without alternative")
}
}
func TestValidateDisableLocalAuthWithOAuth2ButNoUserCreation(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"DISABLE_LOCAL_AUTH=1",
"OAUTH2_PROVIDER=oidc",
"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid-configuration",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateDisableLocalAuthWithOAuth2AndUserCreation(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"DISABLE_LOCAL_AUTH=1",
"OAUTH2_PROVIDER=oidc",
"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid-configuration",
"OAUTH2_USER_CREATION=1",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateDisableLocalAuthWithAuthProxyButNoUserCreation(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"DISABLE_LOCAL_AUTH=1",
"AUTH_PROXY_HEADER=X-Forwarded-User",
"AUTH_PROXY_USER_CREATION=0",
"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateDisableLocalAuthWithAuthProxyAndUserCreation(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"DISABLE_LOCAL_AUTH=1",
"AUTH_PROXY_HEADER=X-Forwarded-User",
"AUTH_PROXY_USER_CREATION=1",
"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateAuthProxyRequiresTrustedNetworks(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"AUTH_PROXY_HEADER=X-Forwarded-User"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
err := configParser.options.Validate()
if err == nil {
t.Fatal("Expected error when auth proxy header is set without trusted networks")
}
if err.Error() != "TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used" {
t.Fatalf("Unexpected error message: %v", err)
}
}
func TestValidateAuthProxyWithTrustedNetworks(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"AUTH_PROXY_HEADER=X-Forwarded-User",
"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateCertFileMissingKeyFile(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"CERT_FILE=/path/to/cert.pem"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when CERT_FILE is set without KEY_FILE")
}
}
func TestValidateKeyFileMissingCertFile(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"KEY_FILE=/path/to/key.pem"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when KEY_FILE is set without CERT_FILE")
}
}
func TestValidateCertFileAndKeyFile(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"CERT_FILE=/path/to/cert.pem",
"KEY_FILE=/path/to/key.pem",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateCertDomainAndCertFileMutuallyExclusive(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"CERT_DOMAIN=example.com",
"CERT_FILE=/path/to/cert.pem",
"KEY_FILE=/path/to/key.pem",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when both CERT_DOMAIN and CERT_FILE are set")
}
}
func TestValidateCertDomainAlone(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"CERT_DOMAIN=example.com"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateMetricsUsernameWithoutPassword(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"METRICS_USERNAME=admin"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when METRICS_USERNAME is set without METRICS_PASSWORD")
}
}
func TestValidateMetricsPasswordWithoutUsername(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{"METRICS_PASSWORD=secret"}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when METRICS_PASSWORD is set without METRICS_USERNAME")
}
}
func TestValidateMetricsUsernameAndPassword(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"METRICS_USERNAME=admin",
"METRICS_PASSWORD=secret",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateDatabaseMinConnsGreaterThanMaxConns(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"DATABASE_MIN_CONNS=25",
"DATABASE_MAX_CONNS=10",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when DATABASE_MIN_CONNS > DATABASE_MAX_CONNS")
}
}
func TestValidateDatabaseMinConnsEqualToMaxConns(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"DATABASE_MIN_CONNS=10",
"DATABASE_MAX_CONNS=10",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateSchedulerRoundRobinMinGreaterThanMax(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL=1440",
"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL=60",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when SCHEDULER_ROUND_ROBIN_MIN_INTERVAL > SCHEDULER_ROUND_ROBIN_MAX_INTERVAL")
}
}
func TestValidateSchedulerRoundRobinMinLessThanMax(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL=60",
"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL=1440",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
func TestValidateSchedulerEntryFrequencyMinGreaterThanMax(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL=1440",
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL=5",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err == nil {
t.Fatal("Expected error when SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL > SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL")
}
}
func TestValidateSchedulerEntryFrequencyMinLessThanMax(t *testing.T) {
configParser := NewConfigParser()
if err := configParser.parseLines([]string{
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL=5",
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL=1440",
}); err != nil {
t.Fatalf("Unexpected parse error: %v", err)
}
if err := configParser.options.Validate(); err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
v2-2.3.0/internal/config/parser.go 0000664 0000000 0000000 00000022622 15201231005 0016740 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"bufio"
"bytes"
"crypto/rand"
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type configParser struct {
options *configOptions
}
func NewConfigParser() *configParser {
return &configParser{
options: NewConfigOptions(),
}
}
func (cp *configParser) ParseEnvironmentVariables() (*configOptions, error) {
if err := cp.parseLines(os.Environ()); err != nil {
return nil, err
}
return cp.options, nil
}
func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
fp, err := os.Open(filename)
if err != nil {
return nil, err
}
defer fp.Close()
if err := cp.parseLines(parseFileContent(fp)); err != nil {
return nil, err
}
return cp.options, nil
}
// Validate checks for invalid or incomplete option combinations.
func (c *configOptions) Validate() error {
if c.OAuth2Provider() == "oidc" && c.OAuth2OIDCDiscoveryEndpoint() == "" {
return errors.New("OAUTH2_OIDC_DISCOVERY_ENDPOINT must be configured when using the OIDC provider")
}
if c.DisableLocalAuth() {
if c.OAuth2Provider() == "" && c.AuthProxyHeader() == "" {
return errors.New("DISABLE_LOCAL_AUTH is enabled but neither OAUTH2_PROVIDER nor AUTH_PROXY_HEADER is set. Please enable at least one authentication source")
}
}
if c.AuthProxyHeader() != "" && len(c.TrustedReverseProxyNetworks()) == 0 {
return errors.New("TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used")
}
if (c.CertFile() != "") != (c.CertKeyFile() != "") {
return errors.New("CERT_FILE and KEY_FILE must both be provided")
}
if c.CertDomain() != "" && c.CertFile() != "" {
return errors.New("CERT_DOMAIN and CERT_FILE/KEY_FILE are mutually exclusive")
}
if (c.MetricsUsername() != "") != (c.MetricsPassword() != "") {
return errors.New("METRICS_USERNAME and METRICS_PASSWORD must both be provided")
}
if c.DatabaseMinConns() > c.DatabaseMaxConns() {
return errors.New("DATABASE_MIN_CONNS must be less than or equal to DATABASE_MAX_CONNS")
}
if c.SchedulerRoundRobinMinInterval() > c.SchedulerRoundRobinMaxInterval() {
return errors.New("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL must be less than or equal to SCHEDULER_ROUND_ROBIN_MAX_INTERVAL")
}
if c.SchedulerEntryFrequencyMinInterval() > c.SchedulerEntryFrequencyMaxInterval() {
return errors.New("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL must be less than or equal to SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL")
}
return nil
}
func (cp *configParser) postParsing() error {
// Parse basePath and rootURL based on BASE_URL
baseURL := cp.options.options["BASE_URL"].parsedStringValue
baseURL = strings.TrimSuffix(baseURL, "/")
parsedURL, err := url.Parse(baseURL)
if err != nil {
return fmt.Errorf("invalid BASE_URL: %v", err)
}
scheme := strings.ToLower(parsedURL.Scheme)
if scheme != "https" && scheme != "http" {
return errors.New("BASE_URL scheme must be http or https")
}
cp.options.options["BASE_URL"].parsedStringValue = baseURL
cp.options.basePath = parsedURL.Path
parsedURL.Path = ""
cp.options.rootURL = parsedURL.String()
// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].parsedStringValue
if youTubeEmbedURLOverride != "" {
parsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)
if err != nil {
return fmt.Errorf("invalid YOUTUBE_EMBED_URL_OVERRIDE: %v", err)
}
cp.options.youTubeEmbedDomain = parsedYouTubeEmbedURL.Hostname()
}
// Generate a media proxy private key if not set
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue) == 0 {
randomKey := make([]byte, 16)
rand.Read(randomKey)
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue = randomKey
}
// Override LISTEN_ADDR with PORT if set (for compatibility reasons)
if cp.options.Port() != "" {
cp.options.options["LISTEN_ADDR"].parsedStringList = []string{":" + cp.options.Port()}
cp.options.options["LISTEN_ADDR"].rawValue = ":" + cp.options.Port()
}
return nil
}
func (cp *configParser) parseLines(lines []string) error {
for lineNum, line := range lines {
key, value, ok := strings.Cut(line, "=")
if !ok {
return fmt.Errorf("unable to parse configuration, invalid format on line %d", lineNum)
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
if err := cp.parseLine(key, value); err != nil {
return err
}
}
if err := cp.postParsing(); err != nil {
return err
}
return nil
}
func (cp *configParser) parseLine(key, value string) error {
field, exists := cp.options.options[key]
if !exists {
if key == "FILTER_ENTRY_MAX_AGE_DAYS" {
slog.Warn("Configuration option FILTER_ENTRY_MAX_AGE_DAYS is deprecated; use user filter rule max-age: instead")
}
// Ignore unknown configuration keys to avoid parsing unrelated environment variables.
return nil
}
// Validate the option if a validator is provided
if field.validator != nil {
if err := field.validator(value); err != nil {
return fmt.Errorf("invalid value for key %s: %v", key, err)
}
}
// Convert the raw value based on its type
switch field.valueType {
case stringType:
field.parsedStringValue = parseStringValue(value, field.parsedStringValue)
field.rawValue = value
case stringListType:
field.parsedStringList = parseStringListValue(value, field.parsedStringList)
field.rawValue = value
case boolType:
parsedValue, err := parseBoolValue(value, field.parsedBoolValue)
if err != nil {
return fmt.Errorf("invalid boolean value for key %s: %v", key, err)
}
field.parsedBoolValue = parsedValue
field.rawValue = value
case intType:
field.parsedIntValue = parseIntValue(value, field.parsedIntValue)
field.rawValue = value
case int64Type:
field.parsedInt64Value = ParsedInt64Value(value, field.parsedInt64Value)
field.rawValue = value
case secondType:
field.parsedDuration = parseDurationValue(value, time.Second, field.parsedDuration)
field.rawValue = value
case minuteType:
field.parsedDuration = parseDurationValue(value, time.Minute, field.parsedDuration)
field.rawValue = value
case hourType:
field.parsedDuration = parseDurationValue(value, time.Hour, field.parsedDuration)
field.rawValue = value
case dayType:
field.parsedDuration = parseDurationValue(value, time.Hour*24, field.parsedDuration)
field.rawValue = value
case urlType:
parsedURL, err := parseURLValue(value, field.parsedURLValue)
if err != nil {
return fmt.Errorf("invalid URL for key %s: %v", key, err)
}
field.parsedURLValue = parsedURL
field.rawValue = value
case secretFileType:
secretValue, err := readSecretFileValue(value)
if err != nil {
return fmt.Errorf("error reading secret file for key %s: %v", key, err)
}
if field.targetKey != "" {
if targetField, ok := cp.options.options[field.targetKey]; ok {
targetField.parsedStringValue = secretValue
targetField.rawValue = secretValue
}
}
field.rawValue = value
case bytesType:
if value != "" {
field.parsedBytesValue = []byte(value)
field.rawValue = value
}
}
return nil
}
func parseStringValue(value string, fallback string) string {
if value == "" {
return fallback
}
return value
}
func parseBoolValue(value string, fallback bool) (bool, error) {
if value == "" {
return fallback, nil
}
value = strings.ToLower(value)
if value == "1" || value == "yes" || value == "true" || value == "on" {
return true, nil
}
if value == "0" || value == "no" || value == "false" || value == "off" {
return false, nil
}
return false, fmt.Errorf("invalid boolean value: %q", value)
}
func parseIntValue(value string, fallback int) int {
if value == "" {
return fallback
}
v, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return v
}
func ParsedInt64Value(value string, fallback int64) int64 {
if value == "" {
return fallback
}
v, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return fallback
}
return v
}
func parseStringListValue(value string, fallback []string) []string {
if value == "" {
return fallback
}
var strList []string
present := make(map[string]bool)
for item := range strings.SplitSeq(value, ",") {
if itemValue := strings.TrimSpace(item); itemValue != "" {
if !present[itemValue] {
present[itemValue] = true
strList = append(strList, itemValue)
}
}
}
return strList
}
func parseDurationValue(value string, unit time.Duration, fallback time.Duration) time.Duration {
if value == "" {
return fallback
}
v, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return time.Duration(v) * unit
}
func parseURLValue(value string, fallback *url.URL) (*url.URL, error) {
if value == "" {
return fallback, nil
}
parsedURL, err := url.Parse(value)
if err != nil {
return fallback, err
}
return parsedURL, nil
}
func readSecretFileValue(filename string) (string, error) {
data, err := os.ReadFile(filename)
if err != nil {
return "", err
}
value := string(bytes.TrimSpace(data))
if value == "" {
return "", errors.New("secret file is empty")
}
return value, nil
}
func parseFileContent(r io.Reader) (lines []string) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
lines = append(lines, line)
}
}
return lines
}
v2-2.3.0/internal/config/parser_test.go 0000664 0000000 0000000 00000027116 15201231005 0020002 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"net/url"
"os"
"reflect"
"testing"
"time"
)
func TestParseStringValue(t *testing.T) {
// Test with non-empty value
result := parseStringValue("test", "fallback")
if result != "test" {
t.Errorf("Expected 'test', got '%s'", result)
}
// Test with empty value
result = parseStringValue("", "fallback")
if result != "fallback" {
t.Errorf("Expected 'fallback', got '%s'", result)
}
// Test with empty value and empty fallback
result = parseStringValue("", "")
if result != "" {
t.Errorf("Expected empty string, got '%s'", result)
}
}
func TestParseBoolValue(t *testing.T) {
// Test with empty value - should return fallback
result, err := parseBoolValue("", true)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != true {
t.Errorf("Expected true, got %v", result)
}
// Test true values
trueValues := []string{"1", "yes", "true", "on", "YES", "TRUE", "ON"}
for _, value := range trueValues {
result, err := parseBoolValue(value, false)
if err != nil {
t.Errorf("Unexpected error for value '%s': %v", value, err)
}
if result != true {
t.Errorf("Expected true for '%s', got %v", value, result)
}
}
// Test false values
falseValues := []string{"0", "no", "false", "off", "NO", "FALSE", "OFF"}
for _, value := range falseValues {
result, err := parseBoolValue(value, true)
if err != nil {
t.Errorf("Unexpected error for value '%s': %v", value, err)
}
if result != false {
t.Errorf("Expected false for '%s', got %v", value, result)
}
}
// Test invalid value - should return error
_, err = parseBoolValue("invalid", false)
if err == nil {
t.Error("Expected error for invalid boolean value")
}
}
func TestParseIntValue(t *testing.T) {
// Test with empty value - should return fallback
result := parseIntValue("", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
// Test with valid integer
result = parseIntValue("123", 42)
if result != 123 {
t.Errorf("Expected 123, got %d", result)
}
// Test with invalid integer - should return fallback
result = parseIntValue("invalid", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
// Test with zero
result = parseIntValue("0", 42)
if result != 0 {
t.Errorf("Expected 0, got %d", result)
}
}
func TestParsedInt64Value(t *testing.T) {
// Test with empty value - should return fallback
result := ParsedInt64Value("", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
// Test with valid int64
result = ParsedInt64Value("9223372036854775807", 42)
if result != 9223372036854775807 {
t.Errorf("Expected 9223372036854775807, got %d", result)
}
// Test with invalid int64 - should return fallback
result = ParsedInt64Value("invalid", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
}
func TestParseStringListValue(t *testing.T) {
// Test with empty value - should return fallback
fallback := []string{"a", "b"}
result := parseStringListValue("", fallback)
if !reflect.DeepEqual(result, fallback) {
t.Errorf("Expected %v, got %v", fallback, result)
}
// Test with single value
result = parseStringListValue("item1", nil)
expected := []string{"item1"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with multiple values
result = parseStringListValue("item1,item2,item3", nil)
expected = []string{"item1", "item2", "item3"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with duplicates - should remove duplicates
result = parseStringListValue("item1,item2,item1", nil)
expected = []string{"item1", "item2"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with spaces
result = parseStringListValue(" item1 , item2 , item3 ", nil)
expected = []string{"item1", "item2", "item3"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
}
func TestParseDurationValue(t *testing.T) {
// Test with empty value - should return fallback
fallback := 5 * time.Second
result := parseDurationValue("", time.Second, fallback)
if result != fallback {
t.Errorf("Expected %v, got %v", fallback, result)
}
// Test with valid duration
result = parseDurationValue("30", time.Second, fallback)
expected := 30 * time.Second
if result != expected {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with minutes
result = parseDurationValue("5", time.Minute, fallback)
expected = 5 * time.Minute
if result != expected {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with invalid value - should return fallback
result = parseDurationValue("invalid", time.Second, fallback)
if result != fallback {
t.Errorf("Expected %v, got %v", fallback, result)
}
}
func TestParseURLValue(t *testing.T) {
// Test with empty value - should return fallback
fallbackURL, _ := url.Parse("https://fallback.com")
result, err := parseURLValue("", fallbackURL)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != fallbackURL {
t.Errorf("Expected %v, got %v", fallbackURL, result)
}
// Test with valid URL
result, err = parseURLValue("https://example.com", nil)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result.String() != "https://example.com" {
t.Errorf("Expected https://example.com, got %s", result.String())
}
// Test with invalid URL - should return fallback and error
result, err = parseURLValue("://invalid", fallbackURL)
if err == nil {
t.Error("Expected error for invalid URL")
}
if result != fallbackURL {
t.Errorf("Expected fallback URL, got %v", result)
}
}
func TestConfigFileParsing(t *testing.T) {
fileContent := `
# This is a comment
LOG_FILE=miniflux.log
LOG_DATE_TIME=1
LOG_FORMAT=json
LISTEN_ADDR=:8080,:8443
`
// Write a temporary config file and parse it
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
filename := tmpFile.Name()
if _, err := tmpFile.WriteString(fileContent); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
configParser := NewConfigParser()
configOptions, err := configParser.ParseFile(filename)
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "miniflux.log" {
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
}
if configOptions.LogDateTime() != true {
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
}
if configOptions.LogFormat() != "json" {
t.Fatalf("Unexpected log format, got %q", configOptions.LogFormat())
}
if configOptions.LogLevel() != "info" {
t.Fatalf("Unexpected log level, got %q", configOptions.LogLevel())
}
if len(configOptions.ListenAddr()) != 2 || configOptions.ListenAddr()[0] != ":8080" || configOptions.ListenAddr()[1] != ":8443" {
t.Fatalf("Unexpected listen addresses, got %v", configOptions.ListenAddr())
}
}
func TestConfigFileParsingWithIncorrectKeyValuePair(t *testing.T) {
fileContent := `
LOG_FILE=miniflux.log
INVALID_LINE
`
// Write a temporary config file and parse it
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
filename := tmpFile.Name()
if _, err := tmpFile.WriteString(fileContent); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
configParser := NewConfigParser()
_, err = configParser.ParseFile(filename)
if err != nil {
t.Fatal("Invalid lines should be ignored, but got error:", err)
}
}
func TestParseAdminPasswordFileOption(t *testing.T) {
tmpFile, err := os.CreateTemp("", "password-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
password := "supersecret"
if _, err := tmpFile.WriteString(password); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
os.Clearenv()
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.AdminPassword() != password {
t.Fatalf("Unexpected admin password, got %q", configOptions.AdminPassword())
}
}
func TestParseAdminPasswordFileOptionWithEmptyFile(t *testing.T) {
tmpFile, err := os.CreateTemp("", "empty-password-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
os.Clearenv()
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
configParser := NewConfigParser()
_, err = configParser.ParseEnvironmentVariables()
if err == nil {
t.Fatal("Expected error due to empty password file, but got none")
}
}
func TestParseLogFileOptionDefaultValue(t *testing.T) {
os.Clearenv()
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "stderr" {
t.Fatalf("Unexpected default log file, got %q", configOptions.LogFile())
}
}
func TestParseLogFileOptionWithCustomFilename(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FILE", "miniflux.log")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "miniflux.log" {
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
}
}
func TestParseLogFileOptionWithEmptyValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FILE", "")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "stderr" {
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
}
}
func TestParseLogDateTimeOptionDefaultValue(t *testing.T) {
os.Clearenv()
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogDateTime() != false {
t.Fatalf("Unexpected default log datetime, got %v", configOptions.LogDateTime())
}
}
func TestParseLogDateTimeOptionWithCustomValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATE_TIME", "true")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogDateTime() != true {
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
}
}
func TestParseLogDateTimeOptionWithEmptyValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATE_TIME", "")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogDateTime() != false {
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
}
}
func TestParseLogDateTimeOptionWithIncorrectValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATE_TIME", "invalid")
configParser := NewConfigParser()
if _, err := configParser.ParseEnvironmentVariables(); err == nil {
t.Fatal("Expected parsing error, got nil")
}
}
v2-2.3.0/internal/config/validators.go 0000664 0000000 0000000 00000002674 15201231005 0017621 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
)
func validateChoices(rawValue string, choices []string) error {
if !slices.Contains(choices, rawValue) {
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
}
return nil
}
func validateListChoices(inputValues, choices []string) error {
for _, value := range inputValues {
if err := validateChoices(value, choices); err != nil {
return err
}
}
return nil
}
func validateGreaterThan(rawValue string, min int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return errors.New("value must be an integer")
}
if intValue > min {
return nil
}
return fmt.Errorf("value must be at least %d", min)
}
func validateGreaterOrEqualThan(rawValue string, min int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return errors.New("value must be an integer")
}
if intValue >= min {
return nil
}
return fmt.Errorf("value must be greater or equal than %d", min)
}
func validateRange(rawValue string, min, max int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return errors.New("value must be an integer")
}
if intValue < min || intValue > max {
return fmt.Errorf("value must be between %d and %d", min, max)
}
return nil
}
v2-2.3.0/internal/config/validators_test.go 0000664 0000000 0000000 00000021546 15201231005 0020657 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"strings"
"testing"
)
func TestValidateChoices(t *testing.T) {
tests := []struct {
name string
rawValue string
choices []string
expectError bool
}{
{
name: "valid choice",
rawValue: "option1",
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "valid choice from middle",
rawValue: "option2",
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "valid choice from end",
rawValue: "option3",
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "invalid choice",
rawValue: "invalid",
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
{
name: "empty value with non-empty choices",
rawValue: "",
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "case sensitive - different case",
rawValue: "OPTION1",
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "single choice valid",
rawValue: "only",
choices: []string{"only"},
expectError: false,
},
{
name: "empty choices list",
rawValue: "anything",
choices: []string{},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateChoices(tt.rawValue, tt.choices)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else {
// Verify error message format
expectedPrefix := "value must be one of:"
if !strings.Contains(err.Error(), expectedPrefix) {
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
}
}
} else {
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
})
}
}
func TestValidateListChoices(t *testing.T) {
tests := []struct {
name string
inputValues []string
choices []string
expectError bool
}{
{
name: "all valid choices",
inputValues: []string{"option1", "option2"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "single valid choice",
inputValues: []string{"option1"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "empty input list",
inputValues: []string{},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "all choices from available list",
inputValues: []string{"option1", "option2", "option3"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "duplicate valid choices",
inputValues: []string{"option1", "option1", "option2"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "one invalid choice",
inputValues: []string{"option1", "invalid"},
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
{
name: "all invalid choices",
inputValues: []string{"invalid1", "invalid2"},
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
{
name: "case sensitive - different case",
inputValues: []string{"OPTION1"},
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "empty string in input",
inputValues: []string{""},
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "empty choices list with non-empty input",
inputValues: []string{"anything"},
choices: []string{},
expectError: true,
},
{
name: "mixed valid and invalid choices",
inputValues: []string{"option1", "invalid", "option2"},
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateListChoices(tt.inputValues, tt.choices)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else {
// Verify error message format
expectedPrefix := "value must be one of:"
if !strings.Contains(err.Error(), expectedPrefix) {
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
}
}
} else {
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
})
}
}
func TestValidateGreaterThan(t *testing.T) {
if err := validateGreaterThan("10", 5); err != nil {
t.Errorf("expected no error, got: %v", err)
}
if err := validateGreaterThan("5", 5); err == nil {
t.Errorf("expected error, got none")
}
if err := validateGreaterThan("abc", 5); err == nil {
t.Errorf("expected error for non-integer input, got none")
}
if err := validateGreaterThan("-1", 0); err == nil {
t.Errorf("expected error for value below minimum, got none")
}
}
func TestValidateGreaterOrEqualThan(t *testing.T) {
if err := validateGreaterOrEqualThan("10", 5); err != nil {
t.Errorf("expected no error, got: %v", err)
}
if err := validateGreaterOrEqualThan("5", 5); err != nil {
t.Errorf("expected no error for equal value, got: %v", err)
}
if err := validateGreaterOrEqualThan("abc", 5); err == nil {
t.Errorf("expected error for non-integer input, got none")
}
if err := validateGreaterOrEqualThan("-1", 0); err == nil {
t.Errorf("expected error for value below minimum, got none")
}
}
func TestValidateRange(t *testing.T) {
tests := []struct {
name string
rawValue string
min int
max int
expectError bool
errorMsg string
}{
{
name: "valid integer within range",
rawValue: "5",
min: 1,
max: 10,
expectError: false,
},
{
name: "valid integer at minimum",
rawValue: "1",
min: 1,
max: 10,
expectError: false,
},
{
name: "valid integer at maximum",
rawValue: "10",
min: 1,
max: 10,
expectError: false,
},
{
name: "valid zero in range",
rawValue: "0",
min: -5,
max: 5,
expectError: false,
},
{
name: "valid negative in range",
rawValue: "-3",
min: -5,
max: 5,
expectError: false,
},
{
name: "integer below minimum",
rawValue: "0",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "integer above maximum",
rawValue: "11",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "integer far below minimum",
rawValue: "-100",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "integer far above maximum",
rawValue: "100",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "non-integer string",
rawValue: "abc",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "empty string",
rawValue: "",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "float string",
rawValue: "5.5",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "string with spaces",
rawValue: " 5 ",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "single value range",
rawValue: "5",
min: 5,
max: 5,
expectError: false,
},
{
name: "single value range - below",
rawValue: "4",
min: 5,
max: 5,
expectError: true,
errorMsg: "value must be between 5 and 5",
},
{
name: "single value range - above",
rawValue: "6",
min: 5,
max: 5,
expectError: true,
errorMsg: "value must be between 5 and 5",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRange(tt.rawValue, tt.min, tt.max)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
t.Errorf("expected error message '%s', got '%s'", tt.errorMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
})
}
}
v2-2.3.0/internal/crypto/ 0000775 0000000 0000000 00000000000 15201231005 0015164 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/crypto/crypto.go 0000664 0000000 0000000 00000003013 15201231005 0017030 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package crypto // import "miniflux.app/v2/internal/crypto"
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"hash/fnv"
"golang.org/x/crypto/bcrypt"
)
// HashFromBytes returns a non-cryptographic checksum of the input.
func HashFromBytes(value []byte) string {
h := fnv.New128a()
h.Write(value)
return hex.EncodeToString(h.Sum(nil))
}
// SHA256 returns a SHA-256 checksum of a string.
func SHA256(value string) string {
h := sha256.Sum256([]byte(value))
return hex.EncodeToString(h[:])
}
// GenerateRandomBytes returns random bytes.
func GenerateRandomBytes(size int) []byte {
b := make([]byte, size)
rand.Read(b)
return b
}
// GenerateRandomStringHex returns a random hexadecimal string.
func GenerateRandomStringHex(size int) string {
return hex.EncodeToString(GenerateRandomBytes(size))
}
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
func GenerateSHA256Hmac(secret string, data []byte) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
}
func GenerateUUID() string {
b := GenerateRandomBytes(16)
return fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
func ConstantTimeCmp(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
v2-2.3.0/internal/database/ 0000775 0000000 0000000 00000000000 15201231005 0015410 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/database/database.go 0000664 0000000 0000000 00000003250 15201231005 0017503 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"fmt"
"log/slog"
)
// Migrate executes database migrations.
func Migrate(db *sql.DB) error {
var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
slog.Info("Running database migrations",
slog.Int("current_version", currentVersion),
slog.Int("latest_version", schemaVersion),
)
for version := currentVersion; version < schemaVersion; version++ {
newVersion := version + 1
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if err := migrations[version](tx); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if _, err := tx.Exec(`TRUNCATE schema_version`); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES ($1)`, newVersion); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
}
return nil
}
// IsSchemaUpToDate checks if the database schema is up to date.
func IsSchemaUpToDate(db *sql.DB) error {
var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
if currentVersion < schemaVersion {
return fmt.Errorf(`the database schema is not up to date: current=v%d expected=v%d`, currentVersion, schemaVersion)
}
return nil
}
v2-2.3.0/internal/database/migrations.go 0000664 0000000 0000000 00000122504 15201231005 0020117 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"errors"
"miniflux.app/v2/internal/crypto"
)
var schemaVersion = len(migrations)
// Order is important. Add new migrations at the end of the list.
var migrations = [...]func(tx *sql.Tx) error{
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE schema_version (
version text not null
);
CREATE TABLE users (
id SERIAL,
username text not null unique,
password text,
is_admin bool default 'f',
language text default 'en_US',
timezone text default 'UTC',
theme text default 'default',
last_login_at timestamp with time zone,
primary key (id)
);
CREATE TABLE sessions (
id SERIAL,
user_id int not null,
token text not null unique,
created_at timestamp with time zone default now(),
user_agent text,
ip text,
primary key (id),
unique (user_id, token),
foreign key (user_id) references users(id) on delete cascade
);
CREATE TABLE categories (
id SERIAL,
user_id int not null,
title text not null,
primary key (id),
unique (user_id, title),
foreign key (user_id) references users(id) on delete cascade
);
CREATE TABLE feeds (
id BIGSERIAL,
user_id int not null,
category_id int not null,
title text not null,
feed_url text not null,
site_url text not null,
checked_at timestamp with time zone default now(),
etag_header text default '',
last_modified_header text default '',
parsing_error_msg text default '',
parsing_error_count int default 0,
primary key (id),
unique (user_id, feed_url),
foreign key (user_id) references users(id) on delete cascade,
foreign key (category_id) references categories(id) on delete cascade
);
CREATE TYPE entry_status as enum('unread', 'read', 'removed');
CREATE TABLE entries (
id BIGSERIAL,
user_id int not null,
feed_id bigint not null,
hash text not null,
published_at timestamp with time zone not null,
title text not null,
url text not null,
author text,
content text,
status entry_status default 'unread',
primary key (id),
unique (feed_id, hash),
foreign key (user_id) references users(id) on delete cascade,
foreign key (feed_id) references feeds(id) on delete cascade
);
CREATE INDEX entries_feed_idx on entries using btree(feed_id);
CREATE TABLE enclosures (
id BIGSERIAL,
user_id int not null,
entry_id bigint not null,
url text not null,
size int default 0,
mime_type text default '',
primary key (id),
foreign key (user_id) references users(id) on delete cascade,
foreign key (entry_id) references entries(id) on delete cascade
);
CREATE TABLE icons (
id BIGSERIAL,
hash text not null unique,
mime_type text not null,
content bytea not null,
primary key (id)
);
CREATE TABLE feed_icons (
feed_id bigint not null,
icon_id bigint not null,
primary key(feed_id, icon_id),
foreign key (feed_id) references feeds(id) on delete cascade,
foreign key (icon_id) references icons(id) on delete cascade
);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// This used to create a HSTORE `extra` column in the table `users`,
// which hasn't been used since Miniflux 2.0.27.
return nil
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE tokens (
id text not null,
value text not null,
created_at timestamp with time zone not null default now(),
primary key(id, value)
);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE entry_sorting_direction AS enum('asc', 'desc');
ALTER TABLE users ADD COLUMN entry_direction entry_sorting_direction default 'asc';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE integrations (
user_id int not null,
pinboard_enabled bool default 'f',
pinboard_token text default '',
pinboard_tags text default 'miniflux',
pinboard_mark_as_unread bool default 'f',
instapaper_enabled bool default 'f',
instapaper_username text default '',
instapaper_password text default '',
fever_enabled bool default 'f',
fever_username text default '',
fever_password text default '',
fever_token text default '',
primary key(user_id)
);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN scraper_rules text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN rewrite_rules text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN crawler boolean default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE sessions rename to user_sessions`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
DROP TABLE tokens;
CREATE TABLE sessions (
id text not null,
data jsonb not null,
created_at timestamp with time zone not null default now(),
primary key(id)
);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN wallabag_enabled bool default 'f',
ADD COLUMN wallabag_url text default '',
ADD COLUMN wallabag_client_id text default '',
ADD COLUMN wallabag_client_secret text default '',
ADD COLUMN wallabag_username text default '',
ADD COLUMN wallabag_password text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE entries ADD COLUMN starred bool default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE INDEX entries_user_status_idx ON entries(user_id, status);
CREATE INDEX feeds_user_category_idx ON feeds(user_id, category_id);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN nunux_keeper_enabled bool default 'f',
ADD COLUMN nunux_keeper_url text default '',
ADD COLUMN nunux_keeper_api_key text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE enclosures ALTER COLUMN size SET DATA TYPE bigint`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE entries ADD COLUMN comments_url text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN pocket_enabled bool default 'f',
ADD COLUMN pocket_access_token text default '',
ADD COLUMN pocket_consumer_key text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE user_sessions ALTER COLUMN ip SET DATA TYPE inet using ip::inet;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds
ADD COLUMN username text default '',
ADD COLUMN password text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN document_vectors tsvector;
UPDATE entries SET document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));
CREATE INDEX document_vectors_idx ON entries USING gin(document_vectors);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN user_agent text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
UPDATE
entries
SET
document_vectors = setweight(to_tsvector(substring(coalesce(title, '') for 1000000)), 'A') || setweight(to_tsvector(substring(coalesce(content, '') for 1000000)), 'B')
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN keyboard_shortcuts boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN disabled boolean default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE users ALTER COLUMN theme SET DEFAULT 'light_serif';
UPDATE users SET theme='light_serif' WHERE theme='default';
UPDATE users SET theme='light_sans_serif' WHERE theme='sansserif';
UPDATE users SET theme='dark_serif' WHERE theme='black';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN changed_at timestamp with time zone;
UPDATE entries SET changed_at = published_at;
ALTER TABLE entries ALTER COLUMN changed_at SET not null;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE api_keys (
id SERIAL,
user_id int not null references users(id) on delete cascade,
token text not null unique,
description text not null,
last_used_at timestamp with time zone,
created_at timestamp with time zone default now(),
primary key(id),
unique (user_id, description)
);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN share_code text not null default '';
CREATE UNIQUE INDEX entries_share_code_idx ON entries USING btree(share_code) WHERE share_code <> '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN next_check_at timestamp with time zone default now();
CREATE INDEX entries_user_feed_idx ON entries (user_id, feed_id);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN ignore_http_cache bool default false`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN entries_per_page int default 100`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN show_reading_time boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX entries_id_user_status_idx ON entries USING btree (id, user_id, status)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN fetch_via_proxy bool default false`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX entries_feed_id_status_hash_idx ON entries USING btree (feed_id, status, hash)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX entries_user_id_status_starred_idx ON entries (user_id, status, starred)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN entry_swipe boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE integrations DROP COLUMN fever_password`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds
ADD COLUMN blocklist_rules text not null default '',
ADD COLUMN keeplist_rules text not null default ''
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE entries ADD COLUMN reading_time int not null default 0`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN created_at timestamp with time zone not null default now();
UPDATE entries SET created_at = published_at;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
hasExtra := false
if err := tx.QueryRow(`
SELECT true
FROM information_schema.columns
WHERE
table_name='users' AND
column_name='extra';
`).Scan(&hasExtra); err != nil && err != sql.ErrNoRows {
return err
}
_, err = tx.Exec(`
ALTER TABLE users
ADD column stylesheet text not null default '',
ADD column google_id text not null default '',
ADD column openid_connect_id text not null default ''
`)
if err != nil {
return err
}
if !hasExtra {
// No need to migrate things from the `extra` column if it's not present
return nil
}
_, err = tx.Exec(`
DECLARE my_cursor CURSOR FOR
SELECT
id,
COALESCE(extra->'custom_css', '') as custom_css,
COALESCE(extra->'google_id', '') as google_id,
COALESCE(extra->'oidc_id', '') as oidc_id
FROM users
FOR UPDATE
`)
if err != nil {
return err
}
defer tx.Exec("CLOSE my_cursor")
for {
var (
userID int64
customStylesheet string
googleID string
oidcID string
)
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
break
}
return err
}
_, err := tx.Exec(
`UPDATE
users
SET
stylesheet=$2,
google_id=$3,
openid_connect_id=$4
WHERE
id=$1
`,
userID, customStylesheet, googleID, oidcID)
if err != nil {
return err
}
}
return err
},
func(tx *sql.Tx) (err error) {
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN IF EXISTS extra;`); err != nil {
return err
}
_, err = tx.Exec(`
CREATE UNIQUE INDEX users_google_id_idx ON users(google_id) WHERE google_id <> '';
CREATE UNIQUE INDEX users_openid_connect_id_idx ON users(openid_connect_id) WHERE openid_connect_id <> '';
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
CREATE INDEX entries_feed_url_idx ON entries(feed_id, url) WHERE length(url) < 2000;
CREATE INDEX entries_user_status_feed_idx ON entries(user_id, status, feed_id);
CREATE INDEX entries_user_status_changed_idx ON entries(user_id, status, changed_at);
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
CREATE TABLE acme_cache (
key varchar(400) not null primary key,
data bytea not null,
updated_at timestamptz not null
);
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN allow_self_signed_certificates boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE webapp_display_mode AS enum('fullscreen', 'standalone', 'minimal-ui', 'browser');
ALTER TABLE users ADD COLUMN display_mode webapp_display_mode default 'standalone';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN cookie text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE categories ADD COLUMN hide_globally boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN hide_globally boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN telegram_bot_enabled bool default 'f',
ADD COLUMN telegram_bot_token text default '',
ADD COLUMN telegram_bot_chat_id text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE entry_sorting_order AS enum('published_at', 'created_at');
ALTER TABLE users ADD COLUMN entry_order entry_sorting_order default 'published_at';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN googlereader_enabled bool default 'f',
ADD COLUMN googlereader_username text default '',
ADD COLUMN googlereader_password text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN espial_enabled bool default 'f',
ADD COLUMN espial_url text default '',
ADD COLUMN espial_api_key text default '',
ADD COLUMN espial_tags text default 'miniflux';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN linkding_enabled bool default 'f',
ADD COLUMN linkding_url text default '',
ADD COLUMN linkding_api_key text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN url_rewrite_rules text not null default ''
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE users
ADD COLUMN default_reading_speed int default 265,
ADD COLUMN cjk_reading_speed int default 500;
`)
return
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN default_home_page text default 'unread';
`)
return
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE integrations ADD COLUMN wallabag_only_url bool default 'f';
`)
return
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN categories_sorting_order text not null default 'unread_count';
`)
return
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN matrix_bot_enabled bool default 'f',
ADD COLUMN matrix_bot_user text default '',
ADD COLUMN matrix_bot_password text default '',
ADD COLUMN matrix_bot_url text default '',
ADD COLUMN matrix_bot_chat_id text default '';
`
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN double_tap boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE entries ADD COLUMN tags text[] default '{}';
`)
return
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE users RENAME double_tap TO gesture_nav;
ALTER TABLE users
ALTER COLUMN gesture_nav SET DATA TYPE text using case when gesture_nav = true then 'tap' when gesture_nav = false then 'none' end,
ALTER COLUMN gesture_nav SET default 'tap';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkding_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN no_media_player boolean default 'f';
ALTER TABLE enclosures ADD COLUMN media_progression int default 0;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkding_mark_as_unread bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// Delete duplicated rows
sql := `
DELETE FROM enclosures a USING enclosures b
WHERE a.id < b.id
AND a.user_id = b.user_id
AND a.entry_id = b.entry_id
AND a.url = b.url;
`
_, err = tx.Exec(sql)
if err != nil {
return err
}
// Remove previous index
_, err = tx.Exec(`DROP INDEX enclosures_user_entry_url_idx`)
if err != nil {
return err
}
// Create unique index
_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, md5(url))`)
if err != nil {
return err
}
return nil
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN mark_read_on_view boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN notion_enabled bool default 'f',
ADD COLUMN notion_token text default '',
ADD COLUMN notion_page_id text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN readwise_enabled bool default 'f',
ADD COLUMN readwise_api_key text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN apprise_enabled bool default 'f',
ADD COLUMN apprise_url text default '',
ADD COLUMN apprise_services_url text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN shiori_enabled bool default 'f',
ADD COLUMN shiori_url text default '',
ADD COLUMN shiori_username text default '',
ADD COLUMN shiori_password text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN shaarli_enabled bool default 'f',
ADD COLUMN shaarli_url text default '',
ADD COLUMN shaarli_api_secret text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN apprise_service_urls text default '';
`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN webhook_enabled bool default 'f',
ADD COLUMN webhook_url text default '',
ADD COLUMN webhook_secret text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN telegram_bot_topic_id int,
ADD COLUMN telegram_bot_disable_web_page_preview bool default 'f',
ADD COLUMN telegram_bot_disable_notification bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN telegram_bot_disable_buttons bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
-- Speed up has_enclosure
CREATE INDEX enclosures_entry_id_idx ON enclosures(entry_id);
-- Speed up unread page
CREATE INDEX entries_user_status_published_idx ON entries(user_id, status, published_at);
CREATE INDEX entries_user_status_created_idx ON entries(user_id, status, created_at);
CREATE INDEX feeds_feed_id_hide_globally_idx ON feeds(id, hide_globally);
-- Speed up history page
CREATE INDEX entries_user_status_changed_published_idx ON entries(user_id, status, changed_at, published_at);
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN rssbridge_enabled bool default 'f',
ADD COLUMN rssbridge_url text default '';
`
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
CREATE TABLE webauthn_credentials (
handle bytea primary key,
cred_id bytea unique not null,
user_id int references users(id) on delete cascade not null,
public_key bytea not null,
attestation_type varchar(255) not null,
aaguid bytea,
sign_count bigint,
clone_warning bool,
name text,
added_on timestamp with time zone default now(),
last_seen_on timestamp with time zone default now()
);
`)
return
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN omnivore_enabled bool default 'f',
ADD COLUMN omnivore_api_key text default '',
ADD COLUMN omnivore_url text default '';
`
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN linkace_enabled bool default 'f',
ADD COLUMN linkace_url text default '',
ADD COLUMN linkace_api_key text default '',
ADD COLUMN linkace_tags text default '',
ADD COLUMN linkace_is_private bool default 't',
ADD COLUMN linkace_check_disabled bool default 't';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN linkwarden_enabled bool default 'f',
ADD COLUMN linkwarden_url text default '',
ADD COLUMN linkwarden_api_key text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN readeck_enabled bool default 'f',
ADD COLUMN readeck_only_url bool default 'f',
ADD COLUMN readeck_url text default '',
ADD COLUMN readeck_api_key text default '',
ADD COLUMN readeck_labels text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN disable_http2 bool default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN media_playback_rate numeric default 1;`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// the WHERE part speed-up the request a lot
sql := `UPDATE entries SET tags = array_remove(tags, '') WHERE '' = ANY(tags);`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// Entry URLs can exceeds btree maximum size
// Checking entry existence is now using entries_feed_id_status_hash_idx index
_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN raindrop_enabled bool default 'f',
ADD COLUMN raindrop_token text default '',
ADD COLUMN raindrop_collection_id text default '',
ADD COLUMN raindrop_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE users
ADD COLUMN block_filter_entry_rules text not null default '',
ADD COLUMN keep_filter_entry_rules text not null default ''
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN betula_url text default '',
ADD COLUMN betula_token text default '',
ADD COLUMN betula_enabled bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN ntfy_enabled bool default 'f',
ADD COLUMN ntfy_url text default '',
ADD COLUMN ntfy_topic text default '',
ADD COLUMN ntfy_api_token text default '',
ADD COLUMN ntfy_username text default '',
ADD COLUMN ntfy_password text default '',
ADD COLUMN ntfy_icon_url text default '';
ALTER TABLE feeds
ADD COLUMN ntfy_enabled bool default 'f',
ADD COLUMN ntfy_priority int default '3';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN mark_read_on_media_player_completion bool default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN custom_js text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN cubox_enabled bool default 'f',
ADD COLUMN cubox_api_link text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN discord_enabled bool default 'f',
ADD COLUMN discord_webhook_link text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE integrations ADD COLUMN ntfy_internal_links bool default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN slack_enabled bool default 'f',
ADD COLUMN slack_webhook_link text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN webhook_url text default '';`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN pushover_enabled bool default 'f',
ADD COLUMN pushover_user text default '',
ADD COLUMN pushover_token text default '',
ADD COLUMN pushover_device text default '',
ADD COLUMN pushover_prefix text default '';
ALTER TABLE feeds
ADD COLUMN pushover_enabled bool default 'f',
ADD COLUMN pushover_priority int default '0';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN ntfy_topic text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE icons ADD COLUMN external_id text default '';
CREATE UNIQUE INDEX icons_external_id_idx ON icons USING btree(external_id) WHERE external_id <> '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
DECLARE id_cursor CURSOR FOR
SELECT
id
FROM icons
WHERE external_id = ''
FOR UPDATE`)
if err != nil {
return err
}
defer tx.Exec("CLOSE id_cursor")
for {
var id int64
if err := tx.QueryRow(`FETCH NEXT FROM id_cursor`).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
break
}
return err
}
_, err = tx.Exec(
`
UPDATE icons SET external_id = $1 WHERE id = $2
`,
crypto.GenerateRandomStringHex(20), id)
if err != nil {
return err
}
}
return nil
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN proxy_url text default ''`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN rssbridge_token text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE users ADD COLUMN always_open_external_links bool default 'f'`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN karakeep_enabled bool default 'f',
ADD COLUMN karakeep_api_key text default '',
ADD COLUMN karakeep_url text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE users ADD COLUMN open_external_links_in_new_tab bool default 't'`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
DROP COLUMN pocket_enabled,
DROP COLUMN pocket_access_token,
DROP COLUMN pocket_consumer_key;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds
ADD COLUMN block_filter_entry_rules text not null default '',
ADD COLUMN keep_filter_entry_rules text not null default ''
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE linktaco_link_visibility AS ENUM (
'PUBLIC',
'PRIVATE'
);
ALTER TABLE integrations
ADD COLUMN linktaco_enabled bool default 'f',
ADD COLUMN linktaco_api_token text default '',
ADD COLUMN linktaco_org_slug text default '',
ADD COLUMN linktaco_tags text default '',
ADD COLUMN linktaco_visibility linktaco_link_visibility default 'PUBLIC';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN wallabag_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
// This migration replaces deprecated timezones by their equivalent on Debian Trixie.
func(tx *sql.Tx) (err error) {
var deprecatedTimeZoneMap = map[string]string{
// Africa
"Africa/Asmera": "Africa/Asmara",
// America - Argentina
"America/Argentina/ComodRivadavia": "America/Argentina/Catamarca",
"America/Buenos_Aires": "America/Argentina/Buenos_Aires",
"America/Catamarca": "America/Argentina/Catamarca",
"America/Cordoba": "America/Argentina/Cordoba",
"America/Jujuy": "America/Argentina/Jujuy",
"America/Mendoza": "America/Argentina/Mendoza",
"America/Rosario": "America/Argentina/Cordoba",
// America - US
"America/Fort_Wayne": "America/Indiana/Indianapolis",
"America/Indianapolis": "America/Indiana/Indianapolis",
"America/Knox_IN": "America/Indiana/Knox",
"America/Louisville": "America/Kentucky/Louisville",
// America - Greenland
"America/Godthab": "America/Nuuk",
// Antarctica
"Antarctica/South_Pole": "Pacific/Auckland",
// Asia
"Asia/Ashkhabad": "Asia/Ashgabat",
"Asia/Calcutta": "Asia/Kolkata",
"Asia/Choibalsan": "Asia/Ulaanbaatar",
"Asia/Chungking": "Asia/Chongqing",
"Asia/Dacca": "Asia/Dhaka",
"Asia/Katmandu": "Asia/Kathmandu",
"Asia/Macao": "Asia/Macau",
"Asia/Rangoon": "Asia/Yangon",
"Asia/Saigon": "Asia/Ho_Chi_Minh",
"Asia/Thimbu": "Asia/Thimphu",
"Asia/Ujung_Pandang": "Asia/Makassar",
"Asia/Ulan_Bator": "Asia/Ulaanbaatar",
// Atlantic
"Atlantic/Faeroe": "Atlantic/Faroe",
// Australia
"Australia/ACT": "Australia/Sydney",
"Australia/LHI": "Australia/Lord_Howe",
"Australia/North": "Australia/Darwin",
"Australia/NSW": "Australia/Sydney",
"Australia/Queensland": "Australia/Brisbane",
"Australia/South": "Australia/Adelaide",
"Australia/Tasmania": "Australia/Hobart",
"Australia/Victoria": "Australia/Melbourne",
"Australia/West": "Australia/Perth",
// Brazil
"Brazil/Acre": "America/Rio_Branco",
"Brazil/DeNoronha": "America/Noronha",
"Brazil/East": "America/Sao_Paulo",
"Brazil/West": "America/Manaus",
// Canada
"Canada/Atlantic": "America/Halifax",
"Canada/Central": "America/Winnipeg",
"Canada/Eastern": "America/Toronto",
"Canada/Mountain": "America/Edmonton",
"Canada/Newfoundland": "America/St_Johns",
"Canada/Pacific": "America/Vancouver",
"Canada/Saskatchewan": "America/Regina",
"Canada/Yukon": "America/Whitehorse",
// Europe
"CET": "Europe/Paris",
"EET": "Europe/Sofia",
"Europe/Kiev": "Europe/Kyiv",
"Europe/Uzhgorod": "Europe/Kyiv",
"Europe/Zaporozhye": "Europe/Kyiv",
"MET": "Europe/Paris",
"WET": "Europe/Lisbon",
// Chile
"Chile/Continental": "America/Santiago",
"Chile/EasterIsland": "Pacific/Easter",
// Fixed offset and generic zones
"CST6CDT": "America/Chicago",
"EST": "America/New_York",
"EST5EDT": "America/New_York",
"HST": "Pacific/Honolulu",
"MST": "America/Denver",
"MST7MDT": "America/Denver",
"PST8PDT": "America/Los_Angeles",
// Countries/Regions
"Cuba": "America/Havana",
"Egypt": "Africa/Cairo",
"Eire": "Europe/Dublin",
"GB": "Europe/London",
"GB-Eire": "Europe/London",
"Hongkong": "Asia/Hong_Kong",
"Iceland": "Atlantic/Reykjavik",
"Iran": "Asia/Tehran",
"Israel": "Asia/Jerusalem",
"Jamaica": "America/Jamaica",
"Japan": "Asia/Tokyo",
"Libya": "Africa/Tripoli",
"Poland": "Europe/Warsaw",
"Portugal": "Europe/Lisbon",
"PRC": "Asia/Shanghai",
"ROC": "Asia/Taipei",
"ROK": "Asia/Seoul",
"Singapore": "Asia/Singapore",
"Turkey": "Europe/Istanbul",
// GMT variations
"GMT+0": "GMT",
"GMT-0": "GMT",
"GMT0": "GMT",
"Greenwich": "GMT",
"UCT": "UTC",
"Universal": "UTC",
"Zulu": "UTC",
// Mexico
"Mexico/BajaNorte": "America/Tijuana",
"Mexico/BajaSur": "America/Mazatlan",
"Mexico/General": "America/Mexico_City",
// US zones
"Navajo": "America/Denver",
"US/Alaska": "America/Anchorage",
"US/Aleutian": "America/Adak",
"US/Arizona": "America/Phoenix",
"US/Central": "America/Chicago",
"US/Eastern": "America/New_York",
"US/East-Indiana": "America/Indiana/Indianapolis",
"US/Hawaii": "Pacific/Honolulu",
"US/Indiana-Starke": "America/Indiana/Knox",
"US/Michigan": "America/Detroit",
"US/Mountain": "America/Denver",
"US/Pacific": "America/Los_Angeles",
"US/Samoa": "Pacific/Pago_Pago",
// Pacific
"Kwajalein": "Pacific/Kwajalein",
"NZ": "Pacific/Auckland",
"NZ-CHAT": "Pacific/Chatham",
"Pacific/Enderbury": "Pacific/Kanton",
"Pacific/Ponape": "Pacific/Pohnpei",
"Pacific/Truk": "Pacific/Chuuk",
// Special cases
"Factory": "UTC", // Factory is used for unconfigured systems
"W-SU": "Europe/Moscow",
}
// Loop through each user and correct the timezone
rows, err := tx.Query(`SELECT id, timezone FROM users`)
if err != nil {
return err
}
userTimezoneMap := make(map[int64]string)
for rows.Next() {
var userID int64
var userTimezone string
if err := rows.Scan(&userID, &userTimezone); err != nil {
return err
}
userTimezoneMap[userID] = userTimezone
}
rows.Close()
for userID, userTimezone := range userTimezoneMap {
if newTimezone, found := deprecatedTimeZoneMap[userTimezone]; found {
if _, err := tx.Exec(`UPDATE users SET timezone = $1 WHERE id = $2`, newTimezone, userID); err != nil {
return err
}
}
}
return nil
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN archiveorg_enabled bool default 'f'
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `DROP EXTENSION IF EXISTS hstore;`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN karakeep_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkwarden_collection_id int;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN readeck_push_enabled bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// There is no need to keep an index on the content of deleted entries.
_, err = tx.Exec(`DROP INDEX document_vectors_idx;`)
if err != nil {
return err
}
sql := `
CREATE INDEX document_vectors_idx
ON entries
USING gin(document_vectors)
WHERE status != 'removed';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`UPDATE user_sessions SET ip = '127.0.0.1'::inet WHERE ip IS NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`UPDATE user_sessions SET created_at = now() WHERE created_at IS NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`UPDATE user_sessions SET user_agent = '' WHERE user_agent IS NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`
ALTER TABLE user_sessions
ALTER COLUMN ip SET DEFAULT '127.0.0.1'::inet,
ALTER COLUMN ip SET NOT NULL,
ALTER COLUMN created_at SET DEFAULT now(),
ALTER COLUMN created_at SET NOT NULL,
ALTER COLUMN user_agent SET DEFAULT '',
ALTER COLUMN user_agent SET NOT NULL
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS user_sessions;
CREATE TABLE web_sessions (
id text not null,
secret_hash bytea not null,
user_id int references users(id) on delete cascade,
created_at timestamp with time zone not null default now(),
user_agent text not null default '',
ip inet,
state jsonb not null default '{}'::jsonb,
primary key (id),
check (jsonb_typeof(state) = 'object')
);
CREATE INDEX web_sessions_user_id_idx
ON web_sessions (user_id)
WHERE user_id IS NOT NULL;
CREATE INDEX web_sessions_created_at_idx
ON web_sessions (created_at);
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
CREATE TABLE entry_tombstones (
feed_id bigint not null references feeds(id) on delete cascade,
hash text not null check (hash <> ''),
deleted_at timestamp with time zone not null default now(),
primary key (feed_id, hash)
);
CREATE INDEX entry_tombstones_deleted_at_idx
ON entry_tombstones (deleted_at);
INSERT INTO entry_tombstones (feed_id, hash, deleted_at)
SELECT feed_id, hash, changed_at
FROM entries
WHERE status = 'removed' AND hash <> ''
ON CONFLICT (feed_id, hash) DO NOTHING;
DELETE FROM entries WHERE status = 'removed';
-- The "removed" status is no longer used, so drop the partial
-- predicate so the planner can use the index for every search.
DROP INDEX document_vectors_idx;
CREATE INDEX document_vectors_idx
ON entries
USING gin(document_vectors);
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
DELETE FROM integrations WHERE user_id NOT IN (SELECT id FROM users);
ALTER TABLE integrations
ADD CONSTRAINT integrations_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
`)
return err
},
func(tx *sql.Tx) (err error) {
// backup_eligible is nullable: NULL marks pre-migration rows so the login path can backfill it from the assertion on first use.
_, err = tx.Exec(`
UPDATE webauthn_credentials SET name = '' WHERE name IS NULL;
ALTER TABLE webauthn_credentials
ALTER COLUMN name SET DEFAULT '',
ALTER COLUMN name SET NOT NULL,
ADD COLUMN backup_eligible boolean,
ADD COLUMN backup_state boolean NOT NULL DEFAULT false;
`)
return err
},
}
v2-2.3.0/internal/database/postgresql.go 0000664 0000000 0000000 00000001201 15201231005 0020134 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"time"
_ "github.com/lib/pq"
)
// NewConnectionPool configures the database connection pool.
func NewConnectionPool(dsn string, minConnections, maxConnections int, connectionLifetime time.Duration) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxConnections)
db.SetMaxIdleConns(minConnections)
db.SetConnMaxLifetime(connectionLifetime)
return db, nil
}
v2-2.3.0/internal/fever/ 0000775 0000000 0000000 00000000000 15201231005 0014753 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/fever/README.md 0000664 0000000 0000000 00000020331 15201231005 0016231 0 ustar 00root root 0000000 0000000 # Miniflux Fever API
This document describes the Fever-compatible API implemented by the `internal/fever` package in this repository.
## Endpoint
- Path: `BASE_URL/fever/`
- Methods: not restricted by the router; read requests are typically sent as `GET`, write requests should be sent as `POST`
- Response format: JSON only
- Reported API version: `3`
## Authentication
Fever authentication is enabled per user from the Miniflux integrations page.
- `Fever Username` and `Fever Password` are configured in Miniflux
- Miniflux stores the Fever token as the MD5 hash of `username:password`
- Clients authenticate by sending that token as the `api_key` parameter
- Token lookup is case-insensitive
Example:
```text
api_key = md5("fever_username:fever_password")
```
Example shell command:
```bash
printf '%s' 'fever_username:fever_password' | md5sum
```
Authentication failure does not return HTTP 401. The middleware returns HTTP 200 with:
```json
{
"api_version": 3,
"auth": 0
}
```
On successful authentication, every response includes:
- `api_version`: always `3`
- `auth`: always `1`
- `last_refreshed_on_time`: current server Unix timestamp at response time
## Dispatch Rules
The handler selects the first matching operation in this order:
1. `groups`
2. `feeds`
3. `favicons`
4. `unread_item_ids`
5. `saved_item_ids`
6. `items`
7. `mark=item`
8. `mark=feed`
9. `mark=group`
If no selector is provided, the server returns the base authenticated response only.
For read operations, the selector must be present in the query string. For write operations, `mark`, `as`, `id`, and `before` are read from request form values, so they may come from the query string or a form body.
## Read Operations
### `?groups`
Returns:
- `groups`: list of categories
- `feeds_groups`: mapping of category IDs to feed IDs
Response shape:
```json
{
"api_version": 3,
"auth": 1,
"last_refreshed_on_time": 1710000000,
"groups": [
{
"id": 1,
"title": "All"
}
],
"feeds_groups": [
{
"group_id": 1,
"feed_ids": "10,11"
}
]
}
```
Notes:
- `groups` are Miniflux categories
- `feeds_groups.feed_ids` is a comma-separated string
- categories with no feeds are returned in `groups` but have no `feeds_groups` entry
### `?feeds`
Returns:
- `feeds`: list of feeds
- `feeds_groups`: mapping of category IDs to feed IDs
Feed fields:
- `id`
- `favicon_id`
- `title`
- `url`
- `site_url`
- `is_spark`
- `last_updated_on_time`
Notes:
- `favicon_id` is `0` when the feed has no icon
- `is_spark` is always `0` in this implementation
- `last_updated_on_time` is the feed check time as a Unix timestamp
### `?favicons`
Returns:
- `favicons`: list of favicon objects
Favicon fields:
- `id`
- `data`
Notes:
- `data` is a data URL such as `image/png;base64,...`
### `?unread_item_ids`
Returns:
- `unread_item_ids`: comma-separated list of unread entry IDs
Response shape:
```json
{
"api_version": 3,
"auth": 1,
"last_refreshed_on_time": 1710000000,
"unread_item_ids": "100,101,102"
}
```
### `?saved_item_ids`
Returns:
- `saved_item_ids`: comma-separated list of starred entry IDs
### `?items`
Returns:
- `items`: list of entries
- `total_items`: total number of non-removed entries for the user
Item fields:
- `id`
- `feed_id`
- `title`
- `author`
- `html`
- `url`
- `is_saved`
- `is_read`
- `created_on_time`
The implementation always excludes entries whose status is `removed`.
#### Pagination and filtering
The handler applies a fixed limit of 50 items.
Supported parameters:
- `since_id`: when greater than `0`, returns entries with `id > since_id`, ordered by `id ASC`
- `max_id`: when equal to `0`, returns the most recent entries ordered by `id DESC`; when greater than `0`, returns entries with `id < max_id`, ordered by `id DESC`
- `with_ids`: comma-separated list of entry IDs to fetch
Selector precedence inside `?items` is:
1. `since_id`
2. `max_id`
3. `with_ids`
4. no item filter
Notes:
- `with_ids` does not enforce the 50-ID maximum mentioned in older Fever documentation
- invalid `with_ids` members are parsed as `0` and do not match normal entries
- when `items` is requested without `since_id`, `max_id`, or `with_ids`, the code applies no explicit `ORDER BY`, so result ordering is not guaranteed by SQL
- `html` is returned after Miniflux content rewriting and may include media-proxy-rewritten URLs
Example:
```json
{
"api_version": 3,
"auth": 1,
"last_refreshed_on_time": 1710000000,
"total_items": 245,
"items": [
{
"id": 100,
"feed_id": 10,
"title": "Example entry",
"author": "Author",
"html": "
Content
",
"url": "https://example.org/post",
"is_saved": 0,
"is_read": 1,
"created_on_time": 1709990000
}
]
}
```
## Write Operations
Normal successful write operations return the base authenticated response:
```json
{
"api_version": 3,
"auth": 1,
"last_refreshed_on_time": 1710000000
}
```
### `mark=item`
Parameters:
- `mark=item`
- `id=`
- `as=read|unread|saved|unsaved`
Behavior:
- `as=read`: marks the entry as read
- `as=unread`: marks the entry as unread
- `as=saved`: toggles the starred flag
- `as=unsaved`: toggles the starred flag
Important:
- `saved` and `unsaved` both call the same toggle operation
- sending `as=saved` twice will save, then unsave
- sending `as=unsaved` twice will unsave, then save
- if `id <= 0`, the handler returns without writing a response body
- if the entry does not exist or is already removed, the server returns the base response without an error
### `mark=feed`
Parameters:
- `mark=feed`
- `as=read`
- `id=`
- `before=`
Behavior:
- marks unread entries in the feed as read when `published_at < before`
- the update runs asynchronously in a goroutine after the response is returned
Notes:
- if `id <= 0`, the handler returns without writing a response body
- if `before` is missing or invalid, it is treated as Unix time `0`, which usually means nothing is marked as read
### `mark=group`
Parameters:
- `mark=group`
- `as=read`
- `id=`
- `before=`
Behavior:
- `id=0`: marks all unread entries as read, ignoring `before`
- `id>0`: marks unread entries in the matching category as read when `published_at < before`
- the update runs asynchronously in a goroutine after the response is returned
Notes:
- group IDs map to Miniflux category IDs
- if `id < 0`, the handler returns without writing a response body
- if `before` is missing or invalid for `id>0`, it is treated as Unix time `0`, which usually means nothing is marked as read
## Error Handling
Authentication failures:
- HTTP status: `200`
- body: `{"api_version":3,"auth":0}`
Internal errors:
- HTTP status: `500`
- body:
```json
{
"error_message": "..."
}
```
## Differences From Generic Fever Documentation
This implementation is Fever-compatible, but it does not match every detail of historical Fever API docs.
- Responses are always JSON; `api=xml` is mentioned in code comments but is not implemented
- `api_version` is `3`
- `last_refreshed_on_time` is set to the current response time, not the timestamp of the most recently refreshed feed
- the `Kindling` and `Sparks` super groups are not returned
- `feeds[].is_spark` is always `0`
- item ordering without explicit pagination parameters is unspecified
- `as=saved` and `as=unsaved` toggle the saved flag instead of setting it absolutely
## Examples
Fetch groups:
```bash
curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&groups'
```
Fetch most recent items:
```bash
curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&items&max_id=0'
```
Fetch items after a known ID:
```bash
curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&items&since_id=123'
```
Mark an item as read:
```bash
curl -s -X POST 'https://miniflux.example.com/fever/' \
-d 'api_key=TOKEN' \
-d 'mark=item' \
-d 'as=read' \
-d 'id=123'
```
Mark a feed as read before a timestamp:
```bash
curl -s -X POST 'https://miniflux.example.com/fever/' \
-d 'api_key=TOKEN' \
-d 'mark=feed' \
-d 'as=read' \
-d 'id=10' \
-d 'before=1710000000'
```
Mark all items as read through the group endpoint:
```bash
curl -s -X POST 'https://miniflux.example.com/fever/' \
-d 'api_key=TOKEN' \
-d 'mark=group' \
-d 'as=read' \
-d 'id=0'
```
v2-2.3.0/internal/fever/handler.go 0000664 0000000 0000000 00000035556 15201231005 0016735 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package fever // import "miniflux.app/v2/internal/fever"
import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
)
// NewHandler returns an http.Handler for Fever API calls.
func NewHandler(store *storage.Storage) http.Handler {
h := &feverHandler{store: store}
return http.HandlerFunc(h.serve)
}
type feverHandler struct {
store *storage.Storage
}
func (h *feverHandler) serve(w http.ResponseWriter, r *http.Request) {
switch {
case request.HasQueryParam(r, "groups"):
h.handleGroups(w, r)
case request.HasQueryParam(r, "feeds"):
h.handleFeeds(w, r)
case request.HasQueryParam(r, "favicons"):
h.handleFavicons(w, r)
case request.HasQueryParam(r, "unread_item_ids"):
h.handleUnreadItems(w, r)
case request.HasQueryParam(r, "saved_item_ids"):
h.handleSavedItems(w, r)
case request.HasQueryParam(r, "items"):
h.handleItems(w, r)
case r.FormValue("mark") == "item":
h.handleWriteItems(w, r)
case r.FormValue("mark") == "feed":
h.handleWriteFeeds(w, r)
case r.FormValue("mark") == "group":
h.handleWriteGroups(w, r)
default:
response.JSON(w, r, newBaseResponse())
}
}
/*
A request with the groups argument will return two additional members:
groups contains an array of group objects
feeds_groups contains an array of feeds_group objects
A group object has the following members:
id (positive integer)
title (utf-8 string)
The feeds_group object is documented under “Feeds/Groups Relationships.”
The “Kindling” super group is not included in this response and is composed of all feeds with
an is_spark equal to 0.
The “Sparks” super group is not included in this response and is composed of all feeds with an
is_spark equal to 1.
*/
func (h *feverHandler) handleGroups(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching groups",
slog.Int64("user_id", userID),
)
categories, err := h.store.Categories(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
feeds, err := h.store.Feeds(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
var result groupsResponse
for _, category := range categories {
result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
}
result.FeedsGroups = buildFeedGroups(feeds)
result.SetCommonValues()
response.JSON(w, r, result)
}
/*
A request with the feeds argument will return two additional members:
feeds contains an array of group objects
feeds_groups contains an array of feeds_group objects
A feed object has the following members:
id (positive integer)
favicon_id (positive integer)
title (utf-8 string)
url (utf-8 string)
site_url (utf-8 string)
is_spark (boolean integer)
last_updated_on_time (Unix timestamp/integer)
The feeds_group object is documented under “Feeds/Groups Relationships.”
The “All Items” super feed is not included in this response and is composed of all items from all feeds
that belong to a given group. For the “Kindling” super group and all user created groups the items
should be limited to feeds with an is_spark equal to 0.
For the “Sparks” super group the items should be limited to feeds with an is_spark equal to 1.
*/
func (h *feverHandler) handleFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching feeds",
slog.Int64("user_id", userID),
)
feeds, err := h.store.Feeds(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
var result feedsResponse
result.Feeds = make([]feed, 0, len(feeds))
for _, f := range feeds {
subscription := feed{
ID: f.ID,
Title: f.Title,
URL: f.FeedURL,
SiteURL: f.SiteURL,
IsSpark: 0,
LastUpdated: f.CheckedAt.Unix(),
}
if f.Icon != nil {
subscription.FaviconID = f.Icon.IconID
}
result.Feeds = append(result.Feeds, subscription)
}
result.FeedsGroups = buildFeedGroups(feeds)
result.SetCommonValues()
response.JSON(w, r, result)
}
/*
A request with the favicons argument will return one additional member:
favicons contains an array of favicon objects
A favicon object has the following members:
id (positive integer)
data (base64 encoded image data; prefixed by image type)
An example data value:
image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==
The data member of a favicon object can be used with the data: protocol to embed an image in CSS or HTML.
A PHP/HTML example:
echo '';
*/
func (h *feverHandler) handleFavicons(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching favicons",
slog.Int64("user_id", userID),
)
icons, err := h.store.Icons(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
var result faviconsResponse
for _, i := range icons {
result.Favicons = append(result.Favicons, favicon{
ID: i.ID,
Data: i.DataURL(),
})
}
result.SetCommonValues()
response.JSON(w, r, result)
}
/*
A request with the items argument will return two additional members:
items contains an array of item objects
total_items contains the total number of items stored in the database (added in API version 2)
An item object has the following members:
id (positive integer)
feed_id (positive integer)
title (utf-8 string)
author (utf-8 string)
html (utf-8 string)
url (utf-8 string)
is_saved (boolean integer)
is_read (boolean integer)
created_on_time (Unix timestamp/integer)
Most servers won’t have enough memory allocated to PHP to dump all items at once.
Three optional arguments control determine the items included in the response.
Use the since_id argument with the highest id of locally cached items to request 50 additional items.
Repeat until the items array in the response is empty.
Use the max_id argument with the lowest id of locally cached items (or 0 initially) to request 50 previous items.
Repeat until the items array in the response is empty. (added in API version 2)
Use the with_ids argument with a comma-separated list of item ids to request (a maximum of 50) specific items.
(added in API version 2)
*/
func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
var result itemsResponse
userID := request.UserID(r)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithLimit(50)
switch {
case request.HasQueryParam(r, "since_id"):
sinceID := request.QueryInt64Param(r, "since_id", 0)
if sinceID > 0 {
slog.Debug("[Fever] Fetching items since a given date",
slog.Int64("user_id", userID),
slog.Int64("since_id", sinceID),
)
builder.AfterEntryID(sinceID)
builder.WithSorting("id", "ASC")
}
case request.HasQueryParam(r, "max_id"):
maxID := request.QueryInt64Param(r, "max_id", 0)
if maxID == 0 {
slog.Debug("[Fever] Fetching most recent items",
slog.Int64("user_id", userID),
)
builder.WithSorting("id", "DESC")
} else if maxID > 0 {
slog.Debug("[Fever] Fetching items before a given item ID",
slog.Int64("user_id", userID),
slog.Int64("max_id", maxID),
)
builder.BeforeEntryID(maxID)
builder.WithSorting("id", "DESC")
}
case request.HasQueryParam(r, "with_ids"):
csvItemIDs := request.QueryStringParam(r, "with_ids", "")
if csvItemIDs != "" {
var itemIDs []int64
for strItemID := range strings.SplitSeq(csvItemIDs, ",") {
strItemID = strings.TrimSpace(strItemID)
itemID, _ := strconv.ParseInt(strItemID, 10, 64)
itemIDs = append(itemIDs, itemID)
}
builder.WithEntryIDs(itemIDs)
}
default:
slog.Debug("[Fever] Fetching oldest items",
slog.Int64("user_id", userID),
)
}
entries, err := builder.GetEntries()
if err != nil {
response.JSONServerError(w, r, err)
return
}
builder = h.store.NewEntryQueryBuilder(userID)
result.Total, err = builder.CountEntries()
if err != nil {
response.JSONServerError(w, r, err)
return
}
result.Items = make([]item, 0, len(entries))
for _, entry := range entries {
isRead := 0
if entry.Status == model.EntryStatusRead {
isRead = 1
}
isSaved := 0
if entry.Starred {
isSaved = 1
}
result.Items = append(result.Items, item{
ID: entry.ID,
FeedID: entry.FeedID,
Title: entry.Title,
Author: entry.Author,
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content),
URL: entry.URL,
IsSaved: isSaved,
IsRead: isRead,
CreatedAt: entry.Date.Unix(),
})
}
result.SetCommonValues()
response.JSON(w, r, result)
}
/*
The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced
with the remote Fever installation.
A request with the unread_item_ids argument will return one additional member:
unread_item_ids (string/comma-separated list of positive integers)
*/
func (h *feverHandler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching unread items",
slog.Int64("user_id", userID),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStatus(model.EntryStatusUnread)
rawEntryIDs, err := builder.GetEntryIDs()
if err != nil {
response.JSONServerError(w, r, err)
return
}
itemIDs := make([]string, 0, len(rawEntryIDs))
for _, entryID := range rawEntryIDs {
itemIDs = append(itemIDs, strconv.FormatInt(entryID, 10))
}
var result unreadResponse
result.ItemIDs = strings.Join(itemIDs, ",")
result.SetCommonValues()
response.JSON(w, r, result)
}
/*
The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced
with the remote Fever installation.
A request with the saved_item_ids argument will return one additional member:
saved_item_ids (string/comma-separated list of positive integers)
*/
func (h *feverHandler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Fetching saved items",
slog.Int64("user_id", userID),
)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStarred(true)
entryIDs, err := builder.GetEntryIDs()
if err != nil {
response.JSONServerError(w, r, err)
return
}
itemsIDs := make([]string, 0, len(entryIDs))
for _, entryID := range entryIDs {
itemsIDs = append(itemsIDs, strconv.FormatInt(entryID, 10))
}
result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")}
result.SetCommonValues()
response.JSON(w, r, result)
}
/*
mark=item
as=? where ? is replaced with read, saved or unsaved
id=? where ? is replaced with the id of the item to modify
*/
func (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
slog.Debug("[Fever] Receiving mark=item call",
slog.Int64("user_id", userID),
)
entryID := request.FormInt64Value(r, "id")
if entryID <= 0 {
return
}
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryID(entryID)
entry, err := builder.GetEntry()
if err != nil {
response.JSONServerError(w, r, err)
return
}
if entry == nil {
slog.Debug("[Fever] Entry not found",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
response.JSON(w, r, newBaseResponse())
return
}
switch r.FormValue("as") {
case "read":
slog.Debug("[Fever] Mark entry as read",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
case "unread":
slog.Debug("[Fever] Mark entry as unread",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
case "saved":
slog.Debug("[Fever] Mark entry as saved",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
if err := h.store.ToggleStarred(userID, entryID); err != nil {
response.JSONServerError(w, r, err)
return
}
settings, err := h.store.Integration(userID)
if err != nil {
response.JSONServerError(w, r, err)
return
}
go func() {
integration.SendEntry(entry, settings)
}()
case "unsaved":
slog.Debug("[Fever] Mark entry as unsaved",
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
if err := h.store.ToggleStarred(userID, entryID); err != nil {
response.JSONServerError(w, r, err)
return
}
}
response.JSON(w, r, newBaseResponse())
}
/*
mark=feed
as=read
id=? where ? is replaced with the id of the feed or group to modify
before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request
*/
func (h *feverHandler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0)
slog.Debug("[Fever] Mark feed as read before a given date",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Time("before_ts", before),
)
if feedID <= 0 {
return
}
if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, newBaseResponse())
}
/*
mark=group
as=read
id=? where ? is replaced with the id of the feed or group to modify
before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request
*/
func (h *feverHandler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
groupID := request.FormInt64Value(r, "id")
if groupID < 0 {
return
}
var err error
if groupID == 0 {
err = h.store.MarkAllAsRead(userID)
slog.Debug("[Fever] Mark all items as read",
slog.Int64("user_id", userID),
)
} else {
before := time.Unix(request.FormInt64Value(r, "before"), 0)
err = h.store.MarkCategoryAsRead(userID, groupID, before)
slog.Debug("[Fever] Mark group as read before a given date",
slog.Int64("user_id", userID),
slog.Int64("group_id", groupID),
slog.Time("before_ts", before),
)
}
if err != nil {
response.JSONServerError(w, r, err)
return
}
response.JSON(w, r, newBaseResponse())
}
/*
A feeds_group object has the following members:
group_id (positive integer)
feed_ids (string/comma-separated list of positive integers)
*/
func buildFeedGroups(feeds model.Feeds) []feedsGroups {
feedsGroupedByCategory := make(map[int64][]string, len(feeds))
for _, feed := range feeds {
feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
}
result := make([]feedsGroups, 0, len(feedsGroupedByCategory))
for categoryID, feedIDs := range feedsGroupedByCategory {
result = append(result, feedsGroups{
GroupID: categoryID,
FeedIDs: strings.Join(feedIDs, ","),
})
}
return result
}
v2-2.3.0/internal/fever/middleware.go 0000664 0000000 0000000 00000004345 15201231005 0017425 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package fever // import "miniflux.app/v2/internal/fever"
import (
"context"
"log/slog"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/storage"
)
// Middleware returns the Fever authentication middleware.
func Middleware(store *storage.Storage) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := request.ClientIP(r)
apiKey := r.FormValue("api_key")
if apiKey == "" {
slog.Warn("[Fever] No API key provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
response.JSON(w, r, newAuthFailureResponse())
return
}
user, err := store.UserByFeverToken(apiKey)
if err != nil {
slog.Error("[Fever] Unable to fetch user by API key",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
response.JSON(w, r, newAuthFailureResponse())
return
}
if user == nil {
slog.Warn("[Fever] No user found with the API key provided",
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
response.JSON(w, r, newAuthFailureResponse())
return
}
slog.Info("[Fever] User authenticated successfully",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", user.ID),
slog.String("username", user.Username),
)
store.SetLastLogin(user.ID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
v2-2.3.0/internal/fever/response.go 0000664 0000000 0000000 00000005404 15201231005 0017143 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package fever // import "miniflux.app/v2/internal/fever"
import (
"time"
)
type baseResponse struct {
Version int `json:"api_version"`
Authenticated int `json:"auth"`
LastRefresh int64 `json:"last_refreshed_on_time"`
}
func (b *baseResponse) SetCommonValues() {
b.Version = 3
b.Authenticated = 1
b.LastRefresh = time.Now().Unix()
}
/*
The default response is a JSON object containing two members:
api_version contains the version of the API responding (positive integer)
auth whether the request was successfully authenticated (boolean integer)
The API can also return XML by passing xml as the optional value of the api argument like so:
http://yourdomain.com/fever/?api=xml
The top level XML element is named response.
The response to each successfully authenticated request will have auth set to 1 and include
at least one additional member:
last_refreshed_on_time contains the time of the most recently refreshed (not updated)
feed (Unix timestamp/integer)
*/
func newBaseResponse() baseResponse {
r := baseResponse{}
r.SetCommonValues()
return r
}
func newAuthFailureResponse() baseResponse {
return baseResponse{Version: 3, Authenticated: 0}
}
type groupsResponse struct {
baseResponse
Groups []group `json:"groups"`
FeedsGroups []feedsGroups `json:"feeds_groups"`
}
type feedsResponse struct {
baseResponse
Feeds []feed `json:"feeds"`
FeedsGroups []feedsGroups `json:"feeds_groups"`
}
type faviconsResponse struct {
baseResponse
Favicons []favicon `json:"favicons"`
}
type itemsResponse struct {
baseResponse
Items []item `json:"items"`
Total int `json:"total_items"`
}
type unreadResponse struct {
baseResponse
ItemIDs string `json:"unread_item_ids"`
}
type savedResponse struct {
baseResponse
ItemIDs string `json:"saved_item_ids"`
}
type group struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
type feedsGroups struct {
GroupID int64 `json:"group_id"`
FeedIDs string `json:"feed_ids"`
}
type feed struct {
ID int64 `json:"id"`
FaviconID int64 `json:"favicon_id"`
Title string `json:"title"`
URL string `json:"url"`
SiteURL string `json:"site_url"`
IsSpark int `json:"is_spark"`
LastUpdated int64 `json:"last_updated_on_time"`
}
type item struct {
ID int64 `json:"id"`
FeedID int64 `json:"feed_id"`
Title string `json:"title"`
Author string `json:"author"`
HTML string `json:"html"`
URL string `json:"url"`
IsSaved int `json:"is_saved"`
IsRead int `json:"is_read"`
CreatedAt int64 `json:"created_on_time"`
}
type favicon struct {
ID int64 `json:"id"`
Data string `json:"data"`
}
v2-2.3.0/internal/googlereader/ 0000775 0000000 0000000 00000000000 15201231005 0016303 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/googlereader/README.md 0000664 0000000 0000000 00000035720 15201231005 0017571 0 ustar 00root root 0000000 0000000 # Miniflux Google Reader API
This document describes the Google Reader compatible API implemented by the `internal/googlereader` package in this repository.
Miniflux implements a compatibility subset intended for existing Google Reader clients. It is not a full reimplementation of the historical Google Reader API, and several behaviors are intentionally narrower or implementation-specific.
## Endpoint
- Client login path: `BASE_URL/accounts/ClientLogin`
- API prefix: `BASE_URL/reader/api/0`
- `BASE_URL` includes the Miniflux root URL and any configured `BasePath`
- Response format:
- `ClientLogin`: plain text by default, JSON when `output=json`
- most API reads: JSON
- most API writes: plain text `OK`
## Enabling the API
Google Reader compatibility is configured per user from the Miniflux integrations page.
- `Google Reader API` must be enabled
- `Google Reader Username` must be unique across all Miniflux users
- `Google Reader Password` is stored as a bcrypt hash
The Google Reader username and password are separate integration credentials. They are not the Miniflux account password.
## Authentication
### `POST /accounts/ClientLogin`
This endpoint exchanges the configured Google Reader username and password for an auth token.
Form parameters:
- `Email`: Google Reader username
- `Passwd`: Google Reader password
- `output`: optional, set to `json` for a JSON response
Successful responses:
- default: plain text
- with `output=json`: JSON
Example plain-text response:
```text
SID=readeruser/0123456789abcdef...
LSID=readeruser/0123456789abcdef...
Auth=readeruser/0123456789abcdef...
```
Example JSON response:
```json
{
"SID": "readeruser/0123456789abcdef...",
"LSID": "readeruser/0123456789abcdef...",
"Auth": "readeruser/0123456789abcdef..."
}
```
On authentication failure, `ClientLogin` returns HTTP `401` with the normal JSON error body:
```json
{
"error_message": "access unauthorized"
}
```
### Auth token format
The token format is:
```text
/
```
The digest is generated server-side from:
- the Google Reader username
- the stored bcrypt hash of the Google Reader password
Specifically, the code computes an HMAC-SHA256 digest of an empty message using the key:
```text
googlereader_username + bcrypt_hash
```
Because the bcrypt hash is only known to the server, clients should not try to precompute the token. Use `ClientLogin` or `GET /reader/api/0/token`.
### Authenticating API calls
Miniflux uses different auth mechanisms for `GET` and `POST` requests:
- `GET` requests must send the header `Authorization: GoogleLogin auth=`
- `POST` requests are authenticated with `T=` read from the parsed form values
Notes:
- the auth scheme must be exactly `GoogleLogin`
- the auth field name must be exactly lowercase `auth`
- for `POST`, `T` may come from the URL query or the form body because the server reads merged form values
- `POST` requests do not accept the token from the `Authorization` header
- `GET` requests do not accept the token from the query string
### `GET /reader/api/0/token`
This endpoint requires normal `GET` authentication and returns the same token as plain text.
Many Google Reader clients use this as the edit token for subsequent write requests. In Miniflux, the edit token and auth token are the same value.
### Authentication failure on `/reader/api/0/*`
When API authentication fails under `/reader/api/0`, Miniflux returns:
- HTTP `401`
- header `X-Reader-Google-Bad-Token: true`
- content type `text/plain; charset=utf-8`
- body `Unauthorized`
This is different from `ClientLogin`, which returns a JSON `401`.
## Identifier formats
### Stream IDs
The implementation recognizes these stream forms:
- built-in streams:
- `user/-/state/com.google/read`
- `user/-/state/com.google/starred`
- `user/-/state/com.google/reading-list`
- `user/-/state/com.google/kept-unread`
- `user/-/state/com.google/broadcast`
- `user/-/state/com.google/broadcast-friends`
- `user/-/state/com.google/like`
- user-specific equivalents:
- `user//state/com.google/...`
- label streams:
- `user/-/label/`
- `user//label/`
- feed streams:
- `feed/`
Important feed stream difference:
- read APIs usually emit `feed/`
- `subscription/edit` with `ac=subscribe` expects `feed/`
- `subscription/edit` with `ac=edit` or `ac=unsubscribe` expects `feed/`
So `feed/<...>` is not a single stable identifier format across all endpoints.
### Item IDs
`edit-tag` and `stream/items/contents` accept repeated `i` parameters in all of these formats:
- long Google Reader form: `tag:google.com,2005:reader/item/00000000148b9369`
- short prefixed hexadecimal form: `tag:google.com,2005:reader/item/2f2`
- bare 16-character hexadecimal form: `000000000000048c`
- decimal entry ID: `12345`
Responses use different forms depending on endpoint:
- `stream/items/ids` returns decimal IDs as strings
- `stream/items/contents` returns long-form Google Reader item IDs
## Common response conventions
JSON errors use this shape:
```json
{
"error_message": "..."
}
```
Plain-text success responses from write endpoints are usually:
```text
OK
```
## POST parameter parsing
Most `POST` handlers call `ParseForm()` and read from `r.Form`, so parameters may be supplied either in the query string or in a standard form body.
Important exception:
- `POST /reader/api/0/edit-tag` reads `a` and `r` from `r.PostForm`, so those tag lists must come from the request body
Because `GET` auth comes only from the `Authorization` header, query parameters never authenticate `GET` requests even when other parameters are read from the query string.
## Endpoint reference
### `GET /reader/api/0/user-info`
Returns JSON only. No `output=json` parameter is required.
Response fields:
- `userId`: Miniflux user ID as a string
- `userName`: Miniflux username
- `userProfileId`: same value as `userId`
- `userEmail`: same value as `userName`
Example:
```json
{
"userId": "1",
"userName": "demo",
"userProfileId": "1",
"userEmail": "demo"
}
```
### `GET /reader/api/0/tag/list?output=json`
Returns the starred state and user labels.
Notes:
- `output=json` is required
- only labels and the starred state are returned
- built-in states such as `read` and `reading-list` are not listed here
Response shape:
```json
{
"tags": [
{
"id": "user/1/state/com.google/starred"
},
{
"id": "user/1/label/Tech",
"label": "Tech",
"type": "folder"
}
]
}
```
### `GET /reader/api/0/subscription/list?output=json`
Returns the user's feeds.
Notes:
- `output=json` is required
- each feed is reported with a numeric feed stream ID such as `feed/42`
- `categories` always contains the Miniflux category as a Google Reader folder
Response shape:
```json
{
"subscriptions": [
{
"id": "feed/42",
"title": "Example Feed",
"categories": [
{
"id": "user/1/label/Tech",
"label": "Tech",
"type": "folder"
}
],
"url": "https://example.org/feed.xml",
"htmlUrl": "https://example.org/",
"iconUrl": "https://miniflux.example.com/icon/..."
}
]
}
```
### `POST /reader/api/0/subscription/quickadd`
Subscribes to the first discovered feed for the given absolute URL.
Form parameters:
- `T`: auth token
- `quickadd`: absolute URL
Response shape when a feed is found:
```json
{
"numResults": 1,
"query": "https://example.org/feed.xml",
"streamId": "feed/42",
"streamName": "Example Feed"
}
```
Response shape when no feed is found:
```json
{
"numResults": 0
}
```
Notes:
- the request URL must be absolute
- the created subscription is assigned to the user's first category when no explicit category is provided
### `POST /reader/api/0/subscription/edit`
Edits subscriptions. Successful requests return plain text `OK`.
Form parameters:
- `T`: auth token
- `ac`: action
- `s`: repeated stream ID
- `a`: optional destination label stream
- `t`: optional title
Supported actions:
- `ac=subscribe`
- `ac=unsubscribe`
- `ac=edit`
Behavior by action:
- `subscribe`
- only the first `s` value is used
- `s` must be `feed/`
- `a`, when present, must be a label stream
- `t`, when present, becomes the feed title after creation
- `unsubscribe`
- every `s` must be `feed/`
- `edit`
- only the first `s` value is used
- `s` must be `feed/`
- `t` renames the feed
- `a` moves the feed to a label, and must be a label stream
Notable limitations:
- removing a label is not implemented here
- `subscribe`, `edit`, and `unsubscribe` do not share the same feed ID format
### `POST /reader/api/0/rename-tag`
Renames a label. Successful requests return plain text `OK`.
Form parameters:
- `T`: auth token
- `s`: source label stream
- `dest`: destination label stream
Rules:
- both `s` and `dest` must be label streams
- the destination label name must not be empty
- if the source label does not exist, the endpoint returns HTTP `404`
### `POST /reader/api/0/disable-tag`
Deletes one or more labels and reassigns affected feeds to the user's first remaining category.
Form parameters:
- `T`: auth token
- `s`: repeated label stream
Rules:
- only label streams are supported
- at least one category must remain after deletion, otherwise the operation fails
Successful requests return plain text `OK`.
### `POST /reader/api/0/edit-tag`
Marks entries read or unread and starred or unstarred.
Form parameters:
- `T`: auth token
- `i`: repeated item ID
- `a`: repeated tag stream to add
- `r`: repeated tag stream to remove
Supported tag semantics:
- add `user/.../state/com.google/read`: mark read
- remove `user/.../state/com.google/read`: mark unread
- add `user/.../state/com.google/kept-unread`: mark unread
- remove `user/.../state/com.google/kept-unread`: mark read
- add `user/.../state/com.google/starred`: star
- remove `user/.../state/com.google/starred`: unstar
Special cases:
- `read` and `kept-unread` cannot be combined in conflicting ways in the same request
- `starred` cannot be present in both add and remove
- `broadcast` and `like` are recognized but ignored
- unsupported tag types cause an error
Successful requests return plain text `OK`.
### `GET /reader/api/0/stream/items/ids?output=json`
Returns item IDs for one stream.
Required query parameters:
- `output=json`
- `s=`
Optional query parameters:
- `n`: maximum number of items to return
- `c`: numeric offset continuation token
- `r`: sort direction, `o` for ascending, anything else for descending
- `ot`: only items published after this Unix timestamp in seconds
- `nt`: only items published before this Unix timestamp in seconds
- `xt`: repeated exclude target stream
- `it`: repeated filter target stream, parsed but currently ignored
Supported `s` values:
- `user/.../state/com.google/reading-list`
- `user/.../state/com.google/starred`
- `user/.../state/com.google/read`
- `feed/`
Notes:
- exactly one `s` value is expected
- label streams are not supported here
- when `xt` contains the `read` stream, `reading-list` and `feed/` behave as unread-only queries
- if `n` is omitted, the query is effectively unbounded
- `continuation` is a numeric offset encoded as a JSON string, not an opaque token
Response shape:
```json
{
"itemRefs": [
{
"id": "12345"
},
{
"id": "12344"
}
],
"continuation": "2"
}
```
### `POST /reader/api/0/stream/items/contents`
Returns content for specific items.
Required parameters:
- `T`: auth token
- `output=json`
- `i`: repeated item ID
Optional query parameters:
- `r`: sort direction, `o` for ascending, anything else for descending
Implementation notes:
- the route is `POST` only
- `T`, `output`, and `i` are read from merged form values, so they may be supplied in the query string or the form body
- the handler parses stream filter query parameters, but in practice only the sort direction affects the result
Response shape:
```json
{
"direction": "ltr",
"id": "user/-/state/com.google/reading-list",
"title": "Reading List",
"self": [
{
"href": "https://miniflux.example.com/reader/api/0/stream/items/contents"
}
],
"updated": 1710000000,
"author": "demo",
"items": [
{
"id": "tag:google.com,2005:reader/item/00000000148b9369",
"categories": [
"user/1/state/com.google/reading-list",
"user/1/label/Tech",
"user/1/state/com.google/starred"
],
"title": "Example entry",
"crawlTimeMsec": "1710000000123",
"timestampUsec": "1710000000123456",
"published": 1710000000,
"updated": 1710000300,
"author": "Author",
"alternate": [
{
"href": "https://example.org/post",
"type": "text/html"
}
],
"summary": {
"direction": "ltr",
"content": "
`
output := RewriteDocumentWithRelativeProxyURL(input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterWithPictureSource(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
input := ``
expected := ``
output := RewriteDocumentWithRelativeProxyURL(input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "http-only")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
input := ``
expected := ``
output := RewriteDocumentWithRelativeProxyURL(input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestMediaProxyWithImageDataURL(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
input := ``
expected := ``
output := RewriteDocumentWithRelativeProxyURL(input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestMediaProxyWithImageSourceDataURL(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
input := ``
expected := ``
output := RewriteDocumentWithRelativeProxyURL(input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestShouldProxifyURLWithMimeType(t *testing.T) {
testCases := []struct {
name string
mediaURL string
mediaMimeType string
mediaProxyOption string
mediaProxyResourceTypes []string
expected bool
}{
{
name: "Empty URL should not be proxified",
mediaURL: "",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "Data URL should not be proxified",
mediaURL: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
mediaMimeType: "image/png",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "HTTP URL with all mode and matching MIME type should be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "HTTPS URL with all mode and matching MIME type should be proxified",
mediaURL: "https://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "HTTP URL with http-only mode and matching MIME type should be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "HTTPS URL with http-only mode should not be proxified",
mediaURL: "https://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "URL with none mode should not be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "none",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "URL with matching MIME type should be proxified",
mediaURL: "http://example.com/video.mp4",
mediaMimeType: "video/mp4",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"video"},
expected: true,
},
{
name: "URL with non-matching MIME type should not be proxified",
mediaURL: "http://example.com/video.mp4",
mediaMimeType: "video/mp4",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "URL with multiple resource types and matching MIME type should be proxified",
mediaURL: "http://example.com/audio.mp3",
mediaMimeType: "audio/mp3",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image", "audio", "video"},
expected: true,
},
{
name: "URL with multiple resource types but non-matching MIME type should not be proxified",
mediaURL: "http://example.com/document.pdf",
mediaMimeType: "application/pdf",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image", "audio", "video"},
expected: false,
},
{
name: "URL with empty resource types should not be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{},
expected: false,
},
{
name: "Relative URL should not be proxified",
mediaURL: "/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "Protocol-relative URL should not be proxified",
mediaURL: "//cdn.example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "Unsupported scheme should not be proxified",
mediaURL: "ftp://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "Blob URL should not be proxified",
mediaURL: "blob:https://example.com/123",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "URL with partial MIME type match should be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "URL with uppercase MIME type should be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "Image/JPEG",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "URL with audio MIME type and audio resource type should be proxified",
mediaURL: "http://example.com/song.ogg",
mediaMimeType: "audio/ogg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio"},
expected: true,
},
{
name: "URL with mixed-case audio MIME type should be proxified",
mediaURL: "http://example.com/song.ogg",
mediaMimeType: "AuDiO/Ogg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio"},
expected: true,
},
{
name: "URL with video MIME type and video resource type should be proxified",
mediaURL: "http://example.com/movie.webm",
mediaMimeType: "video/webm",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"video"},
expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := ShouldProxifyURLWithMimeType(tc.mediaURL, tc.mediaMimeType, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
if result != tc.expected {
t.Errorf("Expected %v, got %v for URL: %s, MIME type: %s, proxy option: %s, resource types: %v",
tc.expected, result, tc.mediaURL, tc.mediaMimeType, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
}
})
}
}
v2-2.3.0/internal/mediaproxy/rewriter.go 0000664 0000000 0000000 00000010227 15201231005 0020221 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
import (
"net/url"
"slices"
"strings"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/reader/sanitizer"
"github.com/PuerkitoBio/goquery"
)
type urlProxyRewriter func(url string) string
func RewriteDocumentWithRelativeProxyURL(htmlDocument string) string {
return genericProxyRewriter(ProxifyRelativeURL, htmlDocument)
}
func RewriteDocumentWithAbsoluteProxyURL(htmlDocument string) string {
return genericProxyRewriter(ProxifyAbsoluteURL, htmlDocument)
}
func genericProxyRewriter(proxifyFunction urlProxyRewriter, htmlDocument string) string {
proxyOption := config.Opts.MediaProxyMode()
if proxyOption == "none" {
return htmlDocument
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlDocument))
if err != nil {
return htmlDocument
}
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
switch mediaType {
case "image":
doc.Find("img, picture source").Each(func(i int, img *goquery.Selection) {
if srcAttrValue, ok := img.Attr("src"); ok {
if shouldProxifyURL(srcAttrValue, proxyOption) {
img.SetAttr("src", proxifyFunction(srcAttrValue))
}
}
if srcsetAttrValue, ok := img.Attr("srcset"); ok {
proxifySourceSet(img, proxifyFunction, proxyOption, srcsetAttrValue)
}
})
if !slices.Contains(config.Opts.MediaProxyResourceTypes(), "video") {
doc.Find("video").Each(func(i int, video *goquery.Selection) {
if posterAttrValue, ok := video.Attr("poster"); ok {
if shouldProxifyURL(posterAttrValue, proxyOption) {
video.SetAttr("poster", proxifyFunction(posterAttrValue))
}
}
})
}
case "audio":
doc.Find("audio, audio source").Each(func(i int, audio *goquery.Selection) {
if srcAttrValue, ok := audio.Attr("src"); ok {
if shouldProxifyURL(srcAttrValue, proxyOption) {
audio.SetAttr("src", proxifyFunction(srcAttrValue))
}
}
})
case "video":
doc.Find("video, video source").Each(func(i int, video *goquery.Selection) {
if srcAttrValue, ok := video.Attr("src"); ok {
if shouldProxifyURL(srcAttrValue, proxyOption) {
video.SetAttr("src", proxifyFunction(srcAttrValue))
}
}
if posterAttrValue, ok := video.Attr("poster"); ok {
if shouldProxifyURL(posterAttrValue, proxyOption) {
video.SetAttr("poster", proxifyFunction(posterAttrValue))
}
}
})
}
}
output, err := doc.FindMatcher(goquery.Single("body")).Html()
if err != nil {
return htmlDocument
}
return output
}
func proxifySourceSet(element *goquery.Selection, proxifyFunction urlProxyRewriter, proxyOption, srcsetAttrValue string) {
imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)
for _, imageCandidate := range imageCandidates {
if shouldProxifyURL(imageCandidate.ImageURL, proxyOption) {
imageCandidate.ImageURL = proxifyFunction(imageCandidate.ImageURL)
}
}
element.SetAttr("srcset", imageCandidates.String())
}
// shouldProxifyURL checks if the media URL should be proxified based on the media proxy option and URL scheme.
func shouldProxifyURL(mediaURL, mediaProxyOption string) bool {
parsedURL, err := url.Parse(mediaURL)
if err != nil || !parsedURL.IsAbs() || parsedURL.Host == "" {
return false
}
switch {
case mediaProxyOption == "all" && (strings.EqualFold(parsedURL.Scheme, "http") || strings.EqualFold(parsedURL.Scheme, "https")):
return true
case mediaProxyOption != "none" && strings.EqualFold(parsedURL.Scheme, "http"):
return true
default:
return false
}
}
// ShouldProxifyURLWithMimeType checks if the media URL should be proxified based on the media proxy option, URL scheme, and MIME type.
func ShouldProxifyURLWithMimeType(mediaURL, mediaMimeType, mediaProxyOption string, mediaProxyResourceTypes []string) bool {
if !shouldProxifyURL(mediaURL, mediaProxyOption) {
return false
}
mediaMimeType = strings.ToLower(mediaMimeType)
for _, mediaType := range mediaProxyResourceTypes {
if strings.HasPrefix(mediaMimeType, mediaType+"/") {
return true
}
}
return false
}
v2-2.3.0/internal/mediaproxy/url.go 0000664 0000000 0000000 00000003225 15201231005 0017160 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/url"
"miniflux.app/v2/internal/config"
)
func ProxifyRelativeURL(mediaURL string) string {
if mediaURL == "" {
return ""
}
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != nil {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
mediaURLBytes := []byte(mediaURL)
mac := hmac.New(sha256.New, config.Opts.MediaProxyPrivateKey())
mac.Write(mediaURLBytes)
digest := mac.Sum(nil)
// Preserve the configured base path so proxied URLs still work when Miniflux is served from a subfolder.
return fmt.Sprintf("%s/proxy/%s/%s", config.Opts.BasePath(), base64.URLEncoding.EncodeToString(digest), base64.URLEncoding.EncodeToString(mediaURLBytes))
}
func ProxifyAbsoluteURL(mediaURL string) string {
if mediaURL == "" {
return ""
}
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != nil {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
// Note that the proxyified URL is relative to the root URL.
proxifiedUrl := ProxifyRelativeURL(mediaURL)
absoluteURL, err := url.JoinPath(config.Opts.RootURL(), proxifiedUrl)
if err != nil {
return mediaURL
}
return absoluteURL
}
func proxifyURLWithCustomProxy(mediaURL string, customProxyURL *url.URL) string {
if customProxyURL == nil {
return mediaURL
}
absoluteURL := customProxyURL.JoinPath(base64.URLEncoding.EncodeToString([]byte(mediaURL)))
return absoluteURL.String()
}
v2-2.3.0/internal/metric/ 0000775 0000000 0000000 00000000000 15201231005 0015127 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/metric/metric.go 0000664 0000000 0000000 00000014377 15201231005 0016755 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package metric // import "miniflux.app/v2/internal/metric"
import (
"context"
"log/slog"
"time"
"miniflux.app/v2/internal/storage"
"github.com/prometheus/client_golang/prometheus"
)
// Status label values for histogram metrics.
const (
StatusSuccess = "success"
StatusError = "error"
)
// Prometheus Metrics.
var (
BackgroundFeedRefreshDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "miniflux",
Name: "background_feed_refresh_duration",
Help: "Processing time to refresh feeds from the background workers",
Buckets: prometheus.LinearBuckets(1, 2, 15),
},
[]string{"status"},
)
ScraperRequestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "miniflux",
Name: "scraper_request_duration",
Help: "Web scraper request duration",
Buckets: prometheus.LinearBuckets(1, 2, 25),
},
[]string{"status"},
)
ArchiveEntriesDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "miniflux",
Name: "archive_entries_duration",
Help: "Archive entries duration",
Buckets: prometheus.LinearBuckets(1, 2, 30),
},
[]string{"status"},
)
usersGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "users",
Help: "Number of users",
},
)
feedsGauge = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "feeds",
Help: "Number of feeds by status",
},
[]string{"status"},
)
brokenFeedsGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "broken_feeds",
Help: "Number of broken feeds",
},
)
entriesGauge = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "entries",
Help: "Number of entries by status",
},
[]string{"status"},
)
dbOpenConnectionsGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_open_connections",
Help: "The number of established connections both in use and idle",
},
)
dbConnectionsInUseGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_connections_in_use",
Help: "The number of connections currently in use",
},
)
dbConnectionsIdleGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_connections_idle",
Help: "The number of idle connections",
},
)
dbConnectionsWaitCountGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_connections_wait_count",
Help: "The total number of connections waited for",
},
)
dbConnectionsMaxIdleClosedGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_connections_max_idle_closed",
Help: "The total number of connections closed due to SetMaxIdleConns",
},
)
dbConnectionsMaxIdleTimeClosedGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_connections_max_idle_time_closed",
Help: "The total number of connections closed due to SetConnMaxIdleTime",
},
)
dbConnectionsMaxLifetimeClosedGauge = prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "miniflux",
Name: "db_connections_max_lifetime_closed",
Help: "The total number of connections closed due to SetConnMaxLifetime",
},
)
)
// collector represents a metric collector.
type collector struct {
store *storage.Storage
refreshInterval time.Duration
}
// NewCollector initializes a new metric collector.
func NewCollector(store *storage.Storage, refreshInterval time.Duration) *collector {
prometheus.MustRegister(BackgroundFeedRefreshDuration)
prometheus.MustRegister(ScraperRequestDuration)
prometheus.MustRegister(ArchiveEntriesDuration)
prometheus.MustRegister(usersGauge)
prometheus.MustRegister(feedsGauge)
prometheus.MustRegister(brokenFeedsGauge)
prometheus.MustRegister(entriesGauge)
prometheus.MustRegister(dbOpenConnectionsGauge)
prometheus.MustRegister(dbConnectionsInUseGauge)
prometheus.MustRegister(dbConnectionsIdleGauge)
prometheus.MustRegister(dbConnectionsWaitCountGauge)
prometheus.MustRegister(dbConnectionsMaxIdleClosedGauge)
prometheus.MustRegister(dbConnectionsMaxIdleTimeClosedGauge)
prometheus.MustRegister(dbConnectionsMaxLifetimeClosedGauge)
return &collector{store, refreshInterval}
}
// GatherStorageMetrics polls the database to fetch metrics.
func (c *collector) GatherStorageMetrics(ctx context.Context) {
ticker := time.NewTicker(c.refreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Debug("Stopping metric collector")
return
case <-ticker.C:
}
slog.Debug("Collecting metrics from the database")
if usersCount, err := c.store.CountUsers(); err != nil {
slog.Warn("Unable to collect users metric", slog.Any("error", err))
} else {
usersGauge.Set(float64(usersCount))
}
if brokenFeedsCount, err := c.store.CountAllFeedsWithErrors(); err != nil {
slog.Warn("Unable to collect broken feeds metric", slog.Any("error", err))
} else {
brokenFeedsGauge.Set(float64(brokenFeedsCount))
}
if feedsCount, err := c.store.CountAllFeeds(); err != nil {
slog.Warn("Unable to collect feeds metric", slog.Any("error", err))
} else {
for status, count := range feedsCount {
feedsGauge.WithLabelValues(status).Set(float64(count))
}
}
if entriesCount, err := c.store.CountAllEntries(); err != nil {
slog.Warn("Unable to collect entries metric", slog.Any("error", err))
} else {
for status, count := range entriesCount {
entriesGauge.WithLabelValues(status).Set(float64(count))
}
}
dbStats := c.store.DBStats()
dbOpenConnectionsGauge.Set(float64(dbStats.OpenConnections))
dbConnectionsInUseGauge.Set(float64(dbStats.InUse))
dbConnectionsIdleGauge.Set(float64(dbStats.Idle))
dbConnectionsWaitCountGauge.Set(float64(dbStats.WaitCount))
dbConnectionsMaxIdleClosedGauge.Set(float64(dbStats.MaxIdleClosed))
dbConnectionsMaxIdleTimeClosedGauge.Set(float64(dbStats.MaxIdleTimeClosed))
dbConnectionsMaxLifetimeClosedGauge.Set(float64(dbStats.MaxLifetimeClosed))
}
}
v2-2.3.0/internal/model/ 0000775 0000000 0000000 00000000000 15201231005 0014744 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/model/api_key.go 0000664 0000000 0000000 00000001523 15201231005 0016715 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"time"
)
// APIKey represents an application API key.
// We need to use a pointer for LastUsedAt,
// as the value obtained from the database might sometimes be nil.
type APIKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
Description string `json:"description"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
}
// APIKeys represents a collection of API Key.
type APIKeys []APIKey
// APIKeyCreationRequest represents the request to create a new API Key.
type APIKeyCreationRequest struct {
Description string `json:"description"`
}
v2-2.3.0/internal/model/categories_sort_options.go 0000664 0000000 0000000 00000000556 15201231005 0022250 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
func CategoriesSortingOptions() map[string]string {
return map[string]string{
"unread_count": "form.prefs.select.unread_count",
"alphabetical": "form.prefs.select.alphabetical",
}
}
v2-2.3.0/internal/model/category.go 0000664 0000000 0000000 00000002270 15201231005 0017111 0 ustar 00root root 0000000 0000000 // 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"
// Category represents a feed category.
type Category struct {
ID int64 `json:"id"`
Title string `json:"title"`
UserID int64 `json:"user_id"`
HideGlobally bool `json:"hide_globally"`
// Pointers are needed to avoid breaking /v1/categories?counts=true
FeedCount *int `json:"feed_count,omitempty"`
TotalUnread *int `json:"total_unread,omitempty"`
}
func (c *Category) String() string {
return fmt.Sprintf("ID=%d, UserID=%d, Title=%s", c.ID, c.UserID, c.Title)
}
type CategoryCreationRequest struct {
Title string `json:"title"`
HideGlobally bool `json:"hide_globally"`
}
type CategoryModificationRequest struct {
Title *string `json:"title"`
HideGlobally *bool `json:"hide_globally"`
}
func (c *CategoryModificationRequest) Patch(category *Category) {
if c.Title != nil {
category.Title = *c.Title
}
if c.HideGlobally != nil {
category.HideGlobally = *c.HideGlobally
}
}
// Categories represents a list of categories.
type Categories []Category
v2-2.3.0/internal/model/enclosure.go 0000664 0000000 0000000 00000005104 15201231005 0017272 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"strings"
"miniflux.app/v2/internal/mediaproxy"
)
// Enclosure represents an attachment.
type Enclosure struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
EntryID int64 `json:"entry_id"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
MediaProgression int64 `json:"media_progression"`
}
type EnclosureUpdateRequest struct {
MediaProgression int64 `json:"media_progression"`
}
// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
func (e *Enclosure) Html5MimeType() string {
if e.MimeType == "video/m4v" {
return "video/x-m4v"
}
return e.MimeType
}
func (e *Enclosure) IsAudio() bool {
return strings.HasPrefix(strings.ToLower(e.MimeType), "audio/")
}
func (e *Enclosure) IsVideo() bool {
return strings.HasPrefix(strings.ToLower(e.MimeType), "video/")
}
func (e *Enclosure) IsImage() bool {
mimeType := strings.ToLower(e.MimeType)
if strings.HasPrefix(mimeType, "image/") {
return true
}
mediaURL := strings.ToLower(e.URL)
return strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif")
}
// ProxifyEnclosureURL modifies the enclosure URL to use the media proxy if necessary.
func (e *Enclosure) ProxifyEnclosureURL(mediaProxyOption string, mediaProxyResourceTypes []string) {
if mediaproxy.ShouldProxifyURLWithMimeType(e.URL, e.MimeType, mediaProxyOption, mediaProxyResourceTypes) {
e.URL = mediaproxy.ProxifyAbsoluteURL(e.URL)
}
}
// EnclosureList represents a list of attachments.
type EnclosureList []*Enclosure
// FindMediaPlayerEnclosure returns the first enclosure that can be played by a media player.
func (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {
for _, enclosure := range el {
if enclosure.URL != "" {
if enclosure.IsAudio() || enclosure.IsVideo() {
return enclosure
}
}
}
return nil
}
func (el EnclosureList) ContainsAudioOrVideo() bool {
for _, enclosure := range el {
if enclosure.IsAudio() || enclosure.IsVideo() {
return true
}
}
return false
}
func (el EnclosureList) ProxifyEnclosureURL(mediaProxyOption string, mediaProxyResourceTypes []string) {
for _, enclosure := range el {
enclosure.ProxifyEnclosureURL(mediaProxyOption, mediaProxyResourceTypes)
}
}
v2-2.3.0/internal/model/enclosure_test.go 0000664 0000000 0000000 00000044176 15201231005 0020345 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model
import (
"os"
"testing"
"miniflux.app/v2/internal/config"
)
func TestEnclosure_Html5MimeTypeGivesOriginalMimeType(t *testing.T) {
enclosure := Enclosure{MimeType: "thing/thisMimeTypeIsNotExpectedToBeReplaced"}
if enclosure.Html5MimeType() != enclosure.MimeType {
t.Fatalf(
"HTML5 MimeType must provide original MimeType if not explicitly Replaced. Got %s ,expected '%s' ",
enclosure.Html5MimeType(),
enclosure.MimeType,
)
}
}
func TestEnclosure_Html5MimeTypeReplaceStandardM4vByAppleSpecificMimeType(t *testing.T) {
enclosure := Enclosure{MimeType: "video/m4v"}
if enclosure.Html5MimeType() != "video/x-m4v" {
// Solution from this stackoverflow discussion:
// https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470
// tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed
// https://www.florenceporcel.com/podcast/lfhdu.xml
t.Fatalf(
"HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in browsers. Got '%s'",
enclosure.Html5MimeType(),
)
}
}
func TestEnclosure_IsAudio(t *testing.T) {
testCases := []struct {
name string
mimeType string
expected bool
}{
{"MP3 audio", "audio/mpeg", true},
{"WAV audio", "audio/wav", true},
{"OGG audio", "audio/ogg", true},
{"Mixed case audio", "Audio/MP3", true},
{"Video file", "video/mp4", false},
{"Image file", "image/jpeg", false},
{"Text file", "text/plain", false},
{"Empty mime type", "", false},
{"Audio with extra info", "audio/mpeg; charset=utf-8", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
enclosure := &Enclosure{MimeType: tc.mimeType}
if got := enclosure.IsAudio(); got != tc.expected {
t.Errorf("IsAudio() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType)
}
})
}
}
func TestEnclosure_IsVideo(t *testing.T) {
testCases := []struct {
name string
mimeType string
expected bool
}{
{"MP4 video", "video/mp4", true},
{"AVI video", "video/avi", true},
{"WebM video", "video/webm", true},
{"M4V video", "video/m4v", true},
{"Mixed case video", "Video/MP4", true},
{"Audio file", "audio/mpeg", false},
{"Image file", "image/jpeg", false},
{"Text file", "text/plain", false},
{"Empty mime type", "", false},
{"Video with extra info", "video/mp4; codecs=\"avc1.42E01E\"", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
enclosure := &Enclosure{MimeType: tc.mimeType}
if got := enclosure.IsVideo(); got != tc.expected {
t.Errorf("IsVideo() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType)
}
})
}
}
func TestEnclosure_IsImage(t *testing.T) {
testCases := []struct {
name string
mimeType string
url string
expected bool
}{
{"JPEG image by mime", "image/jpeg", "http://example.com/file", true},
{"PNG image by mime", "image/png", "http://example.com/file", true},
{"GIF image by mime", "image/gif", "http://example.com/file", true},
{"Mixed case image mime", "Image/JPEG", "http://example.com/file", true},
{"JPG file extension", "application/octet-stream", "http://example.com/photo.jpg", true},
{"JPEG file extension", "text/plain", "http://example.com/photo.jpeg", true},
{"PNG file extension", "unknown/type", "http://example.com/photo.png", true},
{"GIF file extension", "binary/data", "http://example.com/photo.gif", true},
{"Mixed case extension", "text/plain", "http://example.com/photo.JPG", true},
{"Image mime and extension", "image/jpeg", "http://example.com/photo.jpg", true},
{"Video file", "video/mp4", "http://example.com/video.mp4", false},
{"Audio file", "audio/mpeg", "http://example.com/audio.mp3", false},
{"Text file", "text/plain", "http://example.com/file.txt", false},
{"No extension", "text/plain", "http://example.com/file", false},
{"Other extension", "text/plain", "http://example.com/file.pdf", false},
{"Empty values", "", "", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
enclosure := &Enclosure{MimeType: tc.mimeType, URL: tc.url}
if got := enclosure.IsImage(); got != tc.expected {
t.Errorf("IsImage() = %v, want %v for mime type %s and URL %s", got, tc.expected, tc.mimeType, tc.url)
}
})
}
}
func TestEnclosureList_FindMediaPlayerEnclosure(t *testing.T) {
testCases := []struct {
name string
enclosures EnclosureList
expectedNil bool
}{
{
name: "Returns first audio enclosure",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
},
expectedNil: false,
},
{
name: "Returns first video enclosure",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
},
expectedNil: false,
},
{
name: "Skips image enclosure and returns audio",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
},
expectedNil: false,
},
{
name: "Skips enclosure with empty URL",
enclosures: EnclosureList{
&Enclosure{URL: "", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
},
expectedNil: false,
},
{
name: "Returns nil for no media enclosures",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
&Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"},
},
expectedNil: true,
},
{
name: "Returns nil for empty list",
enclosures: EnclosureList{},
expectedNil: true,
},
{
name: "Returns nil for all empty URLs",
enclosures: EnclosureList{
&Enclosure{URL: "", MimeType: "audio/mpeg"},
&Enclosure{URL: "", MimeType: "video/mp4"},
},
expectedNil: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := tc.enclosures.FindMediaPlayerEnclosure()
if tc.expectedNil {
if result != nil {
t.Errorf("FindMediaPlayerEnclosure() = %v, want nil", result)
}
} else {
if result == nil {
t.Errorf("FindMediaPlayerEnclosure() = nil, want non-nil")
} else if !result.IsAudio() && !result.IsVideo() {
t.Errorf("FindMediaPlayerEnclosure() returned non-media enclosure: %s", result.MimeType)
}
}
})
}
}
func TestEnclosureList_ContainsAudioOrVideo(t *testing.T) {
testCases := []struct {
name string
enclosures EnclosureList
expected bool
}{
{
name: "Contains audio",
enclosures: EnclosureList{
&Enclosure{MimeType: "audio/mpeg"},
&Enclosure{MimeType: "image/jpeg"},
},
expected: true,
},
{
name: "Contains video",
enclosures: EnclosureList{
&Enclosure{MimeType: "image/jpeg"},
&Enclosure{MimeType: "video/mp4"},
},
expected: true,
},
{
name: "Contains both audio and video",
enclosures: EnclosureList{
&Enclosure{MimeType: "audio/mpeg"},
&Enclosure{MimeType: "video/mp4"},
},
expected: true,
},
{
name: "Contains only images",
enclosures: EnclosureList{
&Enclosure{MimeType: "image/jpeg"},
&Enclosure{MimeType: "image/png"},
},
expected: false,
},
{
name: "Contains only documents",
enclosures: EnclosureList{
&Enclosure{MimeType: "application/pdf"},
&Enclosure{MimeType: "text/plain"},
},
expected: false,
},
{
name: "Empty list",
enclosures: EnclosureList{},
expected: false,
},
{
name: "Single audio enclosure",
enclosures: EnclosureList{
&Enclosure{MimeType: "audio/wav"},
},
expected: true,
},
{
name: "Single video enclosure",
enclosures: EnclosureList{
&Enclosure{MimeType: "video/webm"},
},
expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := tc.enclosures.ContainsAudioOrVideo()
if result != tc.expected {
t.Errorf("ContainsAudioOrVideo() = %v, want %v", result, tc.expected)
}
})
}
}
func TestEnclosure_ProxifyEnclosureURL(t *testing.T) {
// Initialize config for testing
os.Clearenv()
os.Setenv("BASE_URL", "http://localhost")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Config parsing failure: %v`, err)
}
testCases := []struct {
name string
url string
mimeType string
mediaProxyOption string
mediaProxyResourceTypes []string
expectedURLChanged bool
}{
{
name: "HTTP URL with audio type - proxy mode all",
url: "http://example.com/audio.mp3",
mimeType: "audio/mpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: true,
},
{
name: "HTTPS URL with video type - proxy mode all",
url: "https://example.com/video.mp4",
mimeType: "video/mp4",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: true,
},
{
name: "HTTP URL with video type - proxy mode http-only",
url: "http://example.com/video.mp4",
mimeType: "video/mp4",
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: true,
},
{
name: "HTTPS URL with video type - proxy mode http-only",
url: "https://example.com/video.mp4",
mimeType: "video/mp4",
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
{
name: "HTTP URL with image type - not in resource types",
url: "http://example.com/image.jpg",
mimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
{
name: "HTTP URL with image type - in resource types",
url: "http://example.com/image.jpg",
mimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video", "image"},
expectedURLChanged: true,
},
{
name: "HTTP URL - proxy mode none",
url: "http://example.com/audio.mp3",
mimeType: "audio/mpeg",
mediaProxyOption: "none",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
{
name: "Empty URL",
url: "",
mimeType: "audio/mpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
{
name: "Non-media MIME type",
url: "http://example.com/doc.pdf",
mimeType: "application/pdf",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
enclosure := &Enclosure{
URL: tc.url,
MimeType: tc.mimeType,
}
originalURL := enclosure.URL
// Call the method
enclosure.ProxifyEnclosureURL(tc.mediaProxyOption, tc.mediaProxyResourceTypes)
// Check if URL changed as expected
urlChanged := enclosure.URL != originalURL
if urlChanged != tc.expectedURLChanged {
t.Errorf("ProxifyEnclosureURL() URL changed = %v, want %v. Original: %s, New: %s",
urlChanged, tc.expectedURLChanged, originalURL, enclosure.URL)
}
// If URL should have changed, verify it's not empty
if tc.expectedURLChanged && enclosure.URL == "" {
t.Error("ProxifyEnclosureURL() resulted in empty URL when proxification was expected")
}
// If URL shouldn't have changed, verify it's identical
if !tc.expectedURLChanged && enclosure.URL != originalURL {
t.Errorf("ProxifyEnclosureURL() URL changed unexpectedly from %s to %s", originalURL, enclosure.URL)
}
})
}
}
func TestEnclosureList_ProxifyEnclosureURL(t *testing.T) {
// Initialize config for testing
os.Clearenv()
os.Setenv("BASE_URL", "http://localhost")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Config parsing failure: %v`, err)
}
testCases := []struct {
name string
enclosures EnclosureList
mediaProxyOption string
mediaProxyResourceTypes []string
expectedChangedCount int
}{
{
name: "Mixed enclosures with all proxy mode",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"},
&Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
&Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"},
},
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 2, // audio and video should be proxified
},
{
name: "Mixed enclosures with http-only proxy mode",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"},
&Enclosure{URL: "http://example.com/video2.mp4", MimeType: "video/mp4"},
},
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 2, // only HTTP URLs should be proxified
},
{
name: "No media types in resource list",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
},
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expectedChangedCount: 0, // no matching resource types
},
{
name: "Proxy mode none",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
},
mediaProxyOption: "none",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 0,
},
{
name: "Empty enclosure list",
enclosures: EnclosureList{},
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 0,
},
{
name: "Enclosures with empty URLs",
enclosures: EnclosureList{
&Enclosure{URL: "", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
},
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 1, // only the non-empty URL should be processed
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Store original URLs
originalURLs := make([]string, len(tc.enclosures))
for i, enclosure := range tc.enclosures {
originalURLs[i] = enclosure.URL
}
// Call the method
tc.enclosures.ProxifyEnclosureURL(tc.mediaProxyOption, tc.mediaProxyResourceTypes)
// Count how many URLs actually changed
changedCount := 0
for i, enclosure := range tc.enclosures {
if enclosure.URL != originalURLs[i] {
changedCount++
// Verify that changed URLs are not empty (unless they were empty originally)
if originalURLs[i] != "" && enclosure.URL == "" {
t.Errorf("Enclosure %d: ProxifyEnclosureURL resulted in empty URL", i)
}
}
}
if changedCount != tc.expectedChangedCount {
t.Errorf("ProxifyEnclosureURL() changed %d URLs, want %d", changedCount, tc.expectedChangedCount)
}
})
}
}
func TestEnclosure_ProxifyEnclosureURL_EdgeCases(t *testing.T) {
// Initialize config for testing
os.Clearenv()
os.Setenv("BASE_URL", "http://localhost")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Config parsing failure: %v`, err)
}
t.Run("Empty resource types slice", func(t *testing.T) {
enclosure := &Enclosure{
URL: "http://example.com/audio.mp3",
MimeType: "audio/mpeg",
}
originalURL := enclosure.URL
enclosure.ProxifyEnclosureURL("all", []string{})
// With empty resource types, URL should not change
if enclosure.URL != originalURL {
t.Errorf("URL should not change with empty resource types. Original: %s, New: %s", originalURL, enclosure.URL)
}
})
t.Run("Nil resource types slice", func(t *testing.T) {
enclosure := &Enclosure{
URL: "http://example.com/audio.mp3",
MimeType: "audio/mpeg",
}
originalURL := enclosure.URL
enclosure.ProxifyEnclosureURL("all", nil)
// With nil resource types, URL should not change
if enclosure.URL != originalURL {
t.Errorf("URL should not change with nil resource types. Original: %s, New: %s", originalURL, enclosure.URL)
}
})
t.Run("Invalid proxy mode", func(t *testing.T) {
enclosure := &Enclosure{
URL: "http://example.com/audio.mp3",
MimeType: "audio/mpeg",
}
originalURL := enclosure.URL
enclosure.ProxifyEnclosureURL("invalid-mode", []string{"audio"})
// With invalid proxy mode, the function still proxifies non-HTTPS URLs
// because shouldProxifyURL defaults to checking URL scheme
if enclosure.URL == originalURL {
t.Errorf("URL should change for HTTP URL even with invalid proxy mode. Original: %s, New: %s", originalURL, enclosure.URL)
}
})
}
v2-2.3.0/internal/model/entry.go 0000664 0000000 0000000 00000005521 15201231005 0016437 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"time"
)
// Entry statuses and default sorting order.
const (
EntryStatusUnread = "unread"
EntryStatusRead = "read"
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"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Status string `json:"status"`
Hash string `json:"hash"`
Title string `json:"title"`
URL string `json:"url"`
CommentsURL string `json:"comments_url"`
Date time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
ChangedAt time.Time `json:"changed_at"`
Content string `json:"content"`
Author string `json:"author"`
ShareCode string `json:"share_code"`
Starred bool `json:"starred"`
ReadingTime int `json:"reading_time"`
Enclosures EnclosureList `json:"enclosures"`
Feed *Feed `json:"feed,omitempty"`
Tags []string `json:"tags"`
}
func NewEntry() *Entry {
return &Entry{
Enclosures: make(EnclosureList, 0),
Tags: make([]string, 0),
Feed: &Feed{
Category: &Category{},
Icon: &FeedIcon{},
},
}
}
// ShouldMarkAsReadOnView Return whether the entry should be marked as viewed considering all user settings and entry state.
func (e *Entry) ShouldMarkAsReadOnView(user *User) bool {
// Already read, no need to mark as read again. Removed entries are not marked as read
if e.Status != EntryStatusUnread {
return false
}
// There is an enclosure, markAsRead will happen at enclosure completion time, no need to mark as read on view
if user.MarkReadOnMediaPlayerCompletion && e.Enclosures.ContainsAudioOrVideo() {
return false
}
// The user wants to mark as read on view
return user.MarkReadOnView
}
// Entries represents a list of entries.
type Entries []*Entry
// EntriesStatusUpdateRequest represents a request to change entries status.
type EntriesStatusUpdateRequest struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
}
// EntryUpdateRequest represents a request to update an entry.
type EntryUpdateRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
}
func (e *EntryUpdateRequest) Patch(entry *Entry) {
if e.Title != nil && *e.Title != "" {
entry.Title = *e.Title
}
if e.Content != nil && *e.Content != "" {
entry.Content = *e.Content
}
}
v2-2.3.0/internal/model/feed.go 0000664 0000000 0000000 00000025650 15201231005 0016206 0 ustar 00root root 0000000 0000000 // 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"
"io"
"time"
"miniflux.app/v2/internal/config"
)
// List of supported schedulers.
const (
SchedulerRoundRobin = "round_robin"
SchedulerEntryFrequency = "entry_frequency"
// Default settings for the feed query builder
DefaultFeedSorting = "parsing_error_count"
DefaultFeedSortingDirection = "desc"
)
// Feed represents a feed in the application.
type Feed struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
Description string `json:"description"`
CheckedAt time.Time `json:"checked_at"`
NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header"`
LastModifiedHeader string `json:"last_modified_header"`
ParsingErrorMsg string `json:"parsing_error_message"`
ParsingErrorCount int `json:"parsing_error_count"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
PushoverEnabled bool `json:"pushover_enabled"`
NtfyEnabled bool `json:"ntfy_enabled"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
AppriseServiceURLs string `json:"apprise_service_urls"`
WebhookURL string `json:"webhook_url"`
NtfyPriority int `json:"ntfy_priority"`
NtfyTopic string `json:"ntfy_topic"`
PushoverPriority int `json:"pushover_priority"`
ProxyURL string `json:"proxy_url"`
// Non-persisted attributes
Category *Category `json:"category,omitempty"`
Icon *FeedIcon `json:"icon"`
Entries Entries `json:"entries,omitempty"`
// Internal attributes (not exposed in the API and not persisted in the database)
TTL time.Duration `json:"-"`
IconURL string `json:"-"`
UnreadCount int `json:"-"`
ReadCount int `json:"-"`
NumberOfVisibleEntries int `json:"-"`
}
type FeedCounters struct {
ReadCounters map[int64]int `json:"reads"`
UnreadCounters map[int64]int `json:"unreads"`
}
func (f *Feed) String() string {
return fmt.Sprintf("ID=%d, UserID=%d, FeedURL=%s, SiteURL=%s, Title=%s, Category={%s}",
f.ID,
f.UserID,
f.FeedURL,
f.SiteURL,
f.Title,
f.Category,
)
}
// WithCategoryID initializes the category attribute of the feed.
func (f *Feed) WithCategoryID(categoryID int64) {
f.Category = &Category{ID: categoryID}
}
// WithTranslatedErrorMessage adds a new error message and increment the error counter.
func (f *Feed) WithTranslatedErrorMessage(message string) {
f.ParsingErrorCount++
f.ParsingErrorMsg = message
}
// ResetErrorCounter removes all previous errors.
func (f *Feed) ResetErrorCounter() {
f.ParsingErrorCount = 0
f.ParsingErrorMsg = ""
}
// CheckedNow set attribute values when the feed is refreshed.
func (f *Feed) CheckedNow() {
f.CheckedAt = time.Now()
if f.SiteURL == "" {
f.SiteURL = f.FeedURL
}
}
// ScheduleNextCheck set "next_check_at" of a feed based on the scheduler selected from the configuration.
func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelay time.Duration) time.Duration {
// Default to the global config Polling Frequency.
interval := config.Opts.SchedulerRoundRobinMinInterval()
if config.Opts.PollingScheduler() == SchedulerEntryFrequency {
if weeklyCount <= 0 {
interval = config.Opts.SchedulerEntryFrequencyMaxInterval()
} else {
interval = (7 * 24 * time.Hour) / time.Duration(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())
interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval())
interval = max(interval, config.Opts.SchedulerEntryFrequencyMinInterval())
}
}
// Use the RSS TTL field, Retry-After, Cache-Control or Expires HTTP headers if defined.
interval = max(interval, refreshDelay)
// Limit the max interval value for misconfigured feeds.
switch config.Opts.PollingScheduler() {
case SchedulerRoundRobin:
interval = min(interval, config.Opts.SchedulerRoundRobinMaxInterval())
case SchedulerEntryFrequency:
interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval())
}
f.NextCheckAt = time.Now().Add(interval)
return interval
}
// FeedCreationRequest represents the request to create a feed.
type FeedCreationRequest struct {
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
ProxyURL string `json:"proxy_url"`
}
type FeedCreationRequestFromSubscriptionDiscovery struct {
Content io.ReadSeeker
ETag string
LastModified string
FeedCreationRequest
}
// FeedModificationRequest represents the request to update a feed.
type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
Description *string `json:"description"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
UrlRewriteRules *string `json:"urlrewrite_rules"`
KeeplistRules *string `json:"keeplist_rules"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
IgnoreEntryUpdates *bool `json:"ignore_entry_updates"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
Username *string `json:"username"`
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
Disabled *bool `json:"disabled"`
NoMediaPlayer *bool `json:"no_media_player"`
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
HideGlobally *bool `json:"hide_globally"`
DisableHTTP2 *bool `json:"disable_http2"`
ProxyURL *string `json:"proxy_url"`
}
// Patch updates a feed with modified values.
func (f *FeedModificationRequest) Patch(feed *Feed) {
if f.FeedURL != nil && *f.FeedURL != "" {
feed.FeedURL = *f.FeedURL
}
if f.SiteURL != nil && *f.SiteURL != "" {
feed.SiteURL = *f.SiteURL
}
if f.Title != nil && *f.Title != "" {
feed.Title = *f.Title
}
if f.Description != nil && *f.Description != "" {
feed.Description = *f.Description
}
if f.ScraperRules != nil {
feed.ScraperRules = *f.ScraperRules
}
if f.RewriteRules != nil {
feed.RewriteRules = *f.RewriteRules
}
if f.UrlRewriteRules != nil {
feed.UrlRewriteRules = *f.UrlRewriteRules
}
if f.KeeplistRules != nil {
feed.KeeplistRules = *f.KeeplistRules
}
if f.BlocklistRules != nil {
feed.BlocklistRules = *f.BlocklistRules
}
if f.BlockFilterEntryRules != nil {
feed.BlockFilterEntryRules = *f.BlockFilterEntryRules
}
if f.KeepFilterEntryRules != nil {
feed.KeepFilterEntryRules = *f.KeepFilterEntryRules
}
if f.Crawler != nil {
feed.Crawler = *f.Crawler
}
if f.IgnoreEntryUpdates != nil {
feed.IgnoreEntryUpdates = *f.IgnoreEntryUpdates
}
if f.UserAgent != nil {
feed.UserAgent = *f.UserAgent
}
if f.Cookie != nil {
feed.Cookie = *f.Cookie
}
if f.Username != nil {
feed.Username = *f.Username
}
if f.Password != nil {
feed.Password = *f.Password
}
if f.CategoryID != nil && *f.CategoryID > 0 {
feed.Category.ID = *f.CategoryID
}
if f.Disabled != nil {
feed.Disabled = *f.Disabled
}
if f.NoMediaPlayer != nil {
feed.NoMediaPlayer = *f.NoMediaPlayer
}
if f.IgnoreHTTPCache != nil {
feed.IgnoreHTTPCache = *f.IgnoreHTTPCache
}
if f.AllowSelfSignedCertificates != nil {
feed.AllowSelfSignedCertificates = *f.AllowSelfSignedCertificates
}
if f.FetchViaProxy != nil {
feed.FetchViaProxy = *f.FetchViaProxy
}
if f.HideGlobally != nil {
feed.HideGlobally = *f.HideGlobally
}
if f.DisableHTTP2 != nil {
feed.DisableHTTP2 = *f.DisableHTTP2
}
if f.ProxyURL != nil {
feed.ProxyURL = *f.ProxyURL
}
}
// Feeds is a list of feed
type Feeds []*Feed
v2-2.3.0/internal/model/feed_test.go 0000664 0000000 0000000 00000025422 15201231005 0017242 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"os"
"strconv"
"testing"
"time"
"miniflux.app/v2/internal/config"
)
const (
largeWeeklyCount = 10080
noRefreshDelay = 0
)
func TestFeedCategorySetter(t *testing.T) {
feed := &Feed{}
feed.WithCategoryID(int64(123))
if feed.Category == nil {
t.Fatal(`The category field should not be null`)
}
if feed.Category.ID != int64(123) {
t.Error(`The category ID must be set`)
}
}
func TestFeedErrorCounter(t *testing.T) {
feed := &Feed{}
feed.WithTranslatedErrorMessage("Some Error")
if feed.ParsingErrorMsg != "Some Error" {
t.Error(`The error message must be set`)
}
if feed.ParsingErrorCount != 1 {
t.Error(`The error counter must be set to 1`)
}
feed.ResetErrorCounter()
if feed.ParsingErrorMsg != "" {
t.Error(`The error message must be removed`)
}
if feed.ParsingErrorCount != 0 {
t.Error(`The error counter must be set to 0`)
}
}
func TestFeedCheckedNow(t *testing.T) {
feed := &Feed{}
feed.FeedURL = "https://example.org/feed"
feed.CheckedNow()
if feed.SiteURL != feed.FeedURL {
t.Error(`The site URL must not be empty`)
}
if feed.CheckedAt.IsZero() {
t.Error(`The checked date must be set`)
}
}
func checkTargetInterval(t *testing.T, feed *Feed, targetInterval time.Duration, timeBefore time.Time, message string) {
if feed.NextCheckAt.Before(timeBefore.Add(targetInterval)) {
t.Errorf(`The next_check_at should be after timeBefore + %s`, message)
}
if feed.NextCheckAt.After(time.Now().Add(targetInterval)) {
t.Errorf(`The next_check_at should be before now + %s`, message)
}
}
func TestFeedScheduleNextCheckRoundRobinDefault(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := config.Opts.SchedulerRoundRobinMinInterval()
checkTargetInterval(t, feed, targetInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinDefault")
}
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()+30)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
expectedInterval := config.Opts.SchedulerRoundRobinMinInterval() + 30
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval")
}
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()-30)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
expectedInterval := config.Opts.SchedulerRoundRobinMinInterval()
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval")
}
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMaxInterval(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMaxInterval()+30)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
expectedInterval := config.Opts.SchedulerRoundRobinMaxInterval()
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMaxInterval")
}
func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
minInterval := 1
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "round_robin")
os.Setenv("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
expectedInterval := time.Duration(minInterval) * time.Minute
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinMinInterval")
}
func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
maxInterval := 5
minInterval := 1
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very small weekly count to trigger the max interval
weeklyCount := 1
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := time.Duration(maxInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency max interval")
}
func TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testing.T) {
maxInterval := 5
minInterval := 1
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very small weekly count to trigger the max interval
weeklyCount := 0
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := time.Duration(maxInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency max interval")
}
func TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {
maxInterval := 500
minInterval := 100
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := time.Duration(minInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval")
}
func TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {
factor := 2
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_FACTOR", strconv.Itoa(factor))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
weeklyCount := 7
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := config.Opts.SchedulerEntryFrequencyMaxInterval() / time.Duration(factor)
checkTargetInterval(t, feed, targetInterval, timeBefore, "factor * count")
}
func TestFeedScheduleNextCheckEntryFrequencySmallNewTTL(t *testing.T) {
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
maxInterval := 500
minInterval := 100
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
// TTL is smaller than minInterval.
newTTL := time.Duration(minInterval) * time.Minute / 2
feed.ScheduleNextCheck(weeklyCount, newTTL)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := time.Duration(minInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval")
if feed.NextCheckAt.Before(timeBefore.Add(newTTL)) {
t.Error(`The next_check_at should be after timeBefore + TTL`)
}
}
func TestFeedScheduleNextCheckEntryFrequencyLargeNewTTL(t *testing.T) {
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
maxInterval := 500
minInterval := 100
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
// TTL is larger than minInterval.
newTTL := time.Duration(minInterval) * time.Minute * 2
feed.ScheduleNextCheck(weeklyCount, newTTL)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := newTTL
checkTargetInterval(t, feed, targetInterval, timeBefore, "TTL")
if feed.NextCheckAt.Before(timeBefore.Add(time.Minute * time.Duration(minInterval))) {
t.Error(`The next_check_at should be after timeBefore + entry frequency min interval`)
}
}
v2-2.3.0/internal/model/home_page.go 0000664 0000000 0000000 00000000716 15201231005 0017223 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
// HomePages returns the list of available home pages.
func HomePages() map[string]string {
return map[string]string{
"unread": "menu.unread",
"starred": "menu.starred",
"history": "menu.history",
"feeds": "menu.feeds",
"categories": "menu.categories",
}
}
v2-2.3.0/internal/model/icon.go 0000664 0000000 0000000 00000001554 15201231005 0016230 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"encoding/base64"
)
// Icon represents a website icon (favicon)
type Icon struct {
ID int64 `json:"id"`
Hash string `json:"hash"`
MimeType string `json:"mime_type"`
Content []byte `json:"-"`
ExternalID string `json:"external_id"`
}
// DataURL returns the data URL of the icon.
func (i *Icon) DataURL() string {
return i.MimeType + ";base64," + base64.StdEncoding.EncodeToString(i.Content)
}
// Icons represents a list of icons.
type Icons []*Icon
// FeedIcon is a junction table between feeds and icons.
type FeedIcon struct {
FeedID int64 `json:"feed_id"`
IconID int64 `json:"icon_id"`
ExternalIconID string `json:"external_icon_id"`
}
v2-2.3.0/internal/model/integration.go 0000664 0000000 0000000 00000012070 15201231005 0017616 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
// Integration represents user integration settings.
type Integration struct {
UserID int64
BetulaEnabled bool
BetulaURL string
BetulaToken string
PinboardEnabled bool
PinboardToken string
PinboardTags string
PinboardMarkAsUnread bool
InstapaperEnabled bool
InstapaperUsername string
InstapaperPassword string
FeverEnabled bool
FeverUsername string
FeverToken string
GoogleReaderEnabled bool
GoogleReaderUsername string
GoogleReaderPassword string
WallabagEnabled bool
WallabagOnlyURL bool
WallabagURL string
WallabagClientID string
WallabagClientSecret string
WallabagUsername string
WallabagPassword string
WallabagTags string
NunuxKeeperEnabled bool
NunuxKeeperURL string
NunuxKeeperAPIKey string
NotionEnabled bool
NotionToken string
NotionPageID string
EspialEnabled bool
EspialURL string
EspialAPIKey string
EspialTags string
ReadwiseEnabled bool
ReadwiseAPIKey string
TelegramBotEnabled bool
TelegramBotToken string
TelegramBotChatID string
TelegramBotTopicID *int64
TelegramBotDisableWebPagePreview bool
TelegramBotDisableNotification bool
TelegramBotDisableButtons bool
LinkAceEnabled bool
LinkAceURL string
LinkAceAPIKey string
LinkAceTags string
LinkAcePrivate bool
LinkAceCheckDisabled bool
LinkdingEnabled bool
LinkdingURL string
LinkdingAPIKey string
LinkdingTags string
LinkdingMarkAsUnread bool
LinktacoEnabled bool
LinktacoAPIToken string
LinktacoOrgSlug string
LinktacoTags string
LinktacoVisibility string
LinkwardenEnabled bool
LinkwardenURL string
LinkwardenAPIKey string
LinkwardenCollectionID *int64
MatrixBotEnabled bool
MatrixBotUser string
MatrixBotPassword string
MatrixBotURL string
MatrixBotChatID string
AppriseEnabled bool
AppriseURL string
AppriseServicesURL string
ReadeckEnabled bool
ReadeckPushEnabled bool
ReadeckURL string
ReadeckAPIKey string
ReadeckLabels string
ReadeckOnlyURL bool
ShioriEnabled bool
ShioriURL string
ShioriUsername string
ShioriPassword string
ShaarliEnabled bool
ShaarliURL string
ShaarliAPISecret string
WebhookEnabled bool
WebhookURL string
WebhookSecret string
RSSBridgeEnabled bool
RSSBridgeURL string
RSSBridgeToken string
OmnivoreEnabled bool
OmnivoreAPIKey string
OmnivoreURL string
KarakeepEnabled bool
KarakeepAPIKey string
KarakeepURL string
KarakeepTags string
RaindropEnabled bool
RaindropToken string
RaindropCollectionID string
RaindropTags string
NtfyEnabled bool
NtfyTopic string
NtfyURL string
NtfyAPIToken string
NtfyUsername string
NtfyPassword string
NtfyIconURL string
NtfyInternalLinks bool
CuboxEnabled bool
CuboxAPILink string
DiscordEnabled bool
DiscordWebhookLink string
SlackEnabled bool
SlackWebhookLink string
PushoverEnabled bool
PushoverUser string
PushoverToken string
PushoverDevice string
PushoverPrefix string
ArchiveorgEnabled bool
}
v2-2.3.0/internal/model/job.go 0000664 0000000 0000000 00000001234 15201231005 0016045 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
// Job represents a payload sent to the processing queue.
type Job struct {
UserID int64
FeedID int64
FeedURL string
}
// JobList represents a list of jobs.
type JobList []Job
// FeedURLs returns a list of feed URLs from the job list.
// This is useful for logging or debugging purposes to see which feeds are being processed.
func (jl *JobList) FeedURLs() []string {
feedURLs := make([]string, len(*jl))
for i, job := range *jl {
feedURLs[i] = job.FeedURL
}
return feedURLs
}
v2-2.3.0/internal/model/model.go 0000664 0000000 0000000 00000000762 15201231005 0016400 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
type Number interface {
int | int64 | float64
}
func OptionalNumber[T Number](value T) *T {
if value > 0 {
return &value
}
return nil
}
func OptionalString(value string) *string {
if value != "" {
return &value
}
return nil
}
//go:fix inline
func SetOptionalField[T any](value T) *T {
return new(value)
}
v2-2.3.0/internal/model/subscription.go 0000664 0000000 0000000 00000001463 15201231005 0020023 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
// SubscriptionDiscoveryRequest represents a request to discover subscriptions.
type SubscriptionDiscoveryRequest struct {
URL string `json:"url"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
ProxyURL string `json:"proxy_url"`
FetchViaProxy bool `json:"fetch_via_proxy"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
DisableHTTP2 bool `json:"disable_http2"`
}
v2-2.3.0/internal/model/theme.go 0000664 0000000 0000000 00000002133 15201231005 0016374 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
// Themes returns the list of available themes.
func Themes() map[string]string {
return map[string]string{
"light_serif": "Light - Serif",
"light_sans_serif": "Light - Sans Serif",
"dark_serif": "Dark - Serif",
"dark_sans_serif": "Dark - Sans Serif",
"system_serif": "System - Serif",
"system_sans_serif": "System - Sans Serif",
}
}
// ThemeColor returns the color for the address bar or/and the browser color.
// https://developer.mozilla.org/en-US/docs/Web/Manifest#theme_color
// https://developers.google.com/web/tools/lighthouse/audits/address-bar
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name/theme-color
func ThemeColor(theme, colorScheme string) string {
switch theme {
case "dark_serif", "dark_sans_serif":
return "#222"
case "system_serif", "system_sans_serif":
if colorScheme == "dark" {
return "#222"
}
return "#fff"
default:
return "#fff"
}
}
v2-2.3.0/internal/model/user.go 0000664 0000000 0000000 00000017244 15201231005 0016261 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"time"
"miniflux.app/v2/internal/timezone"
)
// User represents a user in the system.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"`
Language string `json:"language"`
Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
CustomJS string `json:"custom_js"`
ExternalFontHosts string `json:"external_font_hosts"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"`
GestureNav string `json:"gesture_nav"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
DefaultReadingSpeed int `json:"default_reading_speed"`
CJKReadingSpeed int `json:"cjk_reading_speed"`
DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"`
MarkReadOnMediaPlayerCompletion bool `json:"mark_read_on_media_player_completion"`
MediaPlaybackRate float64 `json:"media_playback_rate"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab bool `json:"open_external_links_in_new_tab"`
}
// UserCreationRequest represents the request to create a user.
type UserCreationRequest struct {
Username string `json:"username"`
Password string `json:"password"`
IsAdmin bool `json:"is_admin"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
}
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
ExternalFontHosts *string `json:"external_font_hosts"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
IsAdmin *bool `json:"is_admin"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
MarkReadOnMediaPlayerCompletion *bool `json:"mark_read_on_media_player_completion"`
MediaPlaybackRate *float64 `json:"media_playback_rate"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab *bool `json:"open_external_links_in_new_tab"`
}
// Patch updates the User object with the modification request.
func (u *UserModificationRequest) Patch(user *User) {
if u.Username != nil {
user.Username = *u.Username
}
if u.Password != nil {
user.Password = *u.Password
}
if u.IsAdmin != nil {
user.IsAdmin = *u.IsAdmin
}
if u.Theme != nil {
user.Theme = *u.Theme
}
if u.Language != nil {
user.Language = *u.Language
}
if u.Timezone != nil {
user.Timezone = *u.Timezone
}
if u.EntryDirection != nil {
user.EntryDirection = *u.EntryDirection
}
if u.EntryOrder != nil {
user.EntryOrder = *u.EntryOrder
}
if u.Stylesheet != nil {
user.Stylesheet = *u.Stylesheet
}
if u.CustomJS != nil {
user.CustomJS = *u.CustomJS
}
if u.ExternalFontHosts != nil {
user.ExternalFontHosts = *u.ExternalFontHosts
}
if u.GoogleID != nil {
user.GoogleID = *u.GoogleID
}
if u.OpenIDConnectID != nil {
user.OpenIDConnectID = *u.OpenIDConnectID
}
if u.EntriesPerPage != nil {
user.EntriesPerPage = *u.EntriesPerPage
}
if u.KeyboardShortcuts != nil {
user.KeyboardShortcuts = *u.KeyboardShortcuts
}
if u.ShowReadingTime != nil {
user.ShowReadingTime = *u.ShowReadingTime
}
if u.EntrySwipe != nil {
user.EntrySwipe = *u.EntrySwipe
}
if u.GestureNav != nil {
user.GestureNav = *u.GestureNav
}
if u.DisplayMode != nil {
user.DisplayMode = *u.DisplayMode
}
if u.DefaultReadingSpeed != nil {
user.DefaultReadingSpeed = *u.DefaultReadingSpeed
}
if u.CJKReadingSpeed != nil {
user.CJKReadingSpeed = *u.CJKReadingSpeed
}
if u.DefaultHomePage != nil {
user.DefaultHomePage = *u.DefaultHomePage
}
if u.CategoriesSortingOrder != nil {
user.CategoriesSortingOrder = *u.CategoriesSortingOrder
}
if u.MarkReadOnView != nil {
user.MarkReadOnView = *u.MarkReadOnView
}
if u.MarkReadOnMediaPlayerCompletion != nil {
user.MarkReadOnMediaPlayerCompletion = *u.MarkReadOnMediaPlayerCompletion
}
if u.MediaPlaybackRate != nil {
user.MediaPlaybackRate = *u.MediaPlaybackRate
}
if u.BlockFilterEntryRules != nil {
user.BlockFilterEntryRules = *u.BlockFilterEntryRules
}
if u.KeepFilterEntryRules != nil {
user.KeepFilterEntryRules = *u.KeepFilterEntryRules
}
if u.AlwaysOpenExternalLinks != nil {
user.AlwaysOpenExternalLinks = *u.AlwaysOpenExternalLinks
}
if u.OpenExternalLinksInNewTab != nil {
user.OpenExternalLinksInNewTab = *u.OpenExternalLinksInNewTab
}
}
// UseTimezone converts last login date to the given timezone.
func (u *User) UseTimezone(tz string) {
if u.LastLoginAt != nil {
*u.LastLoginAt = timezone.Convert(tz, *u.LastLoginAt)
}
}
// Users represents a list of users.
type Users []*User
// UseTimezone converts last login timestamp of all users to the given timezone.
func (u Users) UseTimezone(tz string) {
for _, user := range u {
user.UseTimezone(tz)
}
}
v2-2.3.0/internal/model/web_session.go 0000664 0000000 0000000 00000017220 15201231005 0017615 0 ustar 00root root 0000000 0000000 // 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)
}
v2-2.3.0/internal/model/web_session_test.go 0000664 0000000 0000000 00000030620 15201231005 0020653 0 ustar 00root root 0000000 0000000 // 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")
}
}
v2-2.3.0/internal/model/webauthn.go 0000664 0000000 0000000 00000001210 15201231005 0017102 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"encoding/hex"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
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 {
return hex.EncodeToString(s.Handle)
}
v2-2.3.0/internal/oauth2/ 0000775 0000000 0000000 00000000000 15201231005 0015046 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/oauth2/authorization.go 0000664 0000000 0000000 00000003030 15201231005 0020271 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"crypto/sha256"
"encoding/base64"
"golang.org/x/oauth2"
"miniflux.app/v2/internal/crypto"
)
// Authorization holds the OAuth2 authorization URL, state parameter, and PKCE code verifier.
type Authorization struct {
url string
state string
codeVerifier string
}
// RedirectURL returns the OAuth2 authorization URL to redirect the user to.
func (a *Authorization) RedirectURL() string {
return a.url
}
// State returns the random state parameter used for CSRF protection.
func (a *Authorization) State() string {
return a.state
}
// CodeVerifier returns the PKCE code verifier associated with this authorization.
func (a *Authorization) CodeVerifier() string {
return a.codeVerifier
}
// GenerateAuthorization creates a new Authorization with a random state and PKCE code challenge
// derived from the given OAuth2 configuration.
func GenerateAuthorization(config *oauth2.Config) *Authorization {
codeVerifier := crypto.GenerateRandomStringHex(32)
sum := sha256.Sum256([]byte(codeVerifier))
state := crypto.GenerateRandomStringHex(24)
authURL := config.AuthCodeURL(
state,
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("code_challenge", base64.RawURLEncoding.EncodeToString(sum[:])),
)
return &Authorization{
url: authURL,
state: state,
codeVerifier: codeVerifier,
}
}
v2-2.3.0/internal/oauth2/google.go 0000664 0000000 0000000 00000005352 15201231005 0016656 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"context"
"encoding/json"
"fmt"
"net/http"
"miniflux.app/v2/internal/model"
"golang.org/x/oauth2"
)
// Google OAuth2 API documentation: https://developers.google.com/identity/protocols/oauth2
const (
googleAuthURL = "https://accounts.google.com/o/oauth2/v2/auth"
googleTokenURL = "https://oauth2.googleapis.com/token"
googleUserInfoURL = "https://www.googleapis.com/oauth2/v3/userinfo"
)
type googleProfile struct {
Sub string `json:"sub"`
Email string `json:"email"`
}
type googleProvider struct {
clientID string
clientSecret string
redirectURL string
}
// NewGoogleProvider returns a Provider that authenticates users via Google OAuth2.
func NewGoogleProvider(clientID, clientSecret, redirectURL string) Provider {
return &googleProvider{clientID: clientID, clientSecret: clientSecret, redirectURL: redirectURL}
}
func (g *googleProvider) Config() *oauth2.Config {
return &oauth2.Config{
RedirectURL: g.redirectURL,
ClientID: g.clientID,
ClientSecret: g.clientSecret,
Scopes: []string{"email"},
Endpoint: oauth2.Endpoint{
AuthURL: googleAuthURL,
TokenURL: googleTokenURL,
},
}
}
func (g *googleProvider) UserExtraKey() string {
return "google_id"
}
func (g *googleProvider) Profile(ctx context.Context, code, codeVerifier string) (*UserProfile, error) {
conf := g.Config()
token, err := conf.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier))
if err != nil {
return nil, fmt.Errorf("google: failed to exchange token: %w", err)
}
client := conf.Client(ctx, token)
resp, err := client.Get(googleUserInfoURL)
if err != nil {
return nil, fmt.Errorf("google: failed to get user info: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("google: unexpected status code %d from userinfo endpoint", resp.StatusCode)
}
var user googleProfile
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(&user); err != nil {
return nil, fmt.Errorf("google: unable to unserialize Google profile: %w", err)
}
return &UserProfile{Key: g.UserExtraKey(), ID: user.Sub, Username: user.Email}, nil
}
func (g *googleProvider) PopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *UserProfile) {
user.GoogleID = profile.ID
}
func (g *googleProvider) PopulateUserWithProfileID(user *model.User, profile *UserProfile) {
user.GoogleID = profile.ID
}
func (g *googleProvider) UserProfileID(user *model.User) string {
return user.GoogleID
}
func (g *googleProvider) UnsetUserProfileID(user *model.User) {
user.GoogleID = ""
}
v2-2.3.0/internal/oauth2/manager.go 0000664 0000000 0000000 00000003235 15201231005 0017012 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"context"
"errors"
"log/slog"
)
// Manager manages registered OAuth2 providers.
type Manager struct {
providers map[string]Provider
}
// FindProvider returns the provider registered under the given name,
// or an error if no such provider exists.
func (m *Manager) FindProvider(name string) (Provider, error) {
if provider, found := m.providers[name]; found {
return provider, nil
}
return nil, errors.New("oauth2 provider not found")
}
// AddProvider registers a provider under the given name.
func (m *Manager) AddProvider(name string, provider Provider) {
m.providers[name] = provider
}
// NewManager creates a Manager and registers the specified OAuth2 provider.
// The provider argument must be "oidc" or "google".
func NewManager(ctx context.Context, provider, clientID, clientSecret, redirectURL, oidcDiscoveryEndpoint string) *Manager {
m := &Manager{providers: make(map[string]Provider)}
switch provider {
case "oidc":
if clientSecret == "" {
slog.Warn("OIDC client secret is empty or missing.")
}
if oidcProvider, err := NewOidcProvider(ctx, clientID, clientSecret, redirectURL, oidcDiscoveryEndpoint); err != nil {
slog.Error("Failed to initialize OIDC provider",
slog.Any("error", err),
)
} else {
m.AddProvider("oidc", oidcProvider)
}
case "google":
m.AddProvider("google", NewGoogleProvider(clientID, clientSecret, redirectURL))
default:
slog.Error("Unsupported OAuth2 provider",
slog.String("provider", provider),
)
}
return m
}
v2-2.3.0/internal/oauth2/oidc.go 0000664 0000000 0000000 00000007362 15201231005 0016323 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"context"
"errors"
"fmt"
"miniflux.app/v2/internal/model"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// ErrEmptyUsername is returned when the OIDC user profile has no username.
var ErrEmptyUsername = errors.New("oidc: username is empty")
type userClaims struct {
Email string `json:"email"`
Profile string `json:"profile"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
}
type oidcProvider struct {
clientID string
clientSecret string
redirectURL string
provider *oidc.Provider
}
// NewOidcProvider returns a Provider that authenticates users via OpenID Connect.
// It discovers the OIDC endpoints from the given discovery URL.
func NewOidcProvider(ctx context.Context, clientID, clientSecret, redirectURL, discoveryEndpoint string) (Provider, error) {
provider, err := oidc.NewProvider(ctx, discoveryEndpoint)
if err != nil {
return nil, fmt.Errorf(`oidc: failed to initialize provider %q: %w`, discoveryEndpoint, err)
}
return &oidcProvider{
clientID: clientID,
clientSecret: clientSecret,
redirectURL: redirectURL,
provider: provider,
}, nil
}
func (o *oidcProvider) UserExtraKey() string {
return "openid_connect_id"
}
func (o *oidcProvider) Config() *oauth2.Config {
return &oauth2.Config{
RedirectURL: o.redirectURL,
ClientID: o.clientID,
ClientSecret: o.clientSecret,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
Endpoint: o.provider.Endpoint(),
}
}
func (o *oidcProvider) Profile(ctx context.Context, code, codeVerifier string) (*UserProfile, error) {
conf := o.Config()
token, err := conf.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier))
if err != nil {
return nil, fmt.Errorf(`oidc: failed to exchange token: %w`, err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, errors.New(`oidc: no id_token in token response`)
}
verifier := o.provider.Verifier(&oidc.Config{ClientID: o.clientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf(`oidc: failed to verify id token: %w`, err)
}
userInfo, err := o.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
if err != nil {
return nil, fmt.Errorf(`oidc: failed to get user info: %w`, err)
}
if idToken.Subject != userInfo.Subject {
return nil, fmt.Errorf(`oidc: id token subject %q does not match userinfo subject %q`, idToken.Subject, userInfo.Subject)
}
profile := &UserProfile{
Key: o.UserExtraKey(),
ID: userInfo.Subject,
}
var userClaims userClaims
if err := userInfo.Claims(&userClaims); err != nil {
return nil, fmt.Errorf(`oidc: failed to parse user claims: %w`, err)
}
// Use the first non-empty value from the claims to set the username.
// The order of preference is: preferred_username, email, name, profile.
for _, value := range []string{userClaims.PreferredUsername, userClaims.Email, userClaims.Name, userClaims.Profile} {
if value != "" {
profile.Username = value
break
}
}
if profile.Username == "" {
return nil, ErrEmptyUsername
}
return profile, nil
}
func (o *oidcProvider) PopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *UserProfile) {
user.OpenIDConnectID = profile.ID
}
func (o *oidcProvider) PopulateUserWithProfileID(user *model.User, profile *UserProfile) {
user.OpenIDConnectID = profile.ID
}
func (o *oidcProvider) UserProfileID(user *model.User) string {
return user.OpenIDConnectID
}
func (o *oidcProvider) UnsetUserProfileID(user *model.User) {
user.OpenIDConnectID = ""
}
v2-2.3.0/internal/oauth2/profile.go 0000664 0000000 0000000 00000001044 15201231005 0017034 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"fmt"
)
// UserProfile represents a user's profile retrieved from an OAuth2 provider.
type UserProfile struct {
Key string
ID string
Username string
}
// String returns a formatted string representation of the user profile.
func (p UserProfile) String() string {
return fmt.Sprintf(`Key=%s ; ID=%s ; Username=%s`, p.Key, p.ID, p.Username)
}
v2-2.3.0/internal/oauth2/provider.go 0000664 0000000 0000000 00000002416 15201231005 0017232 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oauth2 // import "miniflux.app/v2/internal/oauth2"
import (
"context"
"golang.org/x/oauth2"
"miniflux.app/v2/internal/model"
)
// Provider defines the interface that all OAuth2 providers must implement.
type Provider interface {
// Config returns the OAuth2 configuration for this provider.
Config() *oauth2.Config
// UserExtraKey returns the key used to store the provider-specific user ID.
UserExtraKey() string
// Profile exchanges the authorization code for a token and fetches the user's profile.
Profile(ctx context.Context, code, codeVerifier string) (*UserProfile, error)
// PopulateUserCreationWithProfileID sets the provider-specific ID on a new user creation request.
PopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *UserProfile)
// PopulateUserWithProfileID sets the provider-specific ID on an existing user.
PopulateUserWithProfileID(user *model.User, profile *UserProfile)
// UserProfileID returns the provider-specific ID from the given user.
UserProfileID(user *model.User) string
// UnsetUserProfileID removes the provider-specific ID from the given user.
UnsetUserProfileID(user *model.User)
}
v2-2.3.0/internal/proxyrotator/ 0000775 0000000 0000000 00000000000 15201231005 0016440 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/proxyrotator/proxyrotator.go 0000664 0000000 0000000 00000002476 15201231005 0021574 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxyrotator // import "miniflux.app/v2/internal/proxyrotator"
import (
"net/url"
"sync"
)
var ProxyRotatorInstance *ProxyRotator
// ProxyRotator manages a list of proxies and rotates through them.
type ProxyRotator struct {
proxies []*url.URL
currentIndex int
mutex sync.Mutex
}
// NewProxyRotator creates a new ProxyRotator with the given proxy URLs.
func NewProxyRotator(proxyURLs []string) (*ProxyRotator, error) {
parsedProxies := make([]*url.URL, 0, len(proxyURLs))
for _, p := range proxyURLs {
proxyURL, err := url.Parse(p)
if err != nil {
return nil, err
}
parsedProxies = append(parsedProxies, proxyURL)
}
return &ProxyRotator{
proxies: parsedProxies,
currentIndex: 0,
mutex: sync.Mutex{},
}, nil
}
// GetNextProxy returns the next proxy in the rotation.
func (pr *ProxyRotator) GetNextProxy() *url.URL {
if len(pr.proxies) == 0 {
return nil
}
pr.mutex.Lock()
proxy := pr.proxies[pr.currentIndex]
pr.currentIndex = (pr.currentIndex + 1) % len(pr.proxies)
pr.mutex.Unlock()
return proxy
}
// HasProxies checks if there are any proxies available in the rotator.
func (pr *ProxyRotator) HasProxies() bool {
return len(pr.proxies) > 0
}
v2-2.3.0/internal/proxyrotator/proxyrotator_test.go 0000664 0000000 0000000 00000003224 15201231005 0022623 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxyrotator // import "miniflux.app/v2/internal/proxyrotator"
import (
"testing"
)
func TestProxyRotator(t *testing.T) {
proxyURLs := []string{
"http://proxy1.example.com",
"http://proxy2.example.com",
"http://proxy3.example.com",
}
rotator, err := NewProxyRotator(proxyURLs)
if err != nil {
t.Fatalf("Failed to create ProxyRotator: %v", err)
}
if !rotator.HasProxies() {
t.Fatalf("Expected rotator to have proxies")
}
seenProxies := make(map[string]bool)
for range len(proxyURLs) * 2 {
proxy := rotator.GetNextProxy()
if proxy == nil {
t.Fatalf("Expected a proxy, got nil")
}
seenProxies[proxy.String()] = true
}
if len(seenProxies) != len(proxyURLs) {
t.Fatalf("Expected to see all proxies, but saw: %v", seenProxies)
}
}
func TestProxyRotatorEmpty(t *testing.T) {
rotator, err := NewProxyRotator([]string{})
if err != nil {
t.Fatalf("Failed to create ProxyRotator: %v", err)
}
if rotator.HasProxies() {
t.Fatalf("Expected rotator to have no proxies")
}
proxy := rotator.GetNextProxy()
if proxy != nil {
t.Fatalf("Expected no proxy, got: %v", proxy)
}
}
func TestProxyRotatorInvalidURL(t *testing.T) {
invalidProxyURLs := []string{
"http://validproxy.example.com",
"test|test://invalidproxy.example.com",
}
rotator, err := NewProxyRotator(invalidProxyURLs)
if err == nil {
t.Fatalf("Expected an error when creating ProxyRotator with invalid URLs, but got none")
}
if rotator != nil {
t.Fatalf("Expected rotator to be nil when initialization fails, but got: %v", rotator)
}
}
v2-2.3.0/internal/reader/ 0000775 0000000 0000000 00000000000 15201231005 0015106 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/reader/atom/ 0000775 0000000 0000000 00000000000 15201231005 0016046 5 ustar 00root root 0000000 0000000 v2-2.3.0/internal/reader/atom/atom_03.go 0000664 0000000 0000000 00000016154 15201231005 0017646 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"encoding/base64"
"html"
"strings"
)
// Specs: http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html
type atom03Feed struct {
Version string `xml:"version,attr"`
// The "atom:id" element's content conveys a permanent, globally unique identifier for the feed.
// It MUST NOT change over time, even if the feed is relocated. atom:feed elements MAY contain an atom:id element,
// but MUST NOT contain more than one. The content of this element, when present, MUST be a URI.
ID string `xml:"http://purl.org/atom/ns# id"`
// The "atom:title" element is a Content construct that conveys a human-readable title for the feed.
// atom:feed elements MUST contain exactly one atom:title element.
// If the feed describes a Web resource, its content SHOULD be the same as that resource's title.
Title atom03Content `xml:"http://purl.org/atom/ns# title"`
// The "atom:link" element is a Link construct that conveys a URI associated with the feed.
// The nature of the relationship as well as the link itself is determined by the element's content.
// atom:feed elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
// atom:feed elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
// atom:feed elements MAY contain additional atom:link elements beyond those described above.
Links atomLinks `xml:"http://purl.org/atom/ns# link"`
// The "atom:author" element is a Person construct that indicates the default author of the feed.
// atom:feed elements MUST contain exactly one atom:author element,
// UNLESS all of the atom:feed element's child atom:entry elements contain an atom:author element.
// atom:feed elements MUST NOT contain more than one atom:author element.
Author AtomPerson `xml:"http://purl.org/atom/ns# author"`
// The "atom:entry" element's represents an individual entry that is contained by the feed.
// atom:feed elements MAY contain one or more atom:entry elements.
Entries []atom03Entry `xml:"http://purl.org/atom/ns# entry"`
}
type atom03Entry struct {
// The "atom:id" element's content conveys a permanent, globally unique identifier for the entry.
// It MUST NOT change over time, even if other representations of the entry (such as a web representation pointed to by the entry's atom:link element) are relocated.
// If the same entry is syndicated in two atom:feeds published by the same entity, the entry's atom:id MUST be the same in both feeds.
ID string `xml:"id"`
// The "atom:title" element is a Content construct that conveys a human-readable title for the entry.
// atom:entry elements MUST have exactly one "atom:title" element.
// If an entry describes a Web resource, its content SHOULD be the same as that resource's title.
Title atom03Content `xml:"title"`
// The "atom:modified" element is a Date construct that indicates the time that the entry was last modified.
// atom:entry elements MUST contain an atom:modified element, but MUST NOT contain more than one.
// The content of an atom:modified element MUST have a time zone whose value SHOULD be "UTC".
Modified string `xml:"modified"`
// The "atom:issued" element is a Date construct that indicates the time that the entry was issued.
// atom:entry elements MUST contain an atom:issued element, but MUST NOT contain more than one.
// The content of an atom:issued element MAY omit a time zone.
Issued string `xml:"issued"`
// The "atom:created" element is a Date construct that indicates the time that the entry was created.
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.
// The content of an atom:created element MUST have a time zone whose value SHOULD be "UTC".
// If atom:created is not present, its content MUST considered to be the same as that of atom:modified.
Created string `xml:"created"`
// The "atom:link" element is a Link construct that conveys a URI associated with the entry.
// The nature of the relationship as well as the link itself is determined by the element's content.
// atom:entry elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
// atom:entry elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
// atom:entry elements MAY contain additional atom:link elements beyond those described above.
Links atomLinks `xml:"link"`
// The "atom:summary" element is a Content construct that conveys a short summary, abstract or excerpt of the entry.
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.
Summary atom03Content `xml:"summary"`
// The "atom:content" element is a Content construct that conveys the content of the entry.
// atom:entry elements MAY contain one or more atom:content elements.
Content atom03Content `xml:"content"`
// The "atom:author" element is a Person construct that indicates the default author of the entry.
// atom:entry elements MUST contain exactly one atom:author element,
// UNLESS the atom:feed element containing them contains an atom:author element itself.
// atom:entry elements MUST NOT contain more than one atom:author element.
Author AtomPerson `xml:"author"`
}
type atom03Content struct {
// Content constructs MAY have a "type" attribute, whose value indicates the media type of the content.
// When present, this attribute's value MUST be a registered media type [RFC2045].
// If not present, its value MUST be considered to be "text/plain".
Type string `xml:"type,attr"`
// Content constructs MAY have a "mode" attribute, whose value indicates the method used to encode the content.
// When present, this attribute's value MUST be listed below.
// If not present, its value MUST be considered to be "xml".
//
// "xml": A mode attribute with the value "xml" indicates that the element's content is inline xml (for example, namespace-qualified XHTML).
//
// "escaped": A mode attribute with the value "escaped" indicates that the element's content is an escaped string.
// Processors MUST unescape the element's content before considering it as content of the indicated media type.
//
// "base64": A mode attribute with the value "base64" indicates that the element's content is base64-encoded [RFC2045].
// Processors MUST decode the element's content before considering it as content of the the indicated media type.
Mode string `xml:"mode,attr"`
CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"`
}
func (a *atom03Content) content() string {
content := ""
switch a.Mode {
case "xml":
content = a.InnerXML
case "escaped":
content = a.CharData
case "base64":
b, err := base64.StdEncoding.DecodeString(a.CharData)
if err == nil {
content = string(b)
}
default:
content = a.CharData
}
if a.Type != "text/html" {
content = html.EscapeString(content)
}
return strings.TrimSpace(content)
}
v2-2.3.0/internal/reader/atom/atom_03_adapter.go 0000664 0000000 0000000 00000005317 15201231005 0021345 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"log/slog"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
type atom03Adapter struct {
atomFeed *atom03Feed
}
func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
// Populate the feed URL.
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
if feedURL != "" {
if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {
feed.FeedURL = absoluteFeedURL
}
} else {
feed.FeedURL = baseURL
}
// Populate the site URL.
siteURL := a.atomFeed.Links.originalLink()
if siteURL != "" {
if absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
}
} else {
feed.SiteURL = baseURL
}
// Populate the feed title.
feed.Title = a.atomFeed.Title.content()
if feed.Title == "" {
feed.Title = feed.SiteURL
}
for _, atomEntry := range a.atomFeed.Entries {
entry := model.NewEntry()
// Populate the entry URL.
entry.URL = atomEntry.Links.originalLink()
if entry.URL != "" {
if absoluteEntryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, entry.URL); err == nil {
entry.URL = absoluteEntryURL
}
}
// Populate the entry content.
entry.Content = atomEntry.Content.content()
if entry.Content == "" {
entry.Content = atomEntry.Summary.content()
}
// Populate the entry title.
entry.Title = atomEntry.Title.content()
if entry.Title == "" {
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
}
if entry.Title == "" {
entry.Title = entry.URL
}
// Populate the entry author.
entry.Author = atomEntry.Author.PersonName()
if entry.Author == "" {
entry.Author = a.atomFeed.Author.PersonName()
}
// Populate the entry date.
for _, value := range []string{atomEntry.Issued, atomEntry.Modified, atomEntry.Created} {
if parsedDate, err := date.Parse(value); err == nil {
entry.Date = parsedDate
break
} else {
slog.Debug("Unable to parse date from Atom 0.3 feed",
slog.String("date", value),
slog.String("id", atomEntry.ID),
slog.Any("error", err),
)
}
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
// Generate the entry hash.
for _, value := range []string{atomEntry.ID, atomEntry.Links.originalLink()} {
if value != "" {
entry.Hash = crypto.SHA256(value)
break
}
}
feed.Entries = append(feed.Entries, entry)
}
return feed
}
v2-2.3.0/internal/reader/atom/atom_03_test.go 0000664 0000000 0000000 00000023312 15201231005 0020677 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"bytes"
"testing"
"time"
)
func TestParseAtom03(t *testing.T) {
data := `
dive into mark2003-12-13T18:30:02ZMark PilgrimAtom 0.3 snapshottag:diveintomark.org,2003:3.23972003-12-13T08:29:29-04:002003-12-13T18:30:02ZIt's a testHTML content
]]>
`
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if feed.Title != "dive into mark" {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
if feed.FeedURL != "http://diveintomark.org/atom.xml" {
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
}
if feed.SiteURL != "http://diveintomark.org/" {
t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
tz := time.FixedZone("Test Case Time", -int((4 * time.Hour).Seconds()))
if !feed.Entries[0].Date.Equal(time.Date(2003, time.December, 13, 8, 29, 29, 0, tz)) {
t.Errorf("Incorrect entry date, got: %v", feed.Entries[0].Date)
}
if feed.Entries[0].Hash != "b70d30334b808f32e66eb19fabb263525cecd18f205720b583e84f7f295cf728" {
t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
}
if feed.Entries[0].URL != "http://diveintomark.org/2003/12/13/atom03" {
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
}
if feed.Entries[0].Title != "Atom 0.3 snapshot" {
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
}
if feed.Entries[0].Content != "
HTML content
" {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
}
if feed.Entries[0].Author != "Mark Pilgrim" {
t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
}
}
func TestParseAtom03WithoutSiteURL(t *testing.T) {
data := `
2003-12-13T18:30:02ZMark PilgrimAtom 0.3 snapshottag:diveintomark.org,2003:3.2397`
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if feed.SiteURL != "http://diveintomark.org/atom.xml" {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
}
func TestParseAtom03WithoutFeedTitle(t *testing.T) {
data := `
2003-12-13T18:30:02ZMark PilgrimAtom 0.3 snapshottag:diveintomark.org,2003:3.2397`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if feed.Title != "http://diveintomark.org/" {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
}
func TestParseAtom03WithoutEntryTitleButWithLink(t *testing.T) {
data := `
dive into mark2003-12-13T18:30:02ZMark Pilgrimtag:diveintomark.org,2003:3.2397`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Title != "http://diveintomark.org/2003/12/13/atom03" {
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
}
}
func TestParseAtom03WithoutEntryTitleButWithSummary(t *testing.T) {
data := `
dive into mark2003-12-13T18:30:02ZMark Pilgrimtag:diveintomark.org,2003:3.2397It's a test`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Title != "It's a test" {
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
}
}
func TestParseAtom03WithoutEntryTitleButWithXMLContent(t *testing.T) {
data := `
dive into mark2003-12-13T18:30:02ZMark Pilgrimtag:diveintomark.org,2003:3.2397
Some text.
`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Title != "Some text." {
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
}
}
func TestParseAtom03WithSummaryOnly(t *testing.T) {
data := `
dive into mark2003-12-13T18:30:02ZMark PilgrimAtom 0.3 snapshottag:diveintomark.org,2003:3.23972003-12-13T08:29:29-04:002003-12-13T18:30:02ZIt's a test`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Content != "It's a test" {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
}
}
func TestParseAtom03WithXMLContent(t *testing.T) {
data := `
dive into mark2003-12-13T18:30:02ZMark PilgrimAtom 0.3 snapshottag:diveintomark.org,2003:3.23972003-12-13T08:29:29-04:002003-12-13T18:30:02Z
Some text.
`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Content != "
Some text.
" {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
}
}
func TestParseAtom03WithBase64Content(t *testing.T) {
data := `
dive into mark2003-12-13T18:30:02ZMark PilgrimAtom 0.3 snapshottag:diveintomark.org,2003:3.23972003-12-13T08:29:29-04:002003-12-13T18:30:02ZPHA+U29tZSB0ZXh0LjwvcD4=`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Content != "
Some text.
" {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
}
}
v2-2.3.0/internal/reader/atom/atom_10.go 0000664 0000000 0000000 00000017326 15201231005 0017646 0 ustar 00root root 0000000 0000000 // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"encoding/xml"
"html"
"strings"
"miniflux.app/v2/internal/reader/media"
)
// The "atom:feed" element is the document (i.e., top-level) element of
// an Atom Feed Document, acting as a container for metadata and data
// associated with the feed. Its element children consist of metadata
// elements followed by zero or more atom:entry child elements.
//
// Specs:
// https://tools.ietf.org/html/rfc4287
// https://validator.w3.org/feed/docs/atom.html
type atom10Feed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
// The "atom:id" element conveys a permanent, universally unique
// identifier for an entry or feed.
//
// Its content MUST be an IRI, as defined by [RFC3987]. Note that the
// definition of "IRI" excludes relative references. Though the IRI
// might use a dereferencable scheme, Atom Processors MUST NOT assume it
// can be dereferenced.
//
// atom:feed elements MUST contain exactly one atom:id element.
ID string `xml:"http://www.w3.org/2005/Atom id"`
// The "atom:title" element is a Text construct that conveys a human-
// readable title for an entry or feed.
//
// atom:feed elements MUST contain exactly one atom:title element.
Title atom10Text `xml:"http://www.w3.org/2005/Atom title"`
// The "atom:subtitle" element is a Text construct that
// contains a human-readable description or subtitle for the feed.
Subtitle atom10Text `xml:"http://www.w3.org/2005/Atom subtitle"`
// The "atom:author" element is a Person construct that indicates the
// author of the entry or feed.
//
// atom:feed elements MUST contain one or more atom:author elements,
// unless all of the atom:feed element's child atom:entry elements
// contain at least one atom:author element.
Authors atomPersons `xml:"http://www.w3.org/2005/Atom author"`
// The "atom:icon" element's content is an IRI reference [RFC3987] that
// identifies an image that provides iconic visual identification for a
// feed.
//
// atom:feed elements MUST NOT contain more than one atom:icon element.
Icon string `xml:"http://www.w3.org/2005/Atom icon"`
// The "atom:logo" element's content is an IRI reference [RFC3987] that
// identifies an image that provides visual identification for a feed.
//
// atom:feed elements MUST NOT contain more than one atom:logo element.
Logo string `xml:"http://www.w3.org/2005/Atom logo"`
// atom:feed elements SHOULD contain one atom:link element with a rel
// attribute value of "self". This is the preferred URI for
// retrieving Atom Feed Documents representing this Atom feed.
//
// atom:feed elements MUST NOT contain more than one atom:link
// element with a rel attribute value of "alternate" that has the
// same combination of type and hreflang attribute values.
Links atomLinks `xml:"http://www.w3.org/2005/Atom link"`
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
// meaning to the content (if any) of this element.
//
// atom:feed elements MAY contain any number of atom:category
// elements.
Categories atomCategories `xml:"http://www.w3.org/2005/Atom category"`
Entries []atom10Entry `xml:"http://www.w3.org/2005/Atom entry"`
}
type atom10Entry struct {
// The "atom:id" element conveys a permanent, universally unique
// identifier for an entry or feed.
//
// Its content MUST be an IRI, as defined by [RFC3987]. Note that the
// definition of "IRI" excludes relative references. Though the IRI
// might use a dereferencable scheme, Atom Processors MUST NOT assume it
// can be dereferenced.
//
// atom:entry elements MUST contain exactly one atom:id element.
ID string `xml:"http://www.w3.org/2005/Atom id"`
// The "atom:title" element is a Text construct that conveys a human-
// readable title for an entry or feed.
//
// atom:entry elements MUST contain exactly one atom:title element.
Title atom10Text `xml:"http://www.w3.org/2005/Atom title"`
// The "atom:published" element is a Date construct indicating an
// instant in time associated with an event early in the life cycle of
// the entry.
Published string `xml:"http://www.w3.org/2005/Atom published"`
// The "atom:updated" element is a Date construct indicating the most
// recent instant in time when an entry or feed was modified in a way
// the publisher considers significant. Therefore, not all
// modifications necessarily result in a changed atom:updated value.
//
// atom:entry elements MUST contain exactly one atom:updated element.
Updated string `xml:"http://www.w3.org/2005/Atom updated"`
// atom:entry elements MUST NOT contain more than one atom:link
// element with a rel attribute value of "alternate" that has the
// same combination of type and hreflang attribute values.
Links atomLinks `xml:"http://www.w3.org/2005/Atom link"`
// atom:entry elements MUST contain an atom:summary element in either
// of the following cases:
// * the atom:entry contains an atom:content that has a "src"
// attribute (and is thus empty).
// * the atom:entry contains content that is encoded in Base64;
// i.e., the "type" attribute of atom:content is a MIME media type
// [MIMEREG], but is not an XML media type [RFC3023], does not
// begin with "text/", and does not end with "/xml" or "+xml".
//
// atom:entry elements MUST NOT contain more than one atom:summary
// element.
Summary atom10Text `xml:"http://www.w3.org/2005/Atom summary"`
// atom:entry elements MUST NOT contain more than one atom:content
// element.
Content atom10Text `xml:"http://www.w3.org/2005/Atom content"`
// The "atom:author" element is a Person construct that indicates the
// author of the entry or feed.
//
// atom:entry elements MUST contain one or more atom:author elements
Authors atomPersons `xml:"http://www.w3.org/2005/Atom author"`
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
// meaning to the content (if any) of this element.
//
// atom:entry elements MAY contain any number of atom:category
// elements.
Categories atomCategories `xml:"http://www.w3.org/2005/Atom category"`
media.MediaItemElement
}
// A Text construct contains human-readable text, usually in small
// quantities. The content of Text constructs is Language-Sensitive.
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1
// Text: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.1
// HTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.2
// XHTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.3
type atom10Text struct {
Type string `xml:"type,attr"`
CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"`
XHTMLRootElement atomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
}
func (a *atom10Text) body() string {
var content string
if strings.EqualFold(a.Type, "xhtml") {
content = a.xhtmlContent()
} else {
content = a.CharData
}
return strings.TrimSpace(content)
}
func (a *atom10Text) title() string {
var content string
switch {
case strings.EqualFold(a.Type, "xhtml"):
content = a.xhtmlContent()
case strings.Contains(a.InnerXML, "