├── Procfile ├── packaging ├── debian │ ├── compat │ ├── miniflux.manpages │ ├── miniflux.dirs │ ├── copyright │ ├── miniflux.postinst │ ├── rules │ ├── Dockerfile │ ├── control │ └── build.sh ├── miniflux.conf ├── rpm │ └── Dockerfile └── docker │ ├── distroless │ └── Dockerfile │ └── alpine │ └── Dockerfile ├── contrib ├── ansible │ ├── roles │ │ └── mgrote.miniflux │ │ │ ├── defaults │ │ │ └── main.yml │ │ │ ├── handlers │ │ │ └── main.yml │ │ │ ├── templates │ │ │ └── miniflux.conf │ │ │ ├── README.md │ │ │ └── tasks │ │ │ └── main.yml │ ├── playbooks │ │ └── playbook.yml │ └── inventories │ │ └── group_vars │ │ └── miniflux_vars.yml ├── grafana │ └── README.md ├── docker-compose │ ├── Caddyfile │ ├── README.md │ ├── basic.yml │ └── caddy.yml ├── bruno │ ├── miniflux │ │ ├── bruno.json │ │ ├── environments │ │ │ └── Local.bru │ │ ├── Get all users.bru │ │ ├── Get current user.bru │ │ ├── Get all categories.bru │ │ ├── Get version and build information.bru │ │ ├── Get a single user by ID.bru │ │ ├── Discover feeds.bru │ │ ├── Create a new category.bru │ │ ├── Flush history.bru │ │ ├── Create a feed.bru │ │ ├── Get a single user by username.bru │ │ ├── Get all feeds.bru │ │ ├── Get all entries.bru │ │ ├── Get feed counters.bru │ │ ├── Refresh all feeds.bru │ │ ├── Create a new user.bru │ │ ├── Update entries status.bru │ │ ├── OPML Export.bru │ │ ├── Delete a user.bru │ │ ├── Update a user.bru │ │ ├── Update a feed.bru │ │ ├── Delete a feed.bru │ │ ├── Delete a category.bru │ │ ├── Update a category.bru │ │ ├── Get category feeds.bru │ │ ├── Get a single feed.bru │ │ ├── Get feed icon by icon ID.bru │ │ ├── Mark feed as read.bru │ │ ├── Save an entry.bru │ │ ├── Get a single entry.bru │ │ ├── Get category entries.bru │ │ ├── Get feed entries.bru │ │ ├── Get feed icon by feed ID.bru │ │ ├── Refresh category feeds.bru │ │ ├── Update entry.bru │ │ ├── Bookmark an entry.bru │ │ ├── Mark all user entries as read.bru │ │ ├── Refresh a single feed.bru │ │ ├── Get category entry.bru │ │ ├── Fetch entry website content.bru │ │ ├── Mark all category entries as read.bru │ │ ├── Get a single feed entry.bru │ │ └── OPML Import.bru │ └── README.md ├── sysvinit │ ├── README.md │ └── etc │ │ └── default │ │ └── miniflux ├── README.md └── thunder_client │ └── README.md ├── internal ├── ui │ ├── static │ │ ├── js │ │ │ ├── .jshintrc │ │ │ └── request_builder.js │ │ ├── bin │ │ │ ├── favicon.ico │ │ │ ├── icon-120.png │ │ │ ├── icon-128.png │ │ │ ├── icon-152.png │ │ │ ├── icon-167.png │ │ │ ├── icon-180.png │ │ │ ├── icon-192.png │ │ │ ├── icon-512.png │ │ │ ├── favicon-16.png │ │ │ ├── favicon-32.png │ │ │ ├── maskable-icon-120.png │ │ │ ├── maskable-icon-192.png │ │ │ └── maskable-icon-512.png │ │ └── css │ │ │ ├── serif.css │ │ │ └── sans_serif.css │ ├── handler.go │ ├── form │ │ ├── webauthn.go │ │ ├── category.go │ │ ├── api_key.go │ │ └── auth.go │ ├── history_flush.go │ ├── oauth2.go │ ├── unread_mark_all_read.go │ ├── offline.go │ ├── entry_toggle_bookmark.go │ ├── api_key_remove.go │ ├── opml_export.go │ ├── session_remove.go │ ├── feed_remove.go │ ├── static_stylesheet.go │ ├── login_show.go │ ├── static_favicon.go │ ├── feed_mark_as_read.go │ ├── opml_import.go │ ├── category_create.go │ ├── category_mark_as_read.go │ ├── feed_icon.go │ ├── api_key_create.go │ ├── category_remove.go │ ├── pagination.go │ ├── api_key_list.go │ ├── user_create.go │ ├── entry_save.go │ ├── entry_update_status.go │ ├── feed_list.go │ ├── logout.go │ ├── static_app_icon.go │ ├── category_list.go │ ├── static_javascript.go │ ├── user_list.go │ ├── session_list.go │ ├── user_remove.go │ ├── about.go │ ├── oauth2_redirect.go │ ├── entry_enclosure_save_position.go │ ├── subscription_add.go │ ├── shared_entries.go │ ├── category_feeds.go │ ├── category_edit.go │ └── user_edit.go ├── reader │ ├── parser │ │ ├── testdata │ │ │ ├── encoding_ISO-8859-1.xml │ │ │ ├── encoding_WINDOWS-1251.xml │ │ │ └── no_encoding_ISO-8859-1.xml │ │ ├── parser.go │ │ └── format.go │ ├── scraper │ │ └── testdata │ │ │ ├── img.html-result │ │ │ ├── p.html-result │ │ │ ├── iframe.html-result │ │ │ ├── p.html │ │ │ ├── img.html │ │ │ └── iframe.html │ ├── rss │ │ ├── feedburner.go │ │ └── parser.go │ ├── sanitizer │ │ ├── strip_tags_test.go │ │ ├── truncate.go │ │ └── strip_tags.go │ ├── json │ │ └── parser.go │ ├── rdf │ │ └── parser.go │ ├── opml │ │ ├── subscription.go │ │ └── parser.go │ ├── subscription │ │ └── subscription.go │ ├── dublincore │ │ └── dublincore.go │ ├── readingtime │ │ └── readingtime.go │ ├── encoding │ │ └── encoding.go │ └── atom │ │ └── parser.go ├── config │ └── config.go ├── version │ └── version.go ├── model │ ├── job.go │ ├── categories_sort_options.go │ ├── home_page.go │ ├── model.go │ ├── subscription.go │ ├── api_key.go │ ├── icon.go │ ├── user_session.go │ ├── category.go │ ├── theme.go │ ├── enclosure_test.go │ ├── webauthn.go │ └── enclosure.go ├── http │ ├── request │ │ ├── cookie.go │ │ ├── cookie_test.go │ │ └── client_ip.go │ ├── route │ │ └── route.go │ ├── response │ │ └── xml │ │ │ └── xml.go │ ├── server │ │ └── middleware.go │ └── cookie │ │ └── cookie.go ├── cli │ ├── flush_sessions.go │ ├── info.go │ ├── export_feeds.go │ ├── ask_credentials.go │ ├── health_check.go │ ├── reset_password.go │ ├── logger.go │ └── create_admin.go ├── template │ └── templates │ │ ├── views │ │ ├── feeds.html │ │ ├── webauthn_rename.html │ │ ├── create_api_key.html │ │ ├── create_category.html │ │ ├── import.html │ │ ├── about.html │ │ ├── bookmark_entries.html │ │ ├── edit_category.html │ │ ├── search_entries.html │ │ ├── create_user.html │ │ ├── sessions.html │ │ └── category_feeds.html │ │ ├── common │ │ ├── feed_menu.html │ │ ├── settings_menu.html │ │ ├── entry_pagination.html │ │ └── pagination.html │ │ └── standalone │ │ └── offline.html ├── oauth2 │ ├── profile.go │ ├── provider.go │ ├── manager.go │ └── authorization.go ├── tests │ ├── endpoint_test.go │ ├── import_export_test.go │ ├── version_test.go │ └── subscription_test.go ├── validator │ ├── subscription.go │ ├── validator.go │ └── category.go ├── locale │ ├── locale_test.go │ ├── locale.go │ ├── plural_test.go │ ├── catalog.go │ └── error.go ├── worker │ ├── pool.go │ └── worker.go ├── api │ ├── payload.go │ ├── opml.go │ └── icon.go ├── storage │ ├── timezone.go │ └── storage.go ├── timezone │ └── timezone.go ├── integration │ ├── rssbridge │ │ └── rssbridge.go │ └── matrixbot │ │ └── matrixbot.go └── crypto │ └── crypto.go ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── bug_report.md │ ├── feed-problems.md │ ├── improvement.md │ └── feature_request.md ├── pull_request_template.md ├── workflows │ ├── build_binaries.yml │ ├── linters.yml │ ├── codeql-analysis.yml │ ├── rpm_packages.yml │ └── tests.yml └── dependabot.yml ├── .gitignore ├── main.go ├── SECURITY.md ├── client ├── doc.go └── README.md └── .devcontainer ├── docker-compose.yml └── devcontainer.json /Procfile: -------------------------------------------------------------------------------- 1 | web: miniflux.app 2 | -------------------------------------------------------------------------------- /packaging/debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /packaging/debian/miniflux.manpages: -------------------------------------------------------------------------------- 1 | miniflux.1 -------------------------------------------------------------------------------- /packaging/debian/miniflux.dirs: -------------------------------------------------------------------------------- 1 | etc 2 | usr/bin 3 | -------------------------------------------------------------------------------- /contrib/ansible/roles/mgrote.miniflux/defaults/main.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /contrib/grafana/README.md: -------------------------------------------------------------------------------- 1 | Grafana Dashboard for Miniflux 2 | -------------------------------------------------------------------------------- /internal/ui/static/js/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion": 8 3 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | miniflux-* 2 | ./miniflux 3 | *.rpm 4 | *.deb 5 | .idea 6 | .vscode -------------------------------------------------------------------------------- /contrib/docker-compose/Caddyfile: -------------------------------------------------------------------------------- 1 | miniflux.example.org 2 | reverse_proxy miniflux:8080 3 | -------------------------------------------------------------------------------- /packaging/debian/copyright: -------------------------------------------------------------------------------- 1 | Files: * 2 | Copyright: 2017-2023 Frederic Guillot 3 | License: Apache -------------------------------------------------------------------------------- /packaging/miniflux.conf: -------------------------------------------------------------------------------- 1 | # See https://miniflux.app/docs/configuration.html 2 | 3 | RUN_MIGRATIONS=1 4 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/bruno.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1", 3 | "name": "Miniflux", 4 | "type": "collection" 5 | } -------------------------------------------------------------------------------- /internal/ui/static/bin/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/favicon.ico -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/icon-120.png -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/icon-128.png -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/icon-152.png -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/icon-167.png -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/icon-180.png -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/icon-192.png -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/icon-512.png -------------------------------------------------------------------------------- /contrib/ansible/playbooks/playbook.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: miniflux 3 | roles: 4 | - { role: mgrote.miniflux, tags: "miniflux" } -------------------------------------------------------------------------------- /internal/ui/static/bin/favicon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/favicon-16.png -------------------------------------------------------------------------------- /internal/ui/static/bin/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/favicon-32.png -------------------------------------------------------------------------------- /internal/ui/static/bin/maskable-icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/maskable-icon-120.png -------------------------------------------------------------------------------- /internal/ui/static/bin/maskable-icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/maskable-icon-192.png -------------------------------------------------------------------------------- /internal/ui/static/bin/maskable-icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/ui/static/bin/maskable-icon-512.png -------------------------------------------------------------------------------- /internal/reader/parser/testdata/encoding_ISO-8859-1.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/reader/parser/testdata/encoding_ISO-8859-1.xml -------------------------------------------------------------------------------- /internal/reader/parser/testdata/encoding_WINDOWS-1251.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/reader/parser/testdata/encoding_WINDOWS-1251.xml -------------------------------------------------------------------------------- /internal/reader/parser/testdata/no_encoding_ISO-8859-1.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaymiiOrg/miniflux-v2/main/internal/reader/parser/testdata/no_encoding_ISO-8859-1.xml -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Do you follow the guidelines? 2 | 3 | - [ ] I have tested my changes 4 | - [ ] I read this document: https://miniflux.app/faq.html#pull-request 5 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/environments/Local.bru: -------------------------------------------------------------------------------- 1 | vars { 2 | minifluxBaseURL: http://127.0.0.1:8080 3 | minifluxUsername: admin 4 | } 5 | vars:secret [ 6 | minifluxPassword 7 | ] 8 | -------------------------------------------------------------------------------- /contrib/sysvinit/README.md: -------------------------------------------------------------------------------- 1 | 2 | System-V init for e.g. http://devuan.org 3 | 4 | Assumes an executable `/usr/local/bin/miniflux`. 5 | 6 | Configure in `etc/default/miniflux` 7 | 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report 4 | title: '' 5 | labels: bug, triage needed 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /internal/reader/scraper/testdata/img.html-result: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /internal/reader/scraper/testdata/p.html-result: -------------------------------------------------------------------------------- 1 |

Lorem ipsum dolor sit amet, consectetuer adipiscing ept.

Apquam tincidunt mauris eu risus.

Vestibulum auctor dapibus neque.

2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feed-problems.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feed Problems 3 | about: Problems with a feed or a website 4 | title: '' 5 | labels: feed problems, triage needed 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/improvement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Improvement 3 | about: Do you have an idea to improve an existing feature? 4 | title: '' 5 | labels: improvements 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /internal/ui/static/css/serif.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --entry-content-font-weight: 300; 3 | --entry-content-font-family: Georgia, 'Times New Roman', Times, serif; 4 | --entry-content-quote-font-family: var(--entry-content-font-family); 5 | } -------------------------------------------------------------------------------- /contrib/bruno/README.md: -------------------------------------------------------------------------------- 1 | This folder contains Miniflux API collection for [Bruno](https://www.usebruno.com). 2 | 3 | Bruno is a lightweight alternative to Postman/Insomnia. 4 | 5 | - https://www.usebruno.com 6 | - https://github.com/usebruno/bruno -------------------------------------------------------------------------------- /contrib/README.md: -------------------------------------------------------------------------------- 1 | The contrib directory contains various useful things contributed by the community. 2 | 3 | Community contributions are not officially supported by the maintainers. 4 | There is no guarantee whatsoever that anything in this folder works. 5 | -------------------------------------------------------------------------------- /internal/reader/scraper/testdata/iframe.html-result: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package config // import "miniflux.app/v2/internal/config" 5 | 6 | // Opts holds parsed configuration options. 7 | var Opts *Options 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | - [ ] I have read this document: https://miniflux.app/opinionated.html#feature-request 11 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main // import "miniflux.app/v2" 5 | 6 | import ( 7 | "miniflux.app/v2/internal/cli" 8 | ) 9 | 10 | func main() { 11 | cli.Parse() 12 | } 13 | -------------------------------------------------------------------------------- /contrib/ansible/roles/mgrote.miniflux/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: start_miniflux.service 3 | become: yes 4 | systemd: 5 | name: miniflux 6 | state: restarted 7 | enabled: yes 8 | # wait 15 seconds(for systemd) 9 | - name: miniflux_wait 10 | wait_for: 11 | timeout: 15 12 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get all users.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get all users 3 | type: http 4 | seq: 2 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/users 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get current user.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get current user 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/me 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | -------------------------------------------------------------------------------- /internal/reader/scraper/testdata/p.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |

Lorem ipsum dolor sit amet, consectetuer adipiscing ept.

6 |

Apquam tincidunt mauris eu risus.

7 |

Vestibulum auctor dapibus neque.

8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get all categories.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get all categories 3 | type: http 4 | seq: 9 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/categories 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | -------------------------------------------------------------------------------- /packaging/debian/miniflux.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | case "$1" in 6 | configure) 7 | adduser --system --disabled-password --disabled-login --home /var/empty \ 8 | --no-create-home --quiet --force-badname --group miniflux 9 | ;; 10 | esac 11 | 12 | #DEBHELPER# 13 | 14 | exit 0 15 | -------------------------------------------------------------------------------- /contrib/ansible/inventories/group_vars/miniflux_vars.yml: -------------------------------------------------------------------------------- 1 | --- 2 | miniflux_linux_user: miniflux 3 | miniflux_db_user_name: miniflux_db_user 4 | miniflux_db_user_password: miniflux_db_user_password 5 | miniflux_db: miniflux_db 6 | miniflux_admin_name: admin 7 | miniflux_admin_passwort: miniflux_admin_password 8 | miniflux_port: 8080 9 | -------------------------------------------------------------------------------- /contrib/thunder_client/README.md: -------------------------------------------------------------------------------- 1 | Miniflux API Collection for Thunder Client VS Code Extension 2 | ============================================================ 3 | 4 | Official website: https://www.thunderclient.com 5 | 6 | This folder contains the API endpoints collection for Miniflux. You can import it locally to interact with the Miniflux API. 7 | -------------------------------------------------------------------------------- /internal/reader/scraper/testdata/img.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get version and build information.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get version and build information 3 | type: http 4 | seq: 42 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/version 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package version // import "miniflux.app/v2/internal/version" 5 | 6 | // Variables populated at build time. 7 | var ( 8 | Version = "dev" 9 | Commit = "HEAD" 10 | BuildDate = "undefined" 11 | ) 12 | -------------------------------------------------------------------------------- /contrib/docker-compose/README.md: -------------------------------------------------------------------------------- 1 | Docker-Compose Examples 2 | ======================= 3 | 4 | Here are few Docker Compose examples: 5 | 6 | - `basic.yml`: Basic example 7 | - `caddy.yml`: Use Caddy as reverse-proxy with automatic HTTPS 8 | - `traefik.yml`: Use Traefik as reverse-proxy with automatic HTTPS 9 | 10 | ```bash 11 | docker compose -f basic.yml up -d 12 | ``` 13 | -------------------------------------------------------------------------------- /internal/ui/static/css/sans_serif.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --entry-content-font-weight: 400; 3 | --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"; 4 | --entry-content-quote-font-family: var(--entry-content-font-family); 5 | } 6 | -------------------------------------------------------------------------------- /contrib/sysvinit/etc/default/miniflux: -------------------------------------------------------------------------------- 1 | # sourced by /etc/init.d/miniflux 2 | # see cluster port in pg_lsclusters and ls -Al /var/run/postgresql/ 3 | export DATABASE_URL='host=/var/run/postgresql/ port=5433 user=miniflux password= dbname=miniflux sslmode=disable' 4 | export LISTEN_ADDR='127.0.0.1:8081' 5 | export BASE_URL='https:// and path/' 6 | 7 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get a single user by ID.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get a single user by ID 3 | type: http 4 | seq: 3 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/users/{{userID}} 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | vars:pre-request { 19 | userID: 1 20 | } 21 | -------------------------------------------------------------------------------- /packaging/debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | DESTDIR=debian/miniflux 4 | 5 | %: 6 | dh $@ --with=systemd 7 | 8 | override_dh_auto_clean: 9 | override_dh_auto_test: 10 | override_dh_auto_build: 11 | override_dh_auto_install: 12 | cp miniflux.conf $(DESTDIR)/etc/miniflux.conf 13 | cp miniflux $(DESTDIR)/usr/bin/miniflux 14 | 15 | override_dh_installinit: 16 | dh_installinit --noscripts 17 | -------------------------------------------------------------------------------- /packaging/debian/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BASE_IMAGE_ARCH="amd64" 2 | 3 | FROM ${BASE_IMAGE_ARCH}/golang:bookworm AS build 4 | 5 | ENV DEBIAN_FRONTEND=noninteractive 6 | ENV CGO_ENABLED=0 7 | 8 | RUN apt-get update -q && \ 9 | apt-get install -y -qq build-essential devscripts dh-make debhelper && \ 10 | mkdir -p /build/debian 11 | 12 | ADD . /src 13 | 14 | CMD ["/src/packaging/debian/build.sh"] 15 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Discover feeds.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Discover feeds 3 | type: http 4 | seq: 18 5 | } 6 | 7 | post { 8 | url: {{minifluxBaseURL}}/v1/discover 9 | body: json 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "url": "https://miniflux.app" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/reader/scraper/testdata/iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Create a new category.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Create a new category 3 | type: http 4 | seq: 10 5 | } 6 | 7 | post { 8 | url: {{minifluxBaseURL}}/v1/categories 9 | body: json 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "title": "Test" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Flush history.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Flush history 3 | type: http 4 | seq: 40 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/flush-history 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "url": "https://miniflux.app" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Create a feed.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Create a feed 3 | type: http 4 | seq: 19 5 | } 6 | 7 | post { 8 | url: {{minifluxBaseURL}}/v1/feeds 9 | body: json 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get a single user by username.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get a single user by username 3 | type: http 4 | seq: 4 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/users/{{username}} 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | vars:pre-request { 19 | username: admin 20 | } 21 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get all feeds.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get all feeds 3 | type: http 4 | seq: 20 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/feeds 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/model/job.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model // import "miniflux.app/v2/internal/model" 5 | 6 | // Job represents a payload sent to the processing queue. 7 | type Job struct { 8 | UserID int64 9 | FeedID int64 10 | } 11 | 12 | // JobList represents a list of jobs. 13 | type JobList []Job 14 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get all entries.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get all entries 3 | type: http 4 | seq: 34 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/entries 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get feed counters.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get feed counters 3 | type: http 4 | seq: 21 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/feeds/counters 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Refresh all feeds.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Refresh all feeds 3 | type: http 4 | seq: 22 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/feeds/refresh 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Create a new user.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Create a new user 3 | type: http 4 | seq: 5 5 | } 6 | 7 | post { 8 | url: {{minifluxBaseURL}}/v1/users 9 | body: json 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "username": "foobar", 21 | "password": "secret123" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Update entries status.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Update entries status 3 | type: http 4 | seq: 35 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/entries 9 | body: json 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "entry_ids": [1698, 1699], 21 | "status": "read" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packaging/debian/control: -------------------------------------------------------------------------------- 1 | Source: miniflux 2 | Maintainer: Frederic Guillot 3 | Build-Depends: debhelper (>= 9.20160709) | dh-systemd 4 | 5 | Package: miniflux 6 | Architecture: __PKG_ARCH__ 7 | Section: web 8 | Priority: optional 9 | Description: Minimalist Feed Reader 10 | Miniflux is a minimalist and opinionated feed reader 11 | Homepage: https://miniflux.app 12 | Depends: ${misc:Depends}, ${shlibs:Depends}, adduser 13 | -------------------------------------------------------------------------------- /internal/model/categories_sort_options.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model // import "miniflux.app/v2/internal/model" 5 | 6 | func CategoriesSortingOptions() map[string]string { 7 | return map[string]string{ 8 | "unread_count": "form.prefs.select.unread_count", 9 | "alphabetical": "form.prefs.select.alphabetical", 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/OPML Export.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: OPML Export 3 | type: http 4 | seq: 30 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/export 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "user_agent": "My user agent" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | feedID: 19 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Delete a user.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Delete a user 3 | type: http 4 | seq: 7 5 | } 6 | 7 | delete { 8 | url: {{minifluxBaseURL}}/v1/users/{{userID}} 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "language": "fr_FR" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | userID: 2 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Update a user.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Update a user 3 | type: http 4 | seq: 6 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/users/{{userID}} 9 | body: json 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "language": "fr_FR" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | userID: 1 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Update a feed.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Update a feed 3 | type: http 4 | seq: 25 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/feeds/{{feedID}} 9 | body: json 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "user_agent": "My user agent" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | feedID: 18 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Delete a feed.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Delete a feed 3 | type: http 4 | seq: 26 5 | } 6 | 7 | delete { 8 | url: {{minifluxBaseURL}}/v1/feeds/{{feedID}} 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "user_agent": "My user agent" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | feedID: 18 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Delete a category.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Delete a category 3 | type: http 4 | seq: 12 5 | } 6 | 7 | delete { 8 | url: {{minifluxBaseURL}}/v1/categories/{{categoryID}} 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "title": "Test Update" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | categoryID: 1 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Update a category.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Update a category 3 | type: http 4 | seq: 11 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/categories/{{categoryID}} 9 | body: json 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "title": "Test Update" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | categoryID: 1 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get category feeds.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get category feeds 3 | type: http 4 | seq: 14 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/feeds 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "title": "Test Update" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | categoryID: 2 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get a single feed.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get a single feed 3 | type: http 4 | seq: 24 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/feeds/{{feedID}} 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | feedID: 18 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get feed icon by icon ID.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get feed icon by icon ID 3 | type: http 4 | seq: 28 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/icons/{{iconID}} 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "user_agent": "My user agent" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | iconID: 11 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Mark feed as read.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Mark feed as read 3 | type: http 4 | seq: 29 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/mark-all-as-read 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "user_agent": "My user agent" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | feedID: 19 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Save an entry.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Save an entry 3 | type: http 4 | seq: 38 5 | } 6 | 7 | post { 8 | url: {{minifluxBaseURL}}/v1/entries/{{entryID}}/save 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | entryID: 1698 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get a single entry.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get a single entry 3 | type: http 4 | seq: 36 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/entries/{{entryID}} 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | entryID: 1698 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get category entries.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get category entries 3 | type: http 4 | seq: 16 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/entries 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "title": "Test Update" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | categoryID: 2 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get feed entries.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get feed entries 3 | type: http 4 | seq: 32 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/entries 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | feedID: 19 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get feed icon by feed ID.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get feed icon by feed ID 3 | type: http 4 | seq: 27 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/icon 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "user_agent": "My user agent" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | feedID: 19 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Refresh category feeds.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Refresh category feeds 3 | type: http 4 | seq: 15 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/refresh 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "title": "Test Update" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | categoryID: 2 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Update entry.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Update entry 3 | type: http 4 | seq: 41 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/entries/{{entryID}} 9 | body: json 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "title": "New title", 21 | "content": "Some text" 22 | } 23 | } 24 | 25 | vars:pre-request { 26 | entryID: 1789 27 | } 28 | -------------------------------------------------------------------------------- /internal/http/request/cookie.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package request // import "miniflux.app/v2/internal/http/request" 5 | 6 | import "net/http" 7 | 8 | // CookieValue returns the cookie value. 9 | func CookieValue(r *http.Request, name string) string { 10 | cookie, err := r.Cookie(name) 11 | if err == http.ErrNoCookie { 12 | return "" 13 | } 14 | 15 | return cookie.Value 16 | } 17 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Bookmark an entry.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Bookmark an entry 3 | type: http 4 | seq: 37 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/entries/{{entryID}}/bookmark 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | entryID: 1698 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Mark all user entries as read.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Mark all user entries as read 3 | type: http 4 | seq: 8 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/users/{{userID}}/mark-all-as-read 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "title": "Test Update" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | userID: 1 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Refresh a single feed.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Refresh a single feed 3 | type: http 4 | seq: 23 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/refresh 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | feedID: 18 26 | } 27 | -------------------------------------------------------------------------------- /internal/cli/flush_sessions.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package cli // import "miniflux.app/v2/internal/cli" 5 | 6 | import ( 7 | "fmt" 8 | 9 | "miniflux.app/v2/internal/storage" 10 | ) 11 | 12 | func flushSessions(store *storage.Storage) { 13 | fmt.Println("Flushing all sessions (disconnect users)") 14 | if err := store.FlushAllSessions(); err != nil { 15 | printErrorAndExit(err) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get category entry.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get category entry 3 | type: http 4 | seq: 17 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/entries/{{entryID}} 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "title": "Test Update" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | categoryID: 2 26 | entryID: 1 27 | } 28 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Fetch entry website content.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Fetch entry website content 3 | type: http 4 | seq: 39 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/entries/{{entryID}}/fetch-content 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | entryID: 1698 26 | } 27 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Mark all category entries as read.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Mark all category entries as read 3 | type: http 4 | seq: 13 5 | } 6 | 7 | put { 8 | url: {{minifluxBaseURL}}/v1/categories/{{categoryID}}/mark-all-as-read 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "title": "Test Update" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | categoryID: 2 26 | } 27 | -------------------------------------------------------------------------------- /internal/template/templates/views/feeds.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.feeds.title" }} ({{ .total }}){{ end }} 2 | 3 | {{ define "content"}} 4 | 8 | 9 | {{ if not .feeds }} 10 |

{{ t "alert.no_feed" }}

11 | {{ else }} 12 | {{ template "feed_list" dict "user" .user "feeds" .feeds "ParsingErrorCount" .ParsingErrorCount }} 13 | {{ end }} 14 | 15 | {{ end }} 16 | -------------------------------------------------------------------------------- /internal/oauth2/profile.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package oauth2 // import "miniflux.app/v2/internal/oauth2" 5 | 6 | import ( 7 | "fmt" 8 | ) 9 | 10 | // Profile is the OAuth2 user profile. 11 | type Profile struct { 12 | Key string 13 | ID string 14 | Username string 15 | } 16 | 17 | func (p Profile) String() string { 18 | return fmt.Sprintf(`Key=%s ; ID=%s ; Username=%s`, p.Key, p.ID, p.Username) 19 | } 20 | -------------------------------------------------------------------------------- /internal/reader/rss/feedburner.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package rss // import "miniflux.app/v2/internal/reader/rss" 5 | 6 | // FeedBurnerElement represents FeedBurner XML elements. 7 | type FeedBurnerElement struct { 8 | FeedBurnerLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"` 9 | FeedBurnerEnclosureLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink"` 10 | } 11 | -------------------------------------------------------------------------------- /internal/ui/handler.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "miniflux.app/v2/internal/storage" 8 | "miniflux.app/v2/internal/template" 9 | "miniflux.app/v2/internal/worker" 10 | 11 | "github.com/gorilla/mux" 12 | ) 13 | 14 | type handler struct { 15 | router *mux.Router 16 | store *storage.Storage 17 | tpl *template.Engine 18 | pool *worker.Pool 19 | } 20 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/Get a single feed entry.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: Get a single feed entry 3 | type: http 4 | seq: 33 5 | } 6 | 7 | get { 8 | url: {{minifluxBaseURL}}/v1/feeds/{{feedID}}/entries/{{entryID}} 9 | body: none 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "feed_url": "https://miniflux.app/feed.xml" 21 | } 22 | } 23 | 24 | vars:pre-request { 25 | feedID: 19 26 | entryID: 1698 27 | } 28 | -------------------------------------------------------------------------------- /internal/model/home_page.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model // import "miniflux.app/v2/internal/model" 5 | 6 | // HomePages returns the list of available home pages. 7 | func HomePages() map[string]string { 8 | return map[string]string{ 9 | "unread": "menu.unread", 10 | "starred": "menu.starred", 11 | "history": "menu.history", 12 | "feeds": "menu.feeds", 13 | "categories": "menu.categories", 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the latest stable version is supported. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Preferably, [report the vulnerability privately using GitHub](https://github.com/miniflux/v2/security/advisories/new) ([documentation](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability)). 10 | 11 | If you do not want to use GitHub, send an email to `security AT miniflux DOT net` with all the steps to reproduce the problem. 12 | -------------------------------------------------------------------------------- /internal/tests/endpoint_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | //go:build integration 5 | // +build integration 6 | 7 | package tests 8 | 9 | import ( 10 | "testing" 11 | 12 | miniflux "miniflux.app/v2/client" 13 | ) 14 | 15 | func TestWithBadEndpoint(t *testing.T) { 16 | client := miniflux.New("bad url", testAdminUsername, testAdminPassword) 17 | _, err := client.Users() 18 | if err == nil { 19 | t.Fatal(`Using a bad URL should raise an error`) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /internal/ui/form/webauthn.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package form // import "miniflux.app/v2/internal/ui/form" 5 | 6 | import ( 7 | "net/http" 8 | ) 9 | 10 | // WebauthnForm represents a credential rename form in the UI 11 | type WebauthnForm struct { 12 | Name string 13 | } 14 | 15 | // NewWebauthnForm returns a new WebnauthnForm. 16 | func NewWebauthnForm(r *http.Request) *WebauthnForm { 17 | return &WebauthnForm{ 18 | Name: r.FormValue("name"), 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /internal/ui/history_flush.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/json" 11 | ) 12 | 13 | func (h *handler) flushHistory(w http.ResponseWriter, r *http.Request) { 14 | err := h.store.FlushHistory(request.UserID(r)) 15 | if err != nil { 16 | json.ServerError(w, r, err) 17 | return 18 | } 19 | 20 | json.OK(w, r, "OK") 21 | } 22 | -------------------------------------------------------------------------------- /contrib/ansible/roles/mgrote.miniflux/templates/miniflux.conf: -------------------------------------------------------------------------------- 1 | # See https://docs.miniflux.app/ 2 | 3 | LISTEN_ADDR=0.0.0.0:{{ miniflux_port }} 4 | DATABASE_URL=user={{ miniflux_db_user_name }} password={{ miniflux_db_user_password }} dbname={{ miniflux_db }} sslmode=disable 5 | 6 | POLLING_FREQUENCY=15 7 | PROXY_IMAGES=http-only 8 | 9 | # Run SQL migrations automatically: 10 | RUN_MIGRATIONS=1 11 | 12 | CREATE_ADMIN=1 13 | ADMIN_USERNAME={{ miniflux_admin_name }} 14 | ADMIN_PASSWORD={{ miniflux_admin_passwort }} 15 | 16 | POLLING_FREQUENCY=10 17 | 18 | # Options: https://miniflux.app/miniflux.1.html 19 | -------------------------------------------------------------------------------- /internal/ui/oauth2.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "context" 8 | 9 | "miniflux.app/v2/internal/config" 10 | "miniflux.app/v2/internal/oauth2" 11 | ) 12 | 13 | func getOAuth2Manager(ctx context.Context) *oauth2.Manager { 14 | return oauth2.NewManager( 15 | ctx, 16 | config.Opts.OAuth2ClientID(), 17 | config.Opts.OAuth2ClientSecret(), 18 | config.Opts.OAuth2RedirectURL(), 19 | config.Opts.OIDCDiscoveryEndpoint(), 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /internal/ui/unread_mark_all_read.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/json" 11 | ) 12 | 13 | func (h *handler) markAllAsRead(w http.ResponseWriter, r *http.Request) { 14 | if err := h.store.MarkGloballyVisibleFeedsAsRead(request.UserID(r)); err != nil { 15 | json.ServerError(w, r, err) 16 | return 17 | } 18 | 19 | json.OK(w, r, "OK") 20 | } 21 | -------------------------------------------------------------------------------- /internal/reader/sanitizer/strip_tags_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer" 5 | 6 | import "testing" 7 | 8 | func TestStripTags(t *testing.T) { 9 | input := `This link is relative and this image: ` 10 | expected := `This link is relative and this image: ` 11 | output := StripTags(input) 12 | 13 | if expected != output { 14 | t.Errorf(`Wrong output: "%s" != "%s"`, expected, output) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/cli/info.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package cli // import "miniflux.app/v2/internal/cli" 5 | 6 | import ( 7 | "fmt" 8 | "runtime" 9 | 10 | "miniflux.app/v2/internal/version" 11 | ) 12 | 13 | func info() { 14 | fmt.Println("Version:", version.Version) 15 | fmt.Println("Commit:", version.Commit) 16 | fmt.Println("Build Date:", version.BuildDate) 17 | fmt.Println("Go Version:", runtime.Version()) 18 | fmt.Println("Compiler:", runtime.Compiler) 19 | fmt.Println("Arch:", runtime.GOARCH) 20 | fmt.Println("OS:", runtime.GOOS) 21 | } 22 | -------------------------------------------------------------------------------- /internal/ui/form/category.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package form // import "miniflux.app/v2/internal/ui/form" 5 | 6 | import ( 7 | "net/http" 8 | ) 9 | 10 | // CategoryForm represents a feed form in the UI 11 | type CategoryForm struct { 12 | Title string 13 | HideGlobally string 14 | } 15 | 16 | // NewCategoryForm returns a new CategoryForm. 17 | func NewCategoryForm(r *http.Request) *CategoryForm { 18 | return &CategoryForm{ 19 | Title: r.FormValue("title"), 20 | HideGlobally: r.FormValue("hide_globally"), 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/ui/offline.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/session" 12 | "miniflux.app/v2/internal/ui/view" 13 | ) 14 | 15 | func (h *handler) showOfflinePage(w http.ResponseWriter, r *http.Request) { 16 | sess := session.New(h.store, request.SessionID(r)) 17 | view := view.New(h.tpl, r, sess) 18 | html.OK(w, r, view.Render("offline")) 19 | } 20 | -------------------------------------------------------------------------------- /internal/validator/subscription.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package validator // import "miniflux.app/v2/internal/validator" 5 | 6 | import ( 7 | "miniflux.app/v2/internal/locale" 8 | "miniflux.app/v2/internal/model" 9 | ) 10 | 11 | // ValidateSubscriptionDiscovery validates subscription discovery requests. 12 | func ValidateSubscriptionDiscovery(request *model.SubscriptionDiscoveryRequest) *locale.LocalizedError { 13 | if !IsValidURL(request.URL) { 14 | return locale.NewLocalizedError("error.invalid_site_url") 15 | } 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /internal/locale/locale_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package locale // import "miniflux.app/v2/internal/locale" 5 | 6 | import "testing" 7 | 8 | func TestAvailableLanguages(t *testing.T) { 9 | results := AvailableLanguages() 10 | for k, v := range results { 11 | if k == "" { 12 | t.Errorf(`Empty language key detected`) 13 | } 14 | 15 | if v == "" { 16 | t.Errorf(`Empty language value detected`) 17 | } 18 | } 19 | 20 | if _, found := results["en_US"]; !found { 21 | t.Errorf(`We must have at least the default language (en_US)`) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/ui/entry_toggle_bookmark.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/json" 11 | ) 12 | 13 | func (h *handler) toggleBookmark(w http.ResponseWriter, r *http.Request) { 14 | entryID := request.RouteInt64Param(r, "entryID") 15 | if err := h.store.ToggleBookmark(request.UserID(r), entryID); err != nil { 16 | json.ServerError(w, r, err) 17 | return 18 | } 19 | 20 | json.OK(w, r, "OK") 21 | } 22 | -------------------------------------------------------------------------------- /internal/reader/sanitizer/truncate.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package sanitizer 5 | 6 | import "strings" 7 | 8 | func TruncateHTML(input string, max int) string { 9 | text := StripTags(input) 10 | text = strings.ReplaceAll(text, "\n", " ") 11 | text = strings.ReplaceAll(text, "\t", " ") 12 | text = strings.ReplaceAll(text, " ", " ") 13 | text = strings.TrimSpace(text) 14 | 15 | // Convert to runes to be safe with unicode 16 | runes := []rune(text) 17 | if len(runes) > max { 18 | return strings.TrimSpace(string(runes[:max])) + "…" 19 | } 20 | 21 | return text 22 | } 23 | -------------------------------------------------------------------------------- /contrib/ansible/roles/mgrote.miniflux/README.md: -------------------------------------------------------------------------------- 1 | ## mgrote.miniflux 2 | 3 | ### Details 4 | Installs and configures Miniflux v2 with ansible 5 | 6 | ### Works on... 7 | - [x] Ubuntu (>=18.04) 8 | 9 | ### Variables and Defaults 10 | ##### Linux User 11 | miniflux_linux_user: miniflux 12 | ##### DB User 13 | miniflux_db_user_name: miniflux_db_user 14 | ##### DB Password 15 | miniflux_db_user_password: qqqqqqqqqqqqq 16 | ##### Database 17 | miniflux_db: miniflux_db 18 | ##### Username Miniflux Admin 19 | miniflux_admin_name: admin 20 | ##### Password Miniflux Admin 21 | miniflux_admin_passwort: hallowelt 22 | ##### Port for Miniflux Frontend 23 | miniflux_port: 8080 24 | -------------------------------------------------------------------------------- /internal/reader/json/parser.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package json // import "miniflux.app/v2/internal/reader/json" 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | 11 | "miniflux.app/v2/internal/model" 12 | ) 13 | 14 | // Parse returns a normalized feed struct from a JSON feed. 15 | func Parse(baseURL string, data io.Reader) (*model.Feed, error) { 16 | feed := new(jsonFeed) 17 | if err := json.NewDecoder(data).Decode(&feed); err != nil { 18 | return nil, fmt.Errorf("json: unable to parse feed: %w", err) 19 | } 20 | 21 | return feed.Transform(baseURL), nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/reader/rss/parser.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package rss // import "miniflux.app/v2/internal/reader/rss" 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | 10 | "miniflux.app/v2/internal/model" 11 | "miniflux.app/v2/internal/reader/xml" 12 | ) 13 | 14 | // Parse returns a normalized feed struct from a RSS feed. 15 | func Parse(baseURL string, data io.Reader) (*model.Feed, error) { 16 | feed := new(rssFeed) 17 | if err := xml.NewDecoder(data).Decode(feed); err != nil { 18 | return nil, fmt.Errorf("rss: unable to parse feed: %w", err) 19 | } 20 | return feed.Transform(baseURL), nil 21 | } 22 | -------------------------------------------------------------------------------- /internal/reader/rdf/parser.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package rdf // import "miniflux.app/v2/internal/reader/rdf" 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | 10 | "miniflux.app/v2/internal/model" 11 | "miniflux.app/v2/internal/reader/xml" 12 | ) 13 | 14 | // Parse returns a normalized feed struct from a RDF feed. 15 | func Parse(baseURL string, data io.Reader) (*model.Feed, error) { 16 | feed := new(rdfFeed) 17 | if err := xml.NewDecoder(data).Decode(feed); err != nil { 18 | return nil, fmt.Errorf("rdf: unable to parse feed: %w", err) 19 | } 20 | 21 | return feed.Transform(baseURL), nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/template/templates/common/feed_menu.html: -------------------------------------------------------------------------------- 1 | {{ define "feed_menu" }} 2 | 19 | {{ end }} 20 | -------------------------------------------------------------------------------- /internal/ui/api_key_remove.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/http/route" 12 | ) 13 | 14 | func (h *handler) removeAPIKey(w http.ResponseWriter, r *http.Request) { 15 | keyID := request.RouteInt64Param(r, "keyID") 16 | err := h.store.RemoveAPIKey(request.UserID(r), keyID) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | html.Redirect(w, r, route.Path(h.router, "apiKeys")) 23 | } 24 | -------------------------------------------------------------------------------- /internal/model/model.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model // import "miniflux.app/v2/internal/model" 5 | 6 | // OptionalString populates an optional string field. 7 | func OptionalString(value string) *string { 8 | if value != "" { 9 | return &value 10 | } 11 | return nil 12 | } 13 | 14 | // OptionalInt populates an optional int field. 15 | func OptionalInt(value int) *int { 16 | if value > 0 { 17 | return &value 18 | } 19 | return nil 20 | } 21 | 22 | // OptionalInt64 populates an optional int64 field. 23 | func OptionalInt64(value int64) *int64 { 24 | if value > 0 { 25 | return &value 26 | } 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /internal/ui/opml_export.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/http/response/xml" 12 | "miniflux.app/v2/internal/reader/opml" 13 | ) 14 | 15 | func (h *handler) exportFeeds(w http.ResponseWriter, r *http.Request) { 16 | opmlExport, err := opml.NewHandler(h.store).Export(request.UserID(r)) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | xml.Attachment(w, r, "feeds.opml", opmlExport) 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/build_binaries.yml: -------------------------------------------------------------------------------- 1 | name: Build Binaries 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+' 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Set up Golang 13 | uses: actions/setup-go@v4 14 | with: 15 | go-version: "1.21" 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Compile binaries 19 | run: make build 20 | - name: Upload binaries 21 | uses: actions/upload-artifact@v3 22 | with: 23 | name: miniflux-linux-amd64 24 | path: miniflux-linux-amd64 25 | if-no-files-found: error 26 | retention-days: 5 27 | -------------------------------------------------------------------------------- /internal/template/templates/standalone/offline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ t "page.offline.title" }} - Miniflux 6 | 7 | 8 | 9 | 10 | 11 | 12 |

{{ t "page.offline.message" }} - {{ t "page.offline.refresh_page" }}.

13 | 14 | -------------------------------------------------------------------------------- /internal/ui/session_remove.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/http/route" 12 | ) 13 | 14 | func (h *handler) removeSession(w http.ResponseWriter, r *http.Request) { 15 | sessionID := request.RouteInt64Param(r, "sessionID") 16 | err := h.store.RemoveUserSessionByID(request.UserID(r), sessionID) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | html.Redirect(w, r, route.Path(h.router, "sessions")) 23 | } 24 | -------------------------------------------------------------------------------- /internal/oauth2/provider.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package oauth2 // import "miniflux.app/v2/internal/oauth2" 5 | 6 | import ( 7 | "context" 8 | 9 | "golang.org/x/oauth2" 10 | 11 | "miniflux.app/v2/internal/model" 12 | ) 13 | 14 | // Provider is an interface for OAuth2 providers. 15 | type Provider interface { 16 | GetConfig() *oauth2.Config 17 | GetUserExtraKey() string 18 | GetProfile(ctx context.Context, code, codeVerifier string) (*Profile, error) 19 | PopulateUserCreationWithProfileID(user *model.UserCreationRequest, profile *Profile) 20 | PopulateUserWithProfileID(user *model.User, profile *Profile) 21 | UnsetUserProfileID(user *model.User) 22 | } 23 | -------------------------------------------------------------------------------- /internal/model/subscription.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model // import "miniflux.app/v2/internal/model" 5 | 6 | // SubscriptionDiscoveryRequest represents a request to discover subscriptions. 7 | type SubscriptionDiscoveryRequest struct { 8 | URL string `json:"url"` 9 | UserAgent string `json:"user_agent"` 10 | Cookie string `json:"cookie"` 11 | Username string `json:"username"` 12 | Password string `json:"password"` 13 | FetchViaProxy bool `json:"fetch_via_proxy"` 14 | AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"` 15 | } 16 | -------------------------------------------------------------------------------- /internal/reader/opml/subscription.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package opml // import "miniflux.app/v2/internal/reader/opml" 5 | 6 | // Subcription represents a feed that will be imported or exported. 7 | type Subcription struct { 8 | Title string 9 | SiteURL string 10 | FeedURL string 11 | CategoryName string 12 | } 13 | 14 | // Equals compare two subscriptions. 15 | func (s Subcription) Equals(subscription *Subcription) bool { 16 | return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL && 17 | s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName 18 | } 19 | 20 | // SubcriptionList is a list of subscriptions. 21 | type SubcriptionList []*Subcription 22 | -------------------------------------------------------------------------------- /internal/ui/feed_remove.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/http/route" 12 | ) 13 | 14 | func (h *handler) removeFeed(w http.ResponseWriter, r *http.Request) { 15 | feedID := request.RouteInt64Param(r, "feedID") 16 | 17 | if !h.store.FeedExists(request.UserID(r), feedID) { 18 | html.NotFound(w, r) 19 | return 20 | } 21 | 22 | if err := h.store.RemoveFeed(request.UserID(r), feedID); err != nil { 23 | html.ServerError(w, r, err) 24 | return 25 | } 26 | 27 | html.Redirect(w, r, route.Path(h.router, "feeds")) 28 | } 29 | -------------------------------------------------------------------------------- /internal/reader/subscription/subscription.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package subscription // import "miniflux.app/v2/internal/reader/subscription" 5 | 6 | import "fmt" 7 | 8 | // Subscription represents a feed subscription. 9 | type Subscription struct { 10 | Title string `json:"title"` 11 | URL string `json:"url"` 12 | Type string `json:"type"` 13 | } 14 | 15 | func NewSubscription(title, url, kind string) *Subscription { 16 | return &Subscription{Title: title, URL: url, Type: kind} 17 | } 18 | 19 | func (s Subscription) String() string { 20 | return fmt.Sprintf(`Title="%s", URL="%s", Type="%s"`, s.Title, s.URL, s.Type) 21 | } 22 | 23 | // Subscriptions represents a list of subscription. 24 | type Subscriptions []*Subscription 25 | -------------------------------------------------------------------------------- /client/doc.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | /* 5 | Package client implements a client library for the Miniflux REST API. 6 | 7 | # Examples 8 | 9 | This code snippet fetch the list of users: 10 | 11 | import ( 12 | miniflux "miniflux.app/v2/client" 13 | ) 14 | 15 | client := miniflux.New("https://api.example.org", "admin", "secret") 16 | users, err := client.Users() 17 | if err != nil { 18 | fmt.Println(err) 19 | return 20 | } 21 | fmt.Println(users, err) 22 | 23 | This one discover subscriptions on a website: 24 | 25 | subscriptions, err := client.Discover("https://example.org/") 26 | if err != nil { 27 | fmt.Println(err) 28 | return 29 | } 30 | fmt.Println(subscriptions) 31 | */ 32 | package client // import "miniflux.app/v2/client" 33 | -------------------------------------------------------------------------------- /internal/ui/form/api_key.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package form // import "miniflux.app/v2/internal/ui/form" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/locale" 10 | ) 11 | 12 | // APIKeyForm represents the API Key form. 13 | type APIKeyForm struct { 14 | Description string 15 | } 16 | 17 | // Validate makes sure the form values are valid. 18 | func (a APIKeyForm) Validate() *locale.LocalizedError { 19 | if a.Description == "" { 20 | return locale.NewLocalizedError("error.fields_mandatory") 21 | } 22 | 23 | return nil 24 | } 25 | 26 | // NewAPIKeyForm returns a new APIKeyForm. 27 | func NewAPIKeyForm(r *http.Request) *APIKeyForm { 28 | return &APIKeyForm{ 29 | Description: r.FormValue("description"), 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packaging/rpm/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1 AS build 2 | ENV CGO_ENABLED=0 3 | ADD . /go/src/app 4 | WORKDIR /go/src/app 5 | RUN make miniflux 6 | 7 | FROM rockylinux:9 8 | RUN dnf install -y rpm-build systemd 9 | RUN mkdir -p /root/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS} 10 | RUN echo "%_topdir /root/rpmbuild" >> .rpmmacros 11 | COPY --from=build /go/src/app/miniflux /root/rpmbuild/SOURCES/miniflux 12 | COPY --from=build /go/src/app/LICENSE /root/rpmbuild/SOURCES/ 13 | COPY --from=build /go/src/app/ChangeLog /root/rpmbuild/SOURCES/ 14 | COPY --from=build /go/src/app/miniflux.1 /root/rpmbuild/SOURCES/ 15 | COPY --from=build /go/src/app/packaging/systemd/miniflux.service /root/rpmbuild/SOURCES/ 16 | COPY --from=build /go/src/app/packaging/miniflux.conf /root/rpmbuild/SOURCES/ 17 | COPY --from=build /go/src/app/packaging/rpm/miniflux.spec /root/rpmbuild/SPECS/miniflux.spec 18 | -------------------------------------------------------------------------------- /internal/locale/locale.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package locale // import "miniflux.app/v2/internal/locale" 5 | 6 | // AvailableLanguages returns the list of available languages. 7 | func AvailableLanguages() map[string]string { 8 | return map[string]string{ 9 | "en_US": "English", 10 | "es_ES": "Español", 11 | "fr_FR": "Français", 12 | "de_DE": "Deutsch", 13 | "pl_PL": "Polski", 14 | "pt_BR": "Português Brasileiro", 15 | "zh_CN": "简体中文", 16 | "zh_TW": "繁體中文", 17 | "nl_NL": "Nederlands", 18 | "ru_RU": "Русский", 19 | "it_IT": "Italiano", 20 | "ja_JP": "日本語", 21 | "tr_TR": "Türkçe", 22 | "el_EL": "Ελληνικά", 23 | "fi_FI": "Suomi", 24 | "hi_IN": "हिन्दी", 25 | "uk_UA": "Українська", 26 | "id_ID": "Bahasa Indonesia", 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | app: 4 | image: mcr.microsoft.com/devcontainers/go 5 | volumes: 6 | - ..:/workspace:cached 7 | command: sleep infinity 8 | network_mode: service:db 9 | environment: 10 | - CREATE_ADMIN=1 11 | - ADMIN_USERNAME=admin 12 | - ADMIN_PASSWORD=test123 13 | db: 14 | image: postgres:15 15 | restart: unless-stopped 16 | volumes: 17 | - postgres-data:/var/lib/postgresql/data 18 | hostname: postgres 19 | environment: 20 | POSTGRES_DB: miniflux2 21 | POSTGRES_USER: postgres 22 | POSTGRES_PASSWORD: postgres 23 | POSTGRES_HOST_AUTH_METHOD: trust 24 | ports: 25 | - 5432:5432 26 | apprise: 27 | image: caronc/apprise:latest 28 | restart: unless-stopped 29 | hostname: apprise 30 | volumes: 31 | postgres-data: null 32 | -------------------------------------------------------------------------------- /internal/model/api_key.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model // import "miniflux.app/v2/internal/model" 5 | 6 | import ( 7 | "time" 8 | 9 | "miniflux.app/v2/internal/crypto" 10 | ) 11 | 12 | // APIKey represents an application API key. 13 | type APIKey struct { 14 | ID int64 15 | UserID int64 16 | Token string 17 | Description string 18 | LastUsedAt *time.Time 19 | CreatedAt time.Time 20 | } 21 | 22 | // NewAPIKey initializes a new APIKey. 23 | func NewAPIKey(userID int64, description string) *APIKey { 24 | return &APIKey{ 25 | UserID: userID, 26 | Token: crypto.GenerateRandomString(32), 27 | Description: description, 28 | } 29 | } 30 | 31 | // APIKeys represents a collection of API Key. 32 | type APIKeys []*APIKey 33 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/OPML Import.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: OPML Import 3 | type: http 4 | seq: 31 5 | } 6 | 7 | post { 8 | url: {{minifluxBaseURL}}/v1/import 9 | body: xml 10 | auth: basic 11 | } 12 | 13 | auth:basic { 14 | username: {{minifluxUsername}} 15 | password: {{minifluxPassword}} 16 | } 17 | 18 | body:json { 19 | { 20 | "user_agent": "My user agent" 21 | } 22 | } 23 | 24 | body:xml { 25 | 26 | 27 | 28 | Miniflux 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | } 37 | 38 | vars:pre-request { 39 | feedID: 19 40 | } 41 | -------------------------------------------------------------------------------- /internal/reader/sanitizer/strip_tags.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer" 5 | 6 | import ( 7 | "bytes" 8 | "io" 9 | 10 | "golang.org/x/net/html" 11 | ) 12 | 13 | // StripTags removes all HTML/XML tags from the input string. 14 | func StripTags(input string) string { 15 | tokenizer := html.NewTokenizer(bytes.NewBufferString(input)) 16 | var buffer bytes.Buffer 17 | 18 | for { 19 | if tokenizer.Next() == html.ErrorToken { 20 | err := tokenizer.Err() 21 | if err == io.EOF { 22 | return buffer.String() 23 | } 24 | 25 | return "" 26 | } 27 | 28 | token := tokenizer.Token() 29 | switch token.Type { 30 | case html.TextToken: 31 | buffer.WriteString(token.Data) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Miniflux", 3 | "dockerComposeFile": "docker-compose.yml", 4 | "service": "app", 5 | "workspaceFolder": "/workspace", 6 | "remoteUser": "vscode", 7 | "forwardPorts": [ 8 | 8080 9 | ], 10 | "features": { 11 | "ghcr.io/devcontainers/features/github-cli:1": {}, 12 | "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "settings": { 17 | "go.toolsManagement.checkForUpdates": "local", 18 | "go.useLanguageServer": true, 19 | "go.gopath": "/go" 20 | }, 21 | "extensions": [ 22 | "ms-azuretools.vscode-docker", 23 | "golang.go", 24 | "rangav.vscode-thunder-client", 25 | "GitHub.codespaces", 26 | "GitHub.copilot", 27 | "GitHub.copilot-chat" 28 | ] 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /internal/template/templates/common/settings_menu.html: -------------------------------------------------------------------------------- 1 | {{ define "settings_menu" }} 2 | 24 | {{ end }} -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | permissions: read-all 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | jshint: 11 | name: Javascript Linter 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Install jshint 16 | run: | 17 | sudo npm install -g jshint@2.13.3 18 | - name: Run jshint 19 | run: jshint ui/static/js/*.js 20 | 21 | golangci: 22 | name: Golang Linter 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/setup-go@v4 27 | with: 28 | go-version: "1.21" 29 | - uses: golangci/golangci-lint-action@v3 30 | with: 31 | args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace 32 | -------------------------------------------------------------------------------- /internal/cli/export_feeds.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package cli // import "miniflux.app/v2/internal/cli" 5 | 6 | import ( 7 | "fmt" 8 | 9 | "miniflux.app/v2/internal/reader/opml" 10 | "miniflux.app/v2/internal/storage" 11 | ) 12 | 13 | func exportUserFeeds(store *storage.Storage, username string) { 14 | user, err := store.UserByUsername(username) 15 | if err != nil { 16 | printErrorAndExit(fmt.Errorf("unable to find user: %w", err)) 17 | } 18 | 19 | if user == nil { 20 | printErrorAndExit(fmt.Errorf("user %q not found", username)) 21 | } 22 | 23 | opmlHandler := opml.NewHandler(store) 24 | opmlExport, err := opmlHandler.Export(user.ID) 25 | if err != nil { 26 | printErrorAndExit(fmt.Errorf("unable to export feeds: %w", err)) 27 | } 28 | 29 | fmt.Println(opmlExport) 30 | } 31 | -------------------------------------------------------------------------------- /internal/http/route/route.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package route // import "miniflux.app/v2/internal/http/route" 5 | 6 | import ( 7 | "strconv" 8 | 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | // Path returns the defined route based on given arguments. 13 | func Path(router *mux.Router, name string, args ...any) string { 14 | route := router.Get(name) 15 | if route == nil { 16 | panic("route not found: " + name) 17 | } 18 | 19 | var pairs []string 20 | for _, arg := range args { 21 | switch param := arg.(type) { 22 | case string: 23 | pairs = append(pairs, param) 24 | case int64: 25 | pairs = append(pairs, strconv.FormatInt(param, 10)) 26 | } 27 | } 28 | 29 | result, err := route.URLPath(pairs...) 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | return result.String() 35 | } 36 | -------------------------------------------------------------------------------- /internal/cli/ask_credentials.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package cli // import "miniflux.app/v2/internal/cli" 5 | 6 | import ( 7 | "bufio" 8 | "fmt" 9 | "os" 10 | "strings" 11 | 12 | "golang.org/x/term" 13 | ) 14 | 15 | func askCredentials() (string, string) { 16 | fd := int(os.Stdin.Fd()) 17 | 18 | if !term.IsTerminal(fd) { 19 | printErrorAndExit(fmt.Errorf("this is not a terminal, exiting")) 20 | } 21 | 22 | fmt.Print("Enter Username: ") 23 | 24 | reader := bufio.NewReader(os.Stdin) 25 | username, _ := reader.ReadString('\n') 26 | 27 | fmt.Print("Enter Password: ") 28 | 29 | state, _ := term.GetState(fd) 30 | defer term.Restore(fd, state) 31 | bytePassword, _ := term.ReadPassword(fd) 32 | 33 | fmt.Printf("\n") 34 | return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword)) 35 | } 36 | -------------------------------------------------------------------------------- /internal/model/icon.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model // import "miniflux.app/v2/internal/model" 5 | 6 | import ( 7 | "encoding/base64" 8 | "fmt" 9 | ) 10 | 11 | // Icon represents a website icon (favicon) 12 | type Icon struct { 13 | ID int64 `json:"id"` 14 | Hash string `json:"hash"` 15 | MimeType string `json:"mime_type"` 16 | Content []byte `json:"-"` 17 | } 18 | 19 | // DataURL returns the data URL of the icon. 20 | func (i *Icon) DataURL() string { 21 | return fmt.Sprintf("%s;base64,%s", i.MimeType, base64.StdEncoding.EncodeToString(i.Content)) 22 | } 23 | 24 | // Icons represents a list of icons. 25 | type Icons []*Icon 26 | 27 | // FeedIcon is a junction table between feeds and icons. 28 | type FeedIcon struct { 29 | FeedID int64 `json:"feed_id"` 30 | IconID int64 `json:"icon_id"` 31 | } 32 | -------------------------------------------------------------------------------- /internal/worker/pool.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package worker // import "miniflux.app/v2/internal/worker" 5 | 6 | import ( 7 | "miniflux.app/v2/internal/model" 8 | "miniflux.app/v2/internal/storage" 9 | ) 10 | 11 | // Pool handles a pool of workers. 12 | type Pool struct { 13 | queue chan model.Job 14 | } 15 | 16 | // Push send a list of jobs to the queue. 17 | func (p *Pool) Push(jobs model.JobList) { 18 | for _, job := range jobs { 19 | p.queue <- job 20 | } 21 | } 22 | 23 | // NewPool creates a pool of background workers. 24 | func NewPool(store *storage.Storage, nbWorkers int) *Pool { 25 | workerPool := &Pool{ 26 | queue: make(chan model.Job), 27 | } 28 | 29 | for i := 0; i < nbWorkers; i++ { 30 | worker := &Worker{id: i, store: store} 31 | go worker.Run(workerPool.queue) 32 | } 33 | 34 | return workerPool 35 | } 36 | -------------------------------------------------------------------------------- /internal/template/templates/common/entry_pagination.html: -------------------------------------------------------------------------------- 1 | {{ define "entry_pagination" }} 2 | 19 | {{ end }} 20 | -------------------------------------------------------------------------------- /internal/ui/static_stylesheet.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | "time" 9 | 10 | "miniflux.app/v2/internal/http/request" 11 | "miniflux.app/v2/internal/http/response" 12 | "miniflux.app/v2/internal/http/response/html" 13 | "miniflux.app/v2/internal/ui/static" 14 | ) 15 | 16 | func (h *handler) showStylesheet(w http.ResponseWriter, r *http.Request) { 17 | filename := request.RouteStringParam(r, "name") 18 | etag, found := static.StylesheetBundleChecksums[filename] 19 | if !found { 20 | html.NotFound(w, r) 21 | return 22 | } 23 | 24 | response.New(w, r).WithCaching(etag, 48*time.Hour, func(b *response.Builder) { 25 | b.WithHeader("Content-Type", "text/css; charset=utf-8") 26 | b.WithBody(static.StylesheetBundles[filename]) 27 | b.Write() 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | permissions: read-all 4 | 5 | on: 6 | push: 7 | branches: [ main ] 8 | pull_request: 9 | # The branches below must be a subset of the branches above 10 | branches: [ main ] 11 | schedule: 12 | - cron: '45 22 * * 3' 13 | 14 | jobs: 15 | analyze: 16 | name: Analyze 17 | runs-on: ubuntu-latest 18 | permissions: 19 | actions: read 20 | contents: read 21 | security-events: write 22 | 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | language: [ 'go', 'javascript' ] 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | 32 | - name: Initialize CodeQL 33 | uses: github/codeql-action/init@v2 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | -------------------------------------------------------------------------------- /internal/api/payload.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package api // import "miniflux.app/v2/internal/api" 5 | 6 | import ( 7 | "miniflux.app/v2/internal/model" 8 | ) 9 | 10 | type feedIconResponse struct { 11 | ID int64 `json:"id"` 12 | MimeType string `json:"mime_type"` 13 | Data string `json:"data"` 14 | } 15 | 16 | type entriesResponse struct { 17 | Total int `json:"total"` 18 | Entries model.Entries `json:"entries"` 19 | } 20 | 21 | type feedCreationResponse struct { 22 | FeedID int64 `json:"feed_id"` 23 | } 24 | 25 | type versionResponse struct { 26 | Version string `json:"version"` 27 | Commit string `json:"commit"` 28 | BuildDate string `json:"build_date"` 29 | GoVersion string `json:"go_version"` 30 | Compiler string `json:"compiler"` 31 | Arch string `json:"arch"` 32 | OS string `json:"os"` 33 | } 34 | -------------------------------------------------------------------------------- /internal/template/templates/views/webauthn_rename.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.webauthn_rename.title" }}{{ end }} 2 | 3 | {{ define "content"}} 4 | 7 | 8 |
9 | 10 | 11 | {{ if .errorMessage }} 12 |
{{ .errorMessage }}
13 | {{ end }} 14 | 15 | 16 | 17 | 18 |
19 | 20 |
21 |
22 | {{ end }} 23 | -------------------------------------------------------------------------------- /internal/ui/form/auth.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package form // import "miniflux.app/v2/internal/ui/form" 5 | 6 | import ( 7 | "net/http" 8 | "strings" 9 | 10 | "miniflux.app/v2/internal/locale" 11 | ) 12 | 13 | // AuthForm represents the authentication form. 14 | type AuthForm struct { 15 | Username string 16 | Password string 17 | } 18 | 19 | // Validate makes sure the form values are valid. 20 | func (a AuthForm) Validate() *locale.LocalizedError { 21 | if a.Username == "" || a.Password == "" { 22 | return locale.NewLocalizedError("error.fields_mandatory") 23 | } 24 | 25 | return nil 26 | } 27 | 28 | // NewAuthForm returns a new AuthForm. 29 | func NewAuthForm(r *http.Request) *AuthForm { 30 | return &AuthForm{ 31 | Username: strings.TrimSpace(r.FormValue("username")), 32 | Password: strings.TrimSpace(r.FormValue("password")), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/ui/login_show.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/http/route" 12 | "miniflux.app/v2/internal/ui/session" 13 | "miniflux.app/v2/internal/ui/view" 14 | ) 15 | 16 | func (h *handler) showLoginPage(w http.ResponseWriter, r *http.Request) { 17 | if request.IsAuthenticated(r) { 18 | user, err := h.store.UserByID(request.UserID(r)) 19 | if err != nil { 20 | html.ServerError(w, r, err) 21 | return 22 | } 23 | 24 | html.Redirect(w, r, route.Path(h.router, user.DefaultHomePage)) 25 | return 26 | } 27 | 28 | sess := session.New(h.store, request.SessionID(r)) 29 | view := view.New(h.tpl, r, sess) 30 | html.OK(w, r, view.Render("login")) 31 | } 32 | -------------------------------------------------------------------------------- /internal/ui/static_favicon.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | "time" 9 | 10 | "miniflux.app/v2/internal/http/response" 11 | "miniflux.app/v2/internal/http/response/html" 12 | "miniflux.app/v2/internal/ui/static" 13 | ) 14 | 15 | func (h *handler) showFavicon(w http.ResponseWriter, r *http.Request) { 16 | etag, err := static.GetBinaryFileChecksum("favicon.ico") 17 | if err != nil { 18 | html.NotFound(w, r) 19 | return 20 | } 21 | 22 | response.New(w, r).WithCaching(etag, 48*time.Hour, func(b *response.Builder) { 23 | blob, err := static.LoadBinaryFile("favicon.ico") 24 | if err != nil { 25 | html.ServerError(w, r, err) 26 | return 27 | } 28 | 29 | b.WithHeader("Content-Type", "image/x-icon") 30 | b.WithoutCompression() 31 | b.WithBody(blob) 32 | b.Write() 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /internal/http/response/xml/xml.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package xml // import "miniflux.app/v2/internal/http/response/xml" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/response" 10 | ) 11 | 12 | // OK writes a standard XML response with a status 200 OK. 13 | func OK(w http.ResponseWriter, r *http.Request, body interface{}) { 14 | builder := response.New(w, r) 15 | builder.WithHeader("Content-Type", "text/xml; charset=utf-8") 16 | builder.WithBody(body) 17 | builder.Write() 18 | } 19 | 20 | // Attachment forces the XML document to be downloaded by the web browser. 21 | func Attachment(w http.ResponseWriter, r *http.Request, filename string, body interface{}) { 22 | builder := response.New(w, r) 23 | builder.WithHeader("Content-Type", "text/xml; charset=utf-8") 24 | builder.WithAttachment(filename) 25 | builder.WithBody(body) 26 | builder.Write() 27 | } 28 | -------------------------------------------------------------------------------- /internal/ui/feed_mark_as_read.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/http/route" 12 | ) 13 | 14 | func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) { 15 | feedID := request.RouteInt64Param(r, "feedID") 16 | userID := request.UserID(r) 17 | 18 | feed, err := h.store.FeedByID(userID, feedID) 19 | 20 | if err != nil { 21 | html.ServerError(w, r, err) 22 | return 23 | } 24 | 25 | if feed == nil { 26 | html.NotFound(w, r) 27 | return 28 | } 29 | 30 | if err = h.store.MarkFeedAsRead(userID, feedID, feed.CheckedAt); err != nil { 31 | html.ServerError(w, r, err) 32 | return 33 | } 34 | 35 | html.Redirect(w, r, route.Path(h.router, "feeds")) 36 | } 37 | -------------------------------------------------------------------------------- /internal/http/request/cookie_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package request // import "miniflux.app/v2/internal/http/request" 5 | 6 | import ( 7 | "net/http" 8 | "testing" 9 | ) 10 | 11 | func TestGetCookieValue(t *testing.T) { 12 | r, _ := http.NewRequest("GET", "http://example.org", nil) 13 | r.AddCookie(&http.Cookie{Value: "cookie_value", Name: "my_cookie"}) 14 | 15 | result := CookieValue(r, "my_cookie") 16 | expected := "cookie_value" 17 | 18 | if result != expected { 19 | t.Errorf(`Unexpected cookie value, got %q instead of %q`, result, expected) 20 | } 21 | } 22 | 23 | func TestGetCookieValueWhenUnset(t *testing.T) { 24 | r, _ := http.NewRequest("GET", "http://example.org", nil) 25 | 26 | result := CookieValue(r, "my_cookie") 27 | expected := "" 28 | 29 | if result != expected { 30 | t.Errorf(`Unexpected cookie value, got %q instead of %q`, result, expected) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/template/templates/common/pagination.html: -------------------------------------------------------------------------------- 1 | {{ define "pagination" }} 2 | 19 | {{ end }} 20 | -------------------------------------------------------------------------------- /internal/ui/opml_import.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/session" 12 | "miniflux.app/v2/internal/ui/view" 13 | ) 14 | 15 | func (h *handler) showImportPage(w http.ResponseWriter, r *http.Request) { 16 | user, err := h.store.UserByID(request.UserID(r)) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | sess := session.New(h.store, request.SessionID(r)) 23 | view := view.New(h.tpl, r, sess) 24 | view.Set("menu", "feeds") 25 | view.Set("user", user) 26 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 27 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 28 | 29 | html.OK(w, r, view.Render("import")) 30 | } 31 | -------------------------------------------------------------------------------- /internal/ui/category_create.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/session" 12 | "miniflux.app/v2/internal/ui/view" 13 | ) 14 | 15 | func (h *handler) showCreateCategoryPage(w http.ResponseWriter, r *http.Request) { 16 | user, err := h.store.UserByID(request.UserID(r)) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | sess := session.New(h.store, request.SessionID(r)) 23 | view := view.New(h.tpl, r, sess) 24 | view.Set("menu", "categories") 25 | view.Set("user", user) 26 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 27 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 28 | 29 | html.OK(w, r, view.Render("create_category")) 30 | } 31 | -------------------------------------------------------------------------------- /internal/ui/category_mark_as_read.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | "time" 9 | 10 | "miniflux.app/v2/internal/http/request" 11 | "miniflux.app/v2/internal/http/response/html" 12 | "miniflux.app/v2/internal/http/route" 13 | ) 14 | 15 | func (h *handler) markCategoryAsRead(w http.ResponseWriter, r *http.Request) { 16 | userID := request.UserID(r) 17 | categoryID := request.RouteInt64Param(r, "categoryID") 18 | 19 | category, err := h.store.Category(userID, categoryID) 20 | if err != nil { 21 | html.ServerError(w, r, err) 22 | return 23 | } 24 | 25 | if category == nil { 26 | html.NotFound(w, r) 27 | return 28 | } 29 | 30 | if err = h.store.MarkCategoryAsRead(userID, categoryID, time.Now()); err != nil { 31 | html.ServerError(w, r, err) 32 | return 33 | } 34 | 35 | html.Redirect(w, r, route.Path(h.router, "categories")) 36 | } 37 | -------------------------------------------------------------------------------- /internal/ui/feed_icon.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | "time" 9 | 10 | "miniflux.app/v2/internal/http/request" 11 | "miniflux.app/v2/internal/http/response" 12 | "miniflux.app/v2/internal/http/response/html" 13 | ) 14 | 15 | func (h *handler) showIcon(w http.ResponseWriter, r *http.Request) { 16 | iconID := request.RouteInt64Param(r, "iconID") 17 | icon, err := h.store.IconByID(iconID) 18 | if err != nil { 19 | html.ServerError(w, r, err) 20 | return 21 | } 22 | 23 | if icon == nil { 24 | html.NotFound(w, r) 25 | return 26 | } 27 | 28 | response.New(w, r).WithCaching(icon.Hash, 72*time.Hour, func(b *response.Builder) { 29 | b.WithHeader("Content-Security-Policy", `default-src 'self'`) 30 | b.WithHeader("Content-Type", icon.MimeType) 31 | b.WithBody(icon.Content) 32 | b.WithoutCompression() 33 | b.Write() 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /contrib/docker-compose/basic.yml: -------------------------------------------------------------------------------- 1 | services: 2 | miniflux: 3 | image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest} 4 | container_name: miniflux 5 | restart: always 6 | ports: 7 | - "80:8080" 8 | depends_on: 9 | db: 10 | condition: service_healthy 11 | environment: 12 | - DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable 13 | - RUN_MIGRATIONS=1 14 | - CREATE_ADMIN=1 15 | - ADMIN_USERNAME=admin 16 | - ADMIN_PASSWORD=test123 17 | - DEBUG=1 18 | # Optional health check: 19 | # healthcheck: 20 | # test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"] 21 | db: 22 | image: postgres:15 23 | container_name: postgres 24 | environment: 25 | - POSTGRES_USER=miniflux 26 | - POSTGRES_PASSWORD=secret 27 | volumes: 28 | - miniflux-db:/var/lib/postgresql/data 29 | healthcheck: 30 | test: ["CMD", "pg_isready", "-U", "miniflux"] 31 | interval: 10s 32 | start_period: 30s 33 | volumes: 34 | miniflux-db: 35 | -------------------------------------------------------------------------------- /internal/storage/timezone.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package storage // import "miniflux.app/v2/internal/storage" 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | ) 10 | 11 | // Timezones returns all timezones supported by the database. 12 | func (s *Storage) Timezones() (map[string]string, error) { 13 | timezones := make(map[string]string) 14 | rows, err := s.db.Query(`SELECT name FROM pg_timezone_names() ORDER BY name ASC`) 15 | if err != nil { 16 | return nil, fmt.Errorf(`store: unable to fetch timezones: %v`, err) 17 | } 18 | defer rows.Close() 19 | 20 | for rows.Next() { 21 | var timezone string 22 | if err := rows.Scan(&timezone); err != nil { 23 | return nil, fmt.Errorf(`store: unable to fetch timezones row: %v`, err) 24 | } 25 | 26 | if !strings.HasPrefix(timezone, "posix") && !strings.HasPrefix(timezone, "SystemV") && timezone != "localtime" { 27 | timezones[timezone] = timezone 28 | } 29 | } 30 | 31 | return timezones, nil 32 | } 33 | -------------------------------------------------------------------------------- /internal/cli/health_check.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package cli // import "miniflux.app/v2/internal/cli" 5 | 6 | import ( 7 | "fmt" 8 | "log/slog" 9 | "net/http" 10 | "time" 11 | 12 | "miniflux.app/v2/internal/config" 13 | ) 14 | 15 | func doHealthCheck(healthCheckEndpoint string) { 16 | if healthCheckEndpoint == "auto" { 17 | healthCheckEndpoint = "http://" + config.Opts.ListenAddr() + config.Opts.BasePath() + "/healthcheck" 18 | } 19 | 20 | slog.Debug("Executing health check request", slog.String("endpoint", healthCheckEndpoint)) 21 | 22 | client := &http.Client{Timeout: 3 * time.Second} 23 | resp, err := client.Get(healthCheckEndpoint) 24 | if err != nil { 25 | printErrorAndExit(fmt.Errorf(`health check failure: %v`, err)) 26 | } 27 | defer resp.Body.Close() 28 | 29 | if resp.StatusCode != 200 { 30 | printErrorAndExit(fmt.Errorf(`health check failed with status code %d`, resp.StatusCode)) 31 | } 32 | 33 | slog.Debug(`Health check is passing`) 34 | } 35 | -------------------------------------------------------------------------------- /internal/ui/static/js/request_builder.js: -------------------------------------------------------------------------------- 1 | class RequestBuilder { 2 | constructor(url) { 3 | this.callback = null; 4 | this.url = url; 5 | this.options = { 6 | method: "POST", 7 | cache: "no-cache", 8 | credentials: "include", 9 | body: null, 10 | headers: new Headers({ 11 | "Content-Type": "application/json", 12 | "X-Csrf-Token": getCsrfToken() 13 | }) 14 | }; 15 | } 16 | 17 | withHttpMethod(method) { 18 | this.options.method = method; 19 | return this; 20 | } 21 | 22 | withBody(body) { 23 | this.options.body = JSON.stringify(body); 24 | return this; 25 | } 26 | 27 | withCallback(callback) { 28 | this.callback = callback; 29 | return this; 30 | } 31 | 32 | execute() { 33 | fetch(new Request(this.url, this.options)).then((response) => { 34 | if (this.callback) { 35 | this.callback(response); 36 | } 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/template/templates/views/create_api_key.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.new_api_key.title" }}{{ end }} 2 | 3 | {{ define "content"}} 4 | 8 | 9 |
10 | 11 | 12 | {{ if .errorMessage }} 13 |
{{ .errorMessage }}
14 | {{ end }} 15 | 16 | 17 | 18 | 19 |
20 | {{ t "action.or" }} {{ t "action.cancel" }} 21 |
22 |
23 | {{ end }} 24 | -------------------------------------------------------------------------------- /.github/workflows/rpm_packages.yml: -------------------------------------------------------------------------------- 1 | name: RPM Packages 2 | permissions: read-all 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+' 7 | pull_request: 8 | branches: [ main ] 9 | jobs: 10 | test-package: 11 | if: github.event.pull_request 12 | name: Test Packages 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - name: Build RPM Package 19 | run: make rpm 20 | - name: List generated files 21 | run: ls -l *.rpm 22 | publish-package: 23 | if: ${{ ! github.event.pull_request }} 24 | name: Publish Packages 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | - name: Build RPM Package 31 | run: make rpm 32 | - name: List generated files 33 | run: ls -l *.rpm 34 | - name: Upload package to repository 35 | env: 36 | FURY_TOKEN: ${{ secrets.FURY_TOKEN }} 37 | run: for f in *.rpm; do curl -F package=@$f https://$FURY_TOKEN@push.fury.io/miniflux/; done 38 | -------------------------------------------------------------------------------- /internal/ui/api_key_create.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/form" 12 | "miniflux.app/v2/internal/ui/session" 13 | "miniflux.app/v2/internal/ui/view" 14 | ) 15 | 16 | func (h *handler) showCreateAPIKeyPage(w http.ResponseWriter, r *http.Request) { 17 | sess := session.New(h.store, request.SessionID(r)) 18 | view := view.New(h.tpl, r, sess) 19 | 20 | user, err := h.store.UserByID(request.UserID(r)) 21 | if err != nil { 22 | html.ServerError(w, r, err) 23 | return 24 | } 25 | 26 | view.Set("form", &form.APIKeyForm{}) 27 | view.Set("menu", "settings") 28 | view.Set("user", user) 29 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 30 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 31 | 32 | html.OK(w, r, view.Render("create_api_key")) 33 | } 34 | -------------------------------------------------------------------------------- /internal/ui/category_remove.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/http/route" 12 | ) 13 | 14 | func (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) { 15 | user, err := h.store.UserByID(request.UserID(r)) 16 | if err != nil { 17 | html.ServerError(w, r, err) 18 | return 19 | } 20 | 21 | categoryID := request.RouteInt64Param(r, "categoryID") 22 | category, err := h.store.Category(request.UserID(r), categoryID) 23 | if err != nil { 24 | html.ServerError(w, r, err) 25 | return 26 | } 27 | 28 | if category == nil { 29 | html.NotFound(w, r) 30 | return 31 | } 32 | 33 | if err := h.store.RemoveCategory(user.ID, category.ID); err != nil { 34 | html.ServerError(w, r, err) 35 | return 36 | } 37 | 38 | html.Redirect(w, r, route.Path(h.router, "categories")) 39 | } 40 | -------------------------------------------------------------------------------- /internal/ui/pagination.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | type pagination struct { 7 | Route string 8 | Total int 9 | Offset int 10 | ItemsPerPage int 11 | ShowNext bool 12 | ShowPrev bool 13 | NextOffset int 14 | PrevOffset int 15 | SearchQuery string 16 | } 17 | 18 | func getPagination(route string, total, offset, nbItemsPerPage int) pagination { 19 | nextOffset := 0 20 | prevOffset := 0 21 | showNext := (total - offset) > nbItemsPerPage 22 | showPrev := offset > 0 23 | 24 | if showNext { 25 | nextOffset = offset + nbItemsPerPage 26 | } 27 | 28 | if showPrev { 29 | prevOffset = offset - nbItemsPerPage 30 | } 31 | 32 | return pagination{ 33 | Route: route, 34 | Total: total, 35 | Offset: offset, 36 | ItemsPerPage: nbItemsPerPage, 37 | ShowNext: showNext, 38 | NextOffset: nextOffset, 39 | ShowPrev: showPrev, 40 | PrevOffset: prevOffset, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packaging/docker/distroless/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:latest AS build 2 | ENV CGO_ENABLED=0 3 | ADD . /go/src/app 4 | WORKDIR /go/src/app 5 | RUN go build \ 6 | -o miniflux \ 7 | -ldflags="-s -w -X 'miniflux.app/v2/internal/version.Version=`git describe --tags --abbrev=0`' -X 'miniflux.app/v2/internal/version.Commit=`git rev-parse --short HEAD`' -X 'miniflux.app/v2/internal/version.BuildDate=`date +%FT%T%z`'" \ 8 | main.go 9 | 10 | FROM gcr.io/distroless/base 11 | 12 | LABEL org.opencontainers.image.title=Miniflux 13 | LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader" 14 | LABEL org.opencontainers.image.vendor="Frédéric Guillot" 15 | LABEL org.opencontainers.image.licenses=Apache-2.0 16 | LABEL org.opencontainers.image.url=https://miniflux.app 17 | LABEL org.opencontainers.image.source=https://github.com/miniflux/v2 18 | LABEL org.opencontainers.image.documentation=https://miniflux.app/docs/ 19 | 20 | EXPOSE 8080 21 | ENV LISTEN_ADDR 0.0.0.0:8080 22 | COPY --from=build /go/src/app/miniflux /usr/bin/miniflux 23 | USER nonroot:nonroot 24 | CMD ["/usr/bin/miniflux"] 25 | -------------------------------------------------------------------------------- /internal/api/opml.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package api // import "miniflux.app/v2/internal/api" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/json" 11 | "miniflux.app/v2/internal/http/response/xml" 12 | "miniflux.app/v2/internal/reader/opml" 13 | ) 14 | 15 | func (h *handler) exportFeeds(w http.ResponseWriter, r *http.Request) { 16 | opmlHandler := opml.NewHandler(h.store) 17 | opmlExport, err := opmlHandler.Export(request.UserID(r)) 18 | if err != nil { 19 | json.ServerError(w, r, err) 20 | return 21 | } 22 | 23 | xml.OK(w, r, opmlExport) 24 | } 25 | 26 | func (h *handler) importFeeds(w http.ResponseWriter, r *http.Request) { 27 | opmlHandler := opml.NewHandler(h.store) 28 | err := opmlHandler.Import(request.UserID(r), r.Body) 29 | defer r.Body.Close() 30 | if err != nil { 31 | json.ServerError(w, r, err) 32 | return 33 | } 34 | 35 | json.Created(w, r, map[string]string{"message": "Feeds imported successfully"}) 36 | } 37 | -------------------------------------------------------------------------------- /internal/cli/reset_password.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package cli // import "miniflux.app/v2/internal/cli" 5 | 6 | import ( 7 | "fmt" 8 | 9 | "miniflux.app/v2/internal/model" 10 | "miniflux.app/v2/internal/storage" 11 | "miniflux.app/v2/internal/validator" 12 | ) 13 | 14 | func resetPassword(store *storage.Storage) { 15 | username, password := askCredentials() 16 | user, err := store.UserByUsername(username) 17 | if err != nil { 18 | printErrorAndExit(err) 19 | } 20 | 21 | if user == nil { 22 | printErrorAndExit(fmt.Errorf("user not found")) 23 | } 24 | 25 | userModificationRequest := &model.UserModificationRequest{ 26 | Password: &password, 27 | } 28 | if validationErr := validator.ValidateUserModification(store, user.ID, userModificationRequest); validationErr != nil { 29 | printErrorAndExit(validationErr.Error()) 30 | } 31 | 32 | user.Password = password 33 | if err := store.UpdateUser(user); err != nil { 34 | printErrorAndExit(err) 35 | } 36 | 37 | fmt.Println("Password changed!") 38 | } 39 | -------------------------------------------------------------------------------- /contrib/ansible/roles/mgrote.miniflux/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: add Apt-key for miniflux-repo 2 | become: yes 3 | apt_key: 4 | url: https://apt.miniflux.app/KEY.gpg 5 | state: present 6 | 7 | - name: add miniflux-repo 8 | become: yes 9 | apt_repository: 10 | repo: 'deb https://apt.miniflux.app/ /' 11 | state: present 12 | filename: miniflux_repo 13 | update_cache: yes 14 | 15 | - name: install miniflux 16 | become: yes 17 | apt: 18 | name: miniflux 19 | state: present 20 | 21 | - name: add miniflux linux_user 22 | become: yes 23 | user: 24 | name: "{{ miniflux_linux_user }}" 25 | home: "/var/empty" 26 | create_home: "no" 27 | system: "yes" 28 | shell: "/bin/false" 29 | 30 | - name: create directory "/etc/miniflux.d" 31 | become: yes 32 | file: 33 | path: /etc/miniflux.d 34 | state: directory 35 | 36 | - name: copy miniflux.conf 37 | become: yes 38 | template: 39 | src: "miniflux.conf" 40 | dest: "/etc/miniflux.conf" 41 | notify: 42 | - start_miniflux.service 43 | - miniflux_wait 44 | -------------------------------------------------------------------------------- /internal/tests/import_export_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | //go:build integration 5 | // +build integration 6 | 7 | package tests 8 | 9 | import ( 10 | "bytes" 11 | "io" 12 | "strings" 13 | "testing" 14 | ) 15 | 16 | func TestExport(t *testing.T) { 17 | client := createClient(t) 18 | 19 | output, err := client.Export() 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | if !strings.HasPrefix(string(output), " 33 | 34 | 35 | 36 | 37 | 38 | 39 | ` 40 | 41 | b := bytes.NewReader([]byte(data)) 42 | err := client.Import(io.NopCloser(b)) 43 | if err != nil { 44 | t.Fatal(err) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/template/templates/views/create_category.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.new_category.title" }}{{ end }} 2 | 3 | {{ define "content"}} 4 | 12 | 13 |
14 | 15 | 16 | {{ if .errorMessage }} 17 |
{{ .errorMessage }}
18 | {{ end }} 19 | 20 | 21 | 22 | 23 |
24 | {{ t "action.or" }} {{ t "action.cancel" }} 25 |
26 |
27 | {{ end }} 28 | -------------------------------------------------------------------------------- /internal/tests/version_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | //go:build integration 5 | // +build integration 6 | 7 | package tests 8 | 9 | import ( 10 | "testing" 11 | 12 | miniflux "miniflux.app/v2/client" 13 | ) 14 | 15 | func TestVersionEndpoint(t *testing.T) { 16 | client := miniflux.New(testBaseURL, testAdminUsername, testAdminPassword) 17 | version, err := client.Version() 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | if version.Version == "" { 23 | t.Fatal(`Version should not be empty`) 24 | } 25 | 26 | if version.Commit == "" { 27 | t.Fatal(`Commit should not be empty`) 28 | } 29 | 30 | if version.BuildDate == "" { 31 | t.Fatal(`Build date should not be empty`) 32 | } 33 | 34 | if version.GoVersion == "" { 35 | t.Fatal(`Go version should not be empty`) 36 | } 37 | 38 | if version.Compiler == "" { 39 | t.Fatal(`Compiler should not be empty`) 40 | } 41 | 42 | if version.Arch == "" { 43 | t.Fatal(`Arch should not be empty`) 44 | } 45 | 46 | if version.OS == "" { 47 | t.Fatal(`OS should not be empty`) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/ui/api_key_list.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/session" 12 | "miniflux.app/v2/internal/ui/view" 13 | ) 14 | 15 | func (h *handler) showAPIKeysPage(w http.ResponseWriter, r *http.Request) { 16 | sess := session.New(h.store, request.SessionID(r)) 17 | view := view.New(h.tpl, r, sess) 18 | 19 | user, err := h.store.UserByID(request.UserID(r)) 20 | if err != nil { 21 | html.ServerError(w, r, err) 22 | return 23 | } 24 | 25 | apiKeys, err := h.store.APIKeys(user.ID) 26 | if err != nil { 27 | html.ServerError(w, r, err) 28 | return 29 | } 30 | 31 | view.Set("apiKeys", apiKeys) 32 | view.Set("menu", "settings") 33 | view.Set("user", user) 34 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 35 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 36 | 37 | html.OK(w, r, view.Render("api_keys")) 38 | } 39 | -------------------------------------------------------------------------------- /internal/ui/user_create.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/form" 12 | "miniflux.app/v2/internal/ui/session" 13 | "miniflux.app/v2/internal/ui/view" 14 | ) 15 | 16 | func (h *handler) showCreateUserPage(w http.ResponseWriter, r *http.Request) { 17 | sess := session.New(h.store, request.SessionID(r)) 18 | view := view.New(h.tpl, r, sess) 19 | 20 | user, err := h.store.UserByID(request.UserID(r)) 21 | if err != nil { 22 | html.ServerError(w, r, err) 23 | return 24 | } 25 | 26 | if !user.IsAdmin { 27 | html.Forbidden(w, r) 28 | return 29 | } 30 | 31 | view.Set("form", &form.UserForm{}) 32 | view.Set("menu", "settings") 33 | view.Set("user", user) 34 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 35 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 36 | 37 | html.OK(w, r, view.Render("create_user")) 38 | } 39 | -------------------------------------------------------------------------------- /packaging/docker/alpine/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS build 2 | ENV CGO_ENABLED=0 3 | RUN apk add --no-cache --update git 4 | ADD . /go/src/app 5 | WORKDIR /go/src/app 6 | RUN go build \ 7 | -o miniflux \ 8 | -ldflags="-s -w -X 'miniflux.app/v2/internal/version.Version=`git describe --tags --abbrev=0`' -X 'miniflux.app/v2/internal/version.Commit=`git rev-parse --short HEAD`' -X 'miniflux.app/v2/internal/version.BuildDate=`date +%FT%T%z`'" \ 9 | main.go 10 | 11 | FROM alpine:latest 12 | 13 | LABEL org.opencontainers.image.title=Miniflux 14 | LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader" 15 | LABEL org.opencontainers.image.vendor="Frédéric Guillot" 16 | LABEL org.opencontainers.image.licenses=Apache-2.0 17 | LABEL org.opencontainers.image.url=https://miniflux.app 18 | LABEL org.opencontainers.image.source=https://github.com/miniflux/v2 19 | LABEL org.opencontainers.image.documentation=https://miniflux.app/docs/ 20 | 21 | EXPOSE 8080 22 | ENV LISTEN_ADDR 0.0.0.0:8080 23 | RUN apk --no-cache add ca-certificates tzdata 24 | COPY --from=build /go/src/app/miniflux /usr/bin/miniflux 25 | USER nobody 26 | CMD ["/usr/bin/miniflux"] 27 | -------------------------------------------------------------------------------- /internal/model/user_session.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model // import "miniflux.app/v2/internal/model" 5 | 6 | import ( 7 | "fmt" 8 | "time" 9 | 10 | "miniflux.app/v2/internal/timezone" 11 | ) 12 | 13 | // UserSession represents a user session in the system. 14 | type UserSession struct { 15 | ID int64 16 | UserID int64 17 | Token string 18 | CreatedAt time.Time 19 | UserAgent string 20 | IP string 21 | } 22 | 23 | func (u *UserSession) String() string { 24 | return fmt.Sprintf(`ID="%d", UserID="%d", IP="%s", Token="%s"`, u.ID, u.UserID, u.IP, u.Token) 25 | } 26 | 27 | // UseTimezone converts creation date to the given timezone. 28 | func (u *UserSession) UseTimezone(tz string) { 29 | u.CreatedAt = timezone.Convert(tz, u.CreatedAt) 30 | } 31 | 32 | // UserSessions represents a list of sessions. 33 | type UserSessions []*UserSession 34 | 35 | // UseTimezone converts creation date of all sessions to the given timezone. 36 | func (u UserSessions) UseTimezone(tz string) { 37 | for _, session := range u { 38 | session.UseTimezone(tz) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/ui/entry_save.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/json" 11 | "miniflux.app/v2/internal/integration" 12 | "miniflux.app/v2/internal/model" 13 | ) 14 | 15 | func (h *handler) saveEntry(w http.ResponseWriter, r *http.Request) { 16 | entryID := request.RouteInt64Param(r, "entryID") 17 | builder := h.store.NewEntryQueryBuilder(request.UserID(r)) 18 | builder.WithEntryID(entryID) 19 | builder.WithoutStatus(model.EntryStatusRemoved) 20 | 21 | entry, err := builder.GetEntry() 22 | if err != nil { 23 | json.ServerError(w, r, err) 24 | return 25 | } 26 | 27 | if entry == nil { 28 | json.NotFound(w, r) 29 | return 30 | } 31 | 32 | userIntegrations, err := h.store.Integration(request.UserID(r)) 33 | if err != nil { 34 | json.ServerError(w, r, err) 35 | return 36 | } 37 | 38 | go integration.SendEntry(entry, userIntegrations) 39 | 40 | json.Created(w, r, map[string]string{"message": "saved"}) 41 | } 42 | -------------------------------------------------------------------------------- /internal/ui/entry_update_status.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | json_parser "encoding/json" 8 | "net/http" 9 | 10 | "miniflux.app/v2/internal/http/request" 11 | "miniflux.app/v2/internal/http/response/json" 12 | "miniflux.app/v2/internal/model" 13 | "miniflux.app/v2/internal/validator" 14 | ) 15 | 16 | func (h *handler) updateEntriesStatus(w http.ResponseWriter, r *http.Request) { 17 | var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest 18 | if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil { 19 | json.BadRequest(w, r, err) 20 | return 21 | } 22 | 23 | if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil { 24 | json.BadRequest(w, r, err) 25 | return 26 | } 27 | 28 | count, err := h.store.SetEntriesStatusCount(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status) 29 | if err != nil { 30 | json.ServerError(w, r, err) 31 | return 32 | } 33 | 34 | json.OK(w, r, count) 35 | } 36 | -------------------------------------------------------------------------------- /internal/ui/feed_list.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/session" 12 | "miniflux.app/v2/internal/ui/view" 13 | ) 14 | 15 | func (h *handler) showFeedsPage(w http.ResponseWriter, r *http.Request) { 16 | user, err := h.store.UserByID(request.UserID(r)) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | feeds, err := h.store.FeedsWithCounters(user.ID) 23 | if err != nil { 24 | html.ServerError(w, r, err) 25 | return 26 | } 27 | 28 | sess := session.New(h.store, request.SessionID(r)) 29 | view := view.New(h.tpl, r, sess) 30 | view.Set("feeds", feeds) 31 | view.Set("total", len(feeds)) 32 | view.Set("menu", "feeds") 33 | view.Set("user", user) 34 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 35 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 36 | 37 | html.OK(w, r, view.Render("feeds")) 38 | } 39 | -------------------------------------------------------------------------------- /internal/model/category.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model // import "miniflux.app/v2/internal/model" 5 | 6 | import "fmt" 7 | 8 | // Category represents a feed category. 9 | type Category struct { 10 | ID int64 `json:"id"` 11 | Title string `json:"title"` 12 | UserID int64 `json:"user_id"` 13 | HideGlobally bool `json:"hide_globally"` 14 | FeedCount *int `json:"feed_count,omitempty"` 15 | TotalUnread *int `json:"total_unread,omitempty"` 16 | } 17 | 18 | func (c *Category) String() string { 19 | return fmt.Sprintf("ID=%d, UserID=%d, Title=%s", c.ID, c.UserID, c.Title) 20 | } 21 | 22 | // CategoryRequest represents the request to create or update a category. 23 | type CategoryRequest struct { 24 | Title string `json:"title"` 25 | HideGlobally string `json:"hide_globally"` 26 | } 27 | 28 | // Patch updates category fields. 29 | func (cr *CategoryRequest) Patch(category *Category) { 30 | category.Title = cr.Title 31 | category.HideGlobally = cr.HideGlobally != "" 32 | } 33 | 34 | // Categories represents a list of categories. 35 | type Categories []*Category 36 | -------------------------------------------------------------------------------- /internal/reader/dublincore/dublincore.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package dublincore // import "miniflux.app/v2/internal/reader/dublincore" 5 | 6 | import ( 7 | "strings" 8 | 9 | "miniflux.app/v2/internal/reader/sanitizer" 10 | ) 11 | 12 | // DublinCoreFeedElement represents Dublin Core feed XML elements. 13 | type DublinCoreFeedElement struct { 14 | DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ channel>creator"` 15 | } 16 | 17 | func (feed *DublinCoreFeedElement) GetSanitizedCreator() string { 18 | return strings.TrimSpace(sanitizer.StripTags(feed.DublinCoreCreator)) 19 | } 20 | 21 | // DublinCoreItemElement represents Dublin Core entry XML elements. 22 | type DublinCoreItemElement struct { 23 | DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"` 24 | DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"` 25 | DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"` 26 | } 27 | 28 | func (item *DublinCoreItemElement) GetSanitizedCreator() string { 29 | return strings.TrimSpace(sanitizer.StripTags(item.DublinCoreCreator)) 30 | } 31 | -------------------------------------------------------------------------------- /internal/ui/logout.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/config" 10 | "miniflux.app/v2/internal/http/cookie" 11 | "miniflux.app/v2/internal/http/request" 12 | "miniflux.app/v2/internal/http/response/html" 13 | "miniflux.app/v2/internal/http/route" 14 | "miniflux.app/v2/internal/ui/session" 15 | ) 16 | 17 | func (h *handler) logout(w http.ResponseWriter, r *http.Request) { 18 | sess := session.New(h.store, request.SessionID(r)) 19 | user, err := h.store.UserByID(request.UserID(r)) 20 | if err != nil { 21 | html.ServerError(w, r, err) 22 | return 23 | } 24 | 25 | sess.SetLanguage(user.Language) 26 | sess.SetTheme(user.Theme) 27 | 28 | if err := h.store.RemoveUserSessionByToken(user.ID, request.UserSessionToken(r)); err != nil { 29 | html.ServerError(w, r, err) 30 | return 31 | } 32 | 33 | http.SetCookie(w, cookie.Expired( 34 | cookie.CookieUserSessionID, 35 | config.Opts.HTTPS, 36 | config.Opts.BasePath(), 37 | )) 38 | 39 | html.Redirect(w, r, route.Path(h.router, "login")) 40 | } 41 | -------------------------------------------------------------------------------- /contrib/docker-compose/caddy.yml: -------------------------------------------------------------------------------- 1 | services: 2 | caddy: 3 | image: caddy:2 4 | container_name: caddy 5 | depends_on: 6 | - miniflux 7 | ports: 8 | - "80:80" 9 | - "443:443" 10 | volumes: 11 | - $PWD/Caddyfile:/etc/caddy/Caddyfile 12 | - caddy_data:/data 13 | - caddy_config:/config 14 | miniflux: 15 | image: ${MINIFLUX_IMAGE:-miniflux/miniflux:latest} 16 | container_name: miniflux 17 | depends_on: 18 | db: 19 | condition: service_healthy 20 | environment: 21 | - DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable 22 | - RUN_MIGRATIONS=1 23 | - CREATE_ADMIN=1 24 | - ADMIN_USERNAME=admin 25 | - ADMIN_PASSWORD=test123 26 | - BASE_URL=https://miniflux.example.org 27 | db: 28 | image: postgres:15 29 | container_name: postgres 30 | environment: 31 | - POSTGRES_USER=miniflux 32 | - POSTGRES_PASSWORD=secret 33 | volumes: 34 | - miniflux-db:/var/lib/postgresql/data 35 | healthcheck: 36 | test: ["CMD", "pg_isready", "-U", "miniflux"] 37 | interval: 10s 38 | start_period: 30s 39 | volumes: 40 | miniflux-db: 41 | caddy_data: 42 | caddy_config: 43 | -------------------------------------------------------------------------------- /internal/reader/readingtime/readingtime.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | // Package readtime provides a function to estimate the reading time of an article. 5 | package readingtime 6 | 7 | import ( 8 | "math" 9 | "strings" 10 | "unicode/utf8" 11 | 12 | "miniflux.app/v2/internal/reader/sanitizer" 13 | 14 | "github.com/abadojack/whatlanggo" 15 | ) 16 | 17 | // EstimateReadingTime returns the estimated reading time of an article in minute. 18 | func EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed int) int { 19 | sanitizedContent := sanitizer.StripTags(content) 20 | langInfo := whatlanggo.Detect(sanitizedContent) 21 | 22 | var timeToReadInt int 23 | if langInfo.IsReliable() && (langInfo.Lang == whatlanggo.Jpn || langInfo.Lang == whatlanggo.Cmn || langInfo.Lang == whatlanggo.Kor) { 24 | timeToReadInt = int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed))) 25 | } else { 26 | nbOfWords := len(strings.Fields(sanitizedContent)) 27 | timeToReadInt = int(math.Ceil(float64(nbOfWords) / float64(defaultReadingSpeed))) 28 | } 29 | 30 | return timeToReadInt 31 | } 32 | -------------------------------------------------------------------------------- /internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package storage // import "miniflux.app/v2/internal/storage" 5 | 6 | import ( 7 | "context" 8 | "database/sql" 9 | "time" 10 | ) 11 | 12 | // Storage handles all operations related to the database. 13 | type Storage struct { 14 | db *sql.DB 15 | } 16 | 17 | // NewStorage returns a new Storage. 18 | func NewStorage(db *sql.DB) *Storage { 19 | return &Storage{db} 20 | } 21 | 22 | // DatabaseVersion returns the version of the database which is in use. 23 | func (s *Storage) DatabaseVersion() string { 24 | var dbVersion string 25 | err := s.db.QueryRow(`SELECT current_setting('server_version')`).Scan(&dbVersion) 26 | if err != nil { 27 | return err.Error() 28 | } 29 | 30 | return dbVersion 31 | } 32 | 33 | // Ping checks if the database connection works. 34 | func (s *Storage) Ping() error { 35 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 36 | defer cancel() 37 | 38 | return s.db.PingContext(ctx) 39 | } 40 | 41 | // DBStats returns database statistics. 42 | func (s *Storage) DBStats() sql.DBStats { 43 | return s.db.Stats() 44 | } 45 | -------------------------------------------------------------------------------- /internal/reader/parser/parser.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package parser // import "miniflux.app/v2/internal/reader/parser" 5 | 6 | import ( 7 | "errors" 8 | "io" 9 | 10 | "miniflux.app/v2/internal/model" 11 | "miniflux.app/v2/internal/reader/atom" 12 | "miniflux.app/v2/internal/reader/json" 13 | "miniflux.app/v2/internal/reader/rdf" 14 | "miniflux.app/v2/internal/reader/rss" 15 | ) 16 | 17 | var ErrFeedFormatNotDetected = errors.New("parser: unable to detect feed format") 18 | 19 | // ParseFeed analyzes the input data and returns a normalized feed object. 20 | func ParseFeed(baseURL string, r io.ReadSeeker) (*model.Feed, error) { 21 | r.Seek(0, io.SeekStart) 22 | switch DetectFeedFormat(r) { 23 | case FormatAtom: 24 | r.Seek(0, io.SeekStart) 25 | return atom.Parse(baseURL, r) 26 | case FormatRSS: 27 | r.Seek(0, io.SeekStart) 28 | return rss.Parse(baseURL, r) 29 | case FormatJSON: 30 | r.Seek(0, io.SeekStart) 31 | return json.Parse(baseURL, r) 32 | case FormatRDF: 33 | r.Seek(0, io.SeekStart) 34 | return rdf.Parse(baseURL, r) 35 | default: 36 | return nil, ErrFeedFormatNotDetected 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/locale/plural_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package locale // import "miniflux.app/v2/internal/locale" 5 | 6 | import "testing" 7 | 8 | func TestPluralRules(t *testing.T) { 9 | scenarios := map[string]map[int]int{ 10 | "default": { 11 | 1: 0, 12 | 2: 1, 13 | 5: 1, 14 | }, 15 | "ar_AR": { 16 | 0: 0, 17 | 1: 1, 18 | 2: 2, 19 | 5: 3, 20 | 11: 4, 21 | 200: 5, 22 | }, 23 | "cs_CZ": { 24 | 1: 0, 25 | 2: 1, 26 | 5: 2, 27 | }, 28 | "pl_PL": { 29 | 1: 0, 30 | 2: 1, 31 | 5: 2, 32 | }, 33 | "pt_BR": { 34 | 1: 0, 35 | 2: 1, 36 | 5: 1, 37 | }, 38 | "ru_RU": { 39 | 1: 0, 40 | 2: 1, 41 | 5: 2, 42 | }, 43 | "sr_RS": { 44 | 1: 0, 45 | 2: 1, 46 | 5: 2, 47 | }, 48 | "zh_CN": { 49 | 1: 0, 50 | 5: 0, 51 | }, 52 | } 53 | 54 | for rule, values := range scenarios { 55 | for input, expected := range values { 56 | result := pluralForms[rule](input) 57 | if result != expected { 58 | t.Errorf(`Unexpected result for %q rule, got %d instead of %d for %d as input`, rule, result, expected, input) 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /internal/ui/static_app_icon.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | "path/filepath" 9 | "time" 10 | 11 | "miniflux.app/v2/internal/http/request" 12 | "miniflux.app/v2/internal/http/response" 13 | "miniflux.app/v2/internal/http/response/html" 14 | "miniflux.app/v2/internal/ui/static" 15 | ) 16 | 17 | func (h *handler) showAppIcon(w http.ResponseWriter, r *http.Request) { 18 | filename := request.RouteStringParam(r, "filename") 19 | etag, err := static.GetBinaryFileChecksum(filename) 20 | if err != nil { 21 | html.NotFound(w, r) 22 | return 23 | } 24 | 25 | response.New(w, r).WithCaching(etag, 72*time.Hour, func(b *response.Builder) { 26 | blob, err := static.LoadBinaryFile(filename) 27 | if err != nil { 28 | html.ServerError(w, r, err) 29 | return 30 | } 31 | 32 | switch filepath.Ext(filename) { 33 | case ".png": 34 | b.WithHeader("Content-Type", "image/png") 35 | case ".svg": 36 | b.WithHeader("Content-Type", "image/svg+xml") 37 | } 38 | 39 | b.WithoutCompression() 40 | b.WithBody(blob) 41 | b.Write() 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /internal/ui/category_list.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/session" 12 | "miniflux.app/v2/internal/ui/view" 13 | ) 14 | 15 | func (h *handler) showCategoryListPage(w http.ResponseWriter, r *http.Request) { 16 | user, err := h.store.UserByID(request.UserID(r)) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | categories, err := h.store.CategoriesWithFeedCount(user.ID) 23 | if err != nil { 24 | html.ServerError(w, r, err) 25 | return 26 | } 27 | 28 | sess := session.New(h.store, request.SessionID(r)) 29 | view := view.New(h.tpl, r, sess) 30 | view.Set("categories", categories) 31 | view.Set("total", len(categories)) 32 | view.Set("menu", "categories") 33 | view.Set("user", user) 34 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 35 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 36 | 37 | html.OK(w, r, view.Render("categories")) 38 | } 39 | -------------------------------------------------------------------------------- /internal/ui/static_javascript.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "fmt" 8 | "net/http" 9 | "time" 10 | 11 | "miniflux.app/v2/internal/http/request" 12 | "miniflux.app/v2/internal/http/response" 13 | "miniflux.app/v2/internal/http/response/html" 14 | "miniflux.app/v2/internal/http/route" 15 | "miniflux.app/v2/internal/ui/static" 16 | ) 17 | 18 | func (h *handler) showJavascript(w http.ResponseWriter, r *http.Request) { 19 | filename := request.RouteStringParam(r, "name") 20 | etag, found := static.JavascriptBundleChecksums[filename] 21 | if !found { 22 | html.NotFound(w, r) 23 | return 24 | } 25 | 26 | response.New(w, r).WithCaching(etag, 48*time.Hour, func(b *response.Builder) { 27 | contents := static.JavascriptBundles[filename] 28 | 29 | if filename == "service-worker" { 30 | variables := fmt.Sprintf(`const OFFLINE_URL="%s";`, route.Path(h.router, "offline")) 31 | contents = append([]byte(variables)[:], contents[:]...) 32 | } 33 | 34 | b.WithHeader("Content-Type", "text/javascript; charset=utf-8") 35 | b.WithBody(contents) 36 | b.Write() 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | Miniflux API Client 2 | =================== 3 | 4 | [![PkgGoDev](https://pkg.go.dev/badge/miniflux.app/v2/client)](https://pkg.go.dev/miniflux.app/v2/client) 5 | 6 | Client library for Miniflux REST API. 7 | 8 | Installation 9 | ------------ 10 | 11 | ```bash 12 | go get -u miniflux.app/v2/client 13 | ``` 14 | 15 | Example 16 | ------- 17 | 18 | ```go 19 | package main 20 | 21 | import ( 22 | "fmt" 23 | "os" 24 | 25 | miniflux "miniflux.app/v2/client" 26 | ) 27 | 28 | func main() { 29 | // Authentication with username/password: 30 | client := miniflux.New("https://api.example.org", "admin", "secret") 31 | 32 | // Authentication with an API Key: 33 | client := miniflux.New("https://api.example.org", "my-secret-token") 34 | 35 | // Fetch all feeds. 36 | feeds, err := client.Feeds() 37 | if err != nil { 38 | fmt.Println(err) 39 | return 40 | } 41 | fmt.Println(feeds) 42 | 43 | // Backup your feeds to an OPML file. 44 | opml, err := client.Export() 45 | if err != nil { 46 | fmt.Println(err) 47 | return 48 | } 49 | 50 | err = os.WriteFile("opml.xml", opml, 0644) 51 | if err != nil { 52 | fmt.Println(err) 53 | return 54 | } 55 | } 56 | ``` 57 | -------------------------------------------------------------------------------- /internal/model/theme.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model // import "miniflux.app/v2/internal/model" 5 | 6 | // Themes returns the list of available themes. 7 | func Themes() map[string]string { 8 | return map[string]string{ 9 | "light_serif": "Light - Serif", 10 | "light_sans_serif": "Light - Sans Serif", 11 | "dark_serif": "Dark - Serif", 12 | "dark_sans_serif": "Dark - Sans Serif", 13 | "system_serif": "System - Serif", 14 | "system_sans_serif": "System - Sans Serif", 15 | } 16 | } 17 | 18 | // ThemeColor returns the color for the address bar or/and the browser color. 19 | // https://developer.mozilla.org/en-US/docs/Web/Manifest#theme_color 20 | // https://developers.google.com/web/tools/lighthouse/audits/address-bar 21 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name/theme-color 22 | func ThemeColor(theme, colorScheme string) string { 23 | switch theme { 24 | case "dark_serif", "dark_sans_serif": 25 | return "#222" 26 | case "system_serif", "system_sans_serif": 27 | if colorScheme == "dark" { 28 | return "#222" 29 | } 30 | 31 | return "#fff" 32 | default: 33 | return "#fff" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/ui/user_list.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/session" 12 | "miniflux.app/v2/internal/ui/view" 13 | ) 14 | 15 | func (h *handler) showUsersPage(w http.ResponseWriter, r *http.Request) { 16 | sess := session.New(h.store, request.SessionID(r)) 17 | view := view.New(h.tpl, r, sess) 18 | 19 | user, err := h.store.UserByID(request.UserID(r)) 20 | if err != nil { 21 | html.ServerError(w, r, err) 22 | return 23 | } 24 | 25 | if !user.IsAdmin { 26 | html.Forbidden(w, r) 27 | return 28 | } 29 | 30 | users, err := h.store.Users() 31 | if err != nil { 32 | html.ServerError(w, r, err) 33 | return 34 | } 35 | 36 | users.UseTimezone(user.Timezone) 37 | 38 | view.Set("users", users) 39 | view.Set("menu", "settings") 40 | view.Set("user", user) 41 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 42 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 43 | 44 | html.OK(w, r, view.Render("users")) 45 | } 46 | -------------------------------------------------------------------------------- /internal/reader/parser/format.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package parser // import "miniflux.app/v2/internal/reader/parser" 5 | 6 | import ( 7 | "bytes" 8 | "encoding/xml" 9 | "io" 10 | 11 | rxml "miniflux.app/v2/internal/reader/xml" 12 | ) 13 | 14 | // List of feed formats. 15 | const ( 16 | FormatRDF = "rdf" 17 | FormatRSS = "rss" 18 | FormatAtom = "atom" 19 | FormatJSON = "json" 20 | FormatUnknown = "unknown" 21 | ) 22 | 23 | // DetectFeedFormat tries to guess the feed format from input data. 24 | func DetectFeedFormat(r io.ReadSeeker) string { 25 | data := make([]byte, 512) 26 | r.Read(data) 27 | 28 | if bytes.HasPrefix(bytes.TrimSpace(data), []byte("{")) { 29 | return FormatJSON 30 | } 31 | 32 | r.Seek(0, io.SeekStart) 33 | decoder := rxml.NewDecoder(r) 34 | 35 | for { 36 | token, _ := decoder.Token() 37 | if token == nil { 38 | break 39 | } 40 | 41 | if element, ok := token.(xml.StartElement); ok { 42 | switch element.Name.Local { 43 | case "rss": 44 | return FormatRSS 45 | case "feed": 46 | return FormatAtom 47 | case "RDF": 48 | return FormatRDF 49 | } 50 | } 51 | } 52 | 53 | return FormatUnknown 54 | } 55 | -------------------------------------------------------------------------------- /internal/validator/validator.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package validator // import "miniflux.app/v2/internal/validator" 5 | 6 | import ( 7 | "fmt" 8 | "net/url" 9 | "regexp" 10 | ) 11 | 12 | // ValidateRange makes sure the offset/limit values are valid. 13 | func ValidateRange(offset, limit int) error { 14 | if offset < 0 { 15 | return fmt.Errorf(`Offset value should be >= 0`) 16 | } 17 | 18 | if limit < 0 { 19 | return fmt.Errorf(`Limit value should be >= 0`) 20 | } 21 | 22 | return nil 23 | } 24 | 25 | // ValidateDirection makes sure the sorting direction is valid. 26 | func ValidateDirection(direction string) error { 27 | switch direction { 28 | case "asc", "desc": 29 | return nil 30 | } 31 | 32 | return fmt.Errorf(`Invalid direction, valid direction values are: "asc" or "desc"`) 33 | } 34 | 35 | // IsValidRegex verifies if the regex can be compiled. 36 | func IsValidRegex(expr string) bool { 37 | _, err := regexp.Compile(expr) 38 | return err == nil 39 | } 40 | 41 | // IsValidURL verifies if the provided value is a valid absolute URL. 42 | func IsValidURL(absoluteURL string) bool { 43 | _, err := url.ParseRequestURI(absoluteURL) 44 | return err == nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/cli/logger.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package cli // import "miniflux.app/v2/internal/cli" 5 | 6 | import ( 7 | "io" 8 | "log/slog" 9 | ) 10 | 11 | func InitializeDefaultLogger(logLevel string, logFile io.Writer, logFormat string, logTime bool) error { 12 | var programLogLevel = new(slog.LevelVar) 13 | switch logLevel { 14 | case "debug": 15 | programLogLevel.Set(slog.LevelDebug) 16 | case "info": 17 | programLogLevel.Set(slog.LevelInfo) 18 | case "warning": 19 | programLogLevel.Set(slog.LevelWarn) 20 | case "error": 21 | programLogLevel.Set(slog.LevelError) 22 | } 23 | 24 | logHandlerOptions := &slog.HandlerOptions{Level: programLogLevel} 25 | if !logTime { 26 | logHandlerOptions.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr { 27 | if a.Key == slog.TimeKey { 28 | return slog.Attr{} 29 | } 30 | 31 | return a 32 | } 33 | } 34 | 35 | var logger *slog.Logger 36 | switch logFormat { 37 | case "json": 38 | logger = slog.New(slog.NewJSONHandler(logFile, logHandlerOptions)) 39 | default: 40 | logger = slog.New(slog.NewTextHandler(logFile, logHandlerOptions)) 41 | } 42 | 43 | slog.SetDefault(logger) 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/ui/session_list.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/session" 12 | "miniflux.app/v2/internal/ui/view" 13 | ) 14 | 15 | func (h *handler) showSessionsPage(w http.ResponseWriter, r *http.Request) { 16 | sess := session.New(h.store, request.SessionID(r)) 17 | view := view.New(h.tpl, r, sess) 18 | 19 | user, err := h.store.UserByID(request.UserID(r)) 20 | if err != nil { 21 | html.ServerError(w, r, err) 22 | return 23 | } 24 | 25 | sessions, err := h.store.UserSessions(user.ID) 26 | if err != nil { 27 | html.ServerError(w, r, err) 28 | return 29 | } 30 | 31 | sessions.UseTimezone(user.Timezone) 32 | 33 | view.Set("currentSessionToken", request.UserSessionToken(r)) 34 | view.Set("sessions", sessions) 35 | view.Set("menu", "settings") 36 | view.Set("user", user) 37 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 38 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 39 | 40 | html.OK(w, r, view.Render("sessions")) 41 | } 42 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | reviewers: 8 | - "fguillot" 9 | assignees: 10 | - "fguillot" 11 | 12 | - package-ecosystem: "docker" 13 | directory: "/packaging/docker/alpine" 14 | schedule: 15 | interval: "weekly" 16 | reviewers: 17 | - "fguillot" 18 | assignees: 19 | - "fguillot" 20 | 21 | - package-ecosystem: "docker" 22 | directory: "/packaging/docker/distroless" 23 | schedule: 24 | interval: "weekly" 25 | reviewers: 26 | - "fguillot" 27 | assignees: 28 | - "fguillot" 29 | 30 | - package-ecosystem: "docker" 31 | directory: "packaging/debian" 32 | schedule: 33 | interval: "weekly" 34 | reviewers: 35 | - "fguillot" 36 | assignees: 37 | - "fguillot" 38 | 39 | - package-ecosystem: "docker" 40 | directory: "packaging/rpm" 41 | schedule: 42 | interval: "weekly" 43 | reviewers: 44 | - "fguillot" 45 | assignees: 46 | - "fguillot" 47 | 48 | - package-ecosystem: "github-actions" 49 | directory: "/" 50 | schedule: 51 | interval: "weekly" 52 | reviewers: 53 | - "fguillot" 54 | assignees: 55 | - "fguillot" 56 | -------------------------------------------------------------------------------- /internal/timezone/timezone.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package timezone // import "miniflux.app/v2/internal/timezone" 5 | 6 | import ( 7 | "time" 8 | ) 9 | 10 | // Convert converts provided date time to actual timezone. 11 | func Convert(tz string, t time.Time) time.Time { 12 | userTimezone := getLocation(tz) 13 | 14 | if t.Location().String() == "" { 15 | // In this case, the provided date is already converted to the user timezone by Postgres, 16 | // but the timezone information is not set in the time struct. 17 | // We cannot use time.In() because the date will be converted a second time. 18 | t = time.Date( 19 | t.Year(), 20 | t.Month(), 21 | t.Day(), 22 | t.Hour(), 23 | t.Minute(), 24 | t.Second(), 25 | t.Nanosecond(), 26 | userTimezone, 27 | ) 28 | } else if t.Location() != userTimezone { 29 | t = t.In(userTimezone) 30 | } 31 | 32 | return t 33 | } 34 | 35 | // Now returns the current time with the given timezone. 36 | func Now(tz string) time.Time { 37 | return time.Now().In(getLocation(tz)) 38 | } 39 | 40 | func getLocation(tz string) *time.Location { 41 | loc, err := time.LoadLocation(tz) 42 | if err != nil { 43 | loc = time.Local 44 | } 45 | return loc 46 | } 47 | -------------------------------------------------------------------------------- /internal/ui/user_remove.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "errors" 8 | "net/http" 9 | 10 | "miniflux.app/v2/internal/http/request" 11 | "miniflux.app/v2/internal/http/response/html" 12 | "miniflux.app/v2/internal/http/route" 13 | ) 14 | 15 | func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) { 16 | loggedUser, err := h.store.UserByID(request.UserID(r)) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | if !loggedUser.IsAdmin { 23 | html.Forbidden(w, r) 24 | return 25 | } 26 | 27 | selectedUserID := request.RouteInt64Param(r, "userID") 28 | selectedUser, err := h.store.UserByID(selectedUserID) 29 | if err != nil { 30 | html.ServerError(w, r, err) 31 | return 32 | } 33 | 34 | if selectedUser == nil { 35 | html.NotFound(w, r) 36 | return 37 | } 38 | 39 | if selectedUser.ID == loggedUser.ID { 40 | html.BadRequest(w, r, errors.New("you cannot remove yourself")) 41 | return 42 | } 43 | 44 | if err := h.store.RemoveUser(selectedUser.ID); err != nil { 45 | html.ServerError(w, r, err) 46 | return 47 | } 48 | 49 | html.Redirect(w, r, route.Path(h.router, "users")) 50 | } 51 | -------------------------------------------------------------------------------- /internal/validator/category.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package validator // import "miniflux.app/v2/internal/validator" 5 | 6 | import ( 7 | "miniflux.app/v2/internal/locale" 8 | "miniflux.app/v2/internal/model" 9 | "miniflux.app/v2/internal/storage" 10 | ) 11 | 12 | // ValidateCategoryCreation validates category creation. 13 | func ValidateCategoryCreation(store *storage.Storage, userID int64, request *model.CategoryRequest) *locale.LocalizedError { 14 | if request.Title == "" { 15 | return locale.NewLocalizedError("error.title_required") 16 | } 17 | 18 | if store.CategoryTitleExists(userID, request.Title) { 19 | return locale.NewLocalizedError("error.category_already_exists") 20 | } 21 | 22 | return nil 23 | } 24 | 25 | // ValidateCategoryModification validates category modification. 26 | func ValidateCategoryModification(store *storage.Storage, userID, categoryID int64, request *model.CategoryRequest) *locale.LocalizedError { 27 | if request.Title == "" { 28 | return locale.NewLocalizedError("error.title_required") 29 | } 30 | 31 | if store.AnotherCategoryExists(userID, categoryID, request.Title) { 32 | return locale.NewLocalizedError("error.category_already_exists") 33 | } 34 | 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/cli/create_admin.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package cli // import "miniflux.app/v2/internal/cli" 5 | 6 | import ( 7 | "log/slog" 8 | 9 | "miniflux.app/v2/internal/config" 10 | "miniflux.app/v2/internal/model" 11 | "miniflux.app/v2/internal/storage" 12 | "miniflux.app/v2/internal/validator" 13 | ) 14 | 15 | func createAdmin(store *storage.Storage) { 16 | userCreationRequest := &model.UserCreationRequest{ 17 | Username: config.Opts.AdminUsername(), 18 | Password: config.Opts.AdminPassword(), 19 | IsAdmin: true, 20 | } 21 | 22 | if userCreationRequest.Username == "" || userCreationRequest.Password == "" { 23 | userCreationRequest.Username, userCreationRequest.Password = askCredentials() 24 | } 25 | 26 | if store.UserExists(userCreationRequest.Username) { 27 | slog.Info("Skipping admin user creation because it already exists", 28 | slog.String("username", userCreationRequest.Username), 29 | ) 30 | return 31 | } 32 | 33 | if validationErr := validator.ValidateUserCreationWithPassword(store, userCreationRequest); validationErr != nil { 34 | printErrorAndExit(validationErr.Error()) 35 | } 36 | 37 | if _, err := store.CreateUser(userCreationRequest); err != nil { 38 | printErrorAndExit(err) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/oauth2/manager.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package oauth2 // import "miniflux.app/v2/internal/oauth2" 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "log/slog" 10 | ) 11 | 12 | type Manager struct { 13 | providers map[string]Provider 14 | } 15 | 16 | func (m *Manager) FindProvider(name string) (Provider, error) { 17 | if provider, found := m.providers[name]; found { 18 | return provider, nil 19 | } 20 | 21 | return nil, errors.New("oauth2 provider not found") 22 | } 23 | 24 | func (m *Manager) AddProvider(name string, provider Provider) { 25 | m.providers[name] = provider 26 | } 27 | 28 | func NewManager(ctx context.Context, clientID, clientSecret, redirectURL, oidcDiscoveryEndpoint string) *Manager { 29 | m := &Manager{providers: make(map[string]Provider)} 30 | m.AddProvider("google", NewGoogleProvider(clientID, clientSecret, redirectURL)) 31 | 32 | if oidcDiscoveryEndpoint != "" { 33 | if genericOidcProvider, err := NewOidcProvider(ctx, clientID, clientSecret, redirectURL, oidcDiscoveryEndpoint); err != nil { 34 | slog.Error("Failed to initialize OIDC provider", 35 | slog.Any("error", err), 36 | ) 37 | } else { 38 | m.AddProvider("oidc", genericOidcProvider) 39 | } 40 | } 41 | 42 | return m 43 | } 44 | -------------------------------------------------------------------------------- /internal/model/enclosure_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | func TestEnclosure_Html5MimeTypeGivesOriginalMimeType(t *testing.T) { 11 | enclosure := Enclosure{MimeType: "thing/thisMimeTypeIsNotExpectedToBeReplaced"} 12 | if enclosure.Html5MimeType() != enclosure.MimeType { 13 | t.Fatalf( 14 | "HTML5 MimeType must provide original MimeType if not explicitly Replaced. Got %s ,expected '%s' ", 15 | enclosure.Html5MimeType(), 16 | enclosure.MimeType, 17 | ) 18 | } 19 | } 20 | 21 | func TestEnclosure_Html5MimeTypeReplaceStandardM4vByAppleSpecificMimeType(t *testing.T) { 22 | enclosure := Enclosure{MimeType: "video/m4v"} 23 | if enclosure.Html5MimeType() != "video/x-m4v" { 24 | // Solution from this stackoverflow discussion: 25 | // https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470 26 | // tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed 27 | // https://www.florenceporcel.com/podcast/lfhdu.xml 28 | t.Fatalf( 29 | "HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in brownser. Got '%s'", 30 | enclosure.Html5MimeType(), 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/http/server/middleware.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package httpd // import "miniflux.app/v2/internal/http/server" 5 | 6 | import ( 7 | "context" 8 | "log/slog" 9 | "net/http" 10 | "time" 11 | 12 | "miniflux.app/v2/internal/config" 13 | "miniflux.app/v2/internal/http/request" 14 | ) 15 | 16 | func middleware(next http.Handler) http.Handler { 17 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | clientIP := request.FindClientIP(r) 19 | ctx := r.Context() 20 | ctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP) 21 | 22 | if r.Header.Get("X-Forwarded-Proto") == "https" { 23 | config.Opts.HTTPS = true 24 | } 25 | 26 | t1 := time.Now() 27 | defer func() { 28 | slog.Debug("Incoming request", 29 | slog.String("client_ip", clientIP), 30 | slog.Group("request", 31 | slog.String("method", r.Method), 32 | slog.String("uri", r.RequestURI), 33 | slog.String("protocol", r.Proto), 34 | slog.Duration("execution_time", time.Since(t1)), 35 | ), 36 | ) 37 | }() 38 | 39 | if config.Opts.HTTPS && config.Opts.HasHSTS() { 40 | w.Header().Set("Strict-Transport-Security", "max-age=31536000") 41 | } 42 | 43 | next.ServeHTTP(w, r.WithContext(ctx)) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /internal/model/webauthn.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model // import "miniflux.app/v2/internal/model" 5 | 6 | import ( 7 | "database/sql/driver" 8 | "encoding/hex" 9 | "encoding/json" 10 | "errors" 11 | "fmt" 12 | "time" 13 | 14 | "github.com/go-webauthn/webauthn/webauthn" 15 | ) 16 | 17 | // handle marshalling / unmarshalling session data 18 | type WebAuthnSession struct { 19 | *webauthn.SessionData 20 | } 21 | 22 | func (s WebAuthnSession) Value() (driver.Value, error) { 23 | return json.Marshal(s) 24 | } 25 | 26 | func (s *WebAuthnSession) Scan(value interface{}) error { 27 | b, ok := value.([]byte) 28 | if !ok { 29 | return errors.New("type assertion to []byte failed") 30 | } 31 | 32 | return json.Unmarshal(b, &s) 33 | } 34 | 35 | func (s WebAuthnSession) String() string { 36 | if s.SessionData == nil { 37 | return "{}" 38 | } 39 | return fmt.Sprintf("{Challenge: %s, UserID: %x}", s.SessionData.Challenge, s.SessionData.UserID) 40 | } 41 | 42 | type WebAuthnCredential struct { 43 | Credential webauthn.Credential 44 | Name string 45 | AddedOn *time.Time 46 | LastSeenOn *time.Time 47 | Handle []byte 48 | } 49 | 50 | func (s WebAuthnCredential) HandleEncoded() string { 51 | return hex.EncodeToString(s.Handle) 52 | } 53 | -------------------------------------------------------------------------------- /internal/http/cookie/cookie.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package cookie // import "miniflux.app/v2/internal/http/cookie" 5 | 6 | import ( 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | // Cookie names. 12 | const ( 13 | CookieAppSessionID = "MinifluxAppSessionID" 14 | CookieUserSessionID = "MinifluxUserSessionID" 15 | 16 | // Cookie duration in days. 17 | cookieDuration = 30 18 | ) 19 | 20 | // New creates a new cookie. 21 | func New(name, value string, isHTTPS bool, path string) *http.Cookie { 22 | return &http.Cookie{ 23 | Name: name, 24 | Value: value, 25 | Path: basePath(path), 26 | Secure: isHTTPS, 27 | HttpOnly: true, 28 | Expires: time.Now().Add(cookieDuration * 24 * time.Hour), 29 | SameSite: http.SameSiteLaxMode, 30 | } 31 | } 32 | 33 | // Expired returns an expired cookie. 34 | func Expired(name string, isHTTPS bool, path string) *http.Cookie { 35 | return &http.Cookie{ 36 | Name: name, 37 | Value: "", 38 | Path: basePath(path), 39 | Secure: isHTTPS, 40 | HttpOnly: true, 41 | MaxAge: -1, 42 | Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), 43 | SameSite: http.SameSiteLaxMode, 44 | } 45 | } 46 | 47 | func basePath(path string) string { 48 | if path == "" { 49 | return "/" 50 | } 51 | return path 52 | } 53 | -------------------------------------------------------------------------------- /internal/template/templates/views/import.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.import.title" }}{{ end }} 2 | 3 | {{ define "content"}} 4 | 8 | 9 | {{ if .errorMessage }} 10 |
{{ .errorMessage }}
11 | {{ end }} 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 |
22 |
23 |
24 |
25 | 26 | 27 | 28 | 29 | 30 |
31 | 32 |
33 |
34 | 35 | {{ end }} 36 | -------------------------------------------------------------------------------- /internal/model/enclosure.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package model // import "miniflux.app/v2/internal/model" 5 | import "strings" 6 | 7 | // Enclosure represents an attachment. 8 | type Enclosure struct { 9 | ID int64 `json:"id"` 10 | UserID int64 `json:"user_id"` 11 | EntryID int64 `json:"entry_id"` 12 | URL string `json:"url"` 13 | MimeType string `json:"mime_type"` 14 | Size int64 `json:"size"` 15 | MediaProgression int64 `json:"media_progression"` 16 | } 17 | 18 | // Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType 19 | func (e Enclosure) Html5MimeType() string { 20 | if strings.HasPrefix(e.MimeType, "video") { 21 | switch e.MimeType { 22 | // Solution from this stackoverflow discussion: 23 | // https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470 24 | // tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed 25 | // https://www.florenceporcel.com/podcast/lfhdu.xml 26 | case "video/m4v": 27 | return "video/x-m4v" 28 | } 29 | } 30 | return e.MimeType 31 | } 32 | 33 | // EnclosureList represents a list of attachments. 34 | type EnclosureList []*Enclosure 35 | -------------------------------------------------------------------------------- /internal/ui/about.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | "runtime" 9 | 10 | "miniflux.app/v2/internal/config" 11 | "miniflux.app/v2/internal/http/request" 12 | "miniflux.app/v2/internal/http/response/html" 13 | "miniflux.app/v2/internal/ui/session" 14 | "miniflux.app/v2/internal/ui/view" 15 | "miniflux.app/v2/internal/version" 16 | ) 17 | 18 | func (h *handler) showAboutPage(w http.ResponseWriter, r *http.Request) { 19 | user, err := h.store.UserByID(request.UserID(r)) 20 | if err != nil { 21 | html.ServerError(w, r, err) 22 | return 23 | } 24 | 25 | sess := session.New(h.store, request.SessionID(r)) 26 | view := view.New(h.tpl, r, sess) 27 | view.Set("version", version.Version) 28 | view.Set("commit", version.Commit) 29 | view.Set("build_date", version.BuildDate) 30 | view.Set("menu", "settings") 31 | view.Set("user", user) 32 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 33 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 34 | view.Set("globalConfigOptions", config.Opts.SortedOptions(true)) 35 | view.Set("postgres_version", h.store.DatabaseVersion()) 36 | view.Set("go_version", runtime.Version()) 37 | 38 | html.OK(w, r, view.Render("about")) 39 | } 40 | -------------------------------------------------------------------------------- /internal/ui/oauth2_redirect.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "log/slog" 8 | "net/http" 9 | 10 | "miniflux.app/v2/internal/http/request" 11 | "miniflux.app/v2/internal/http/response/html" 12 | "miniflux.app/v2/internal/http/route" 13 | "miniflux.app/v2/internal/oauth2" 14 | "miniflux.app/v2/internal/ui/session" 15 | ) 16 | 17 | func (h *handler) oauth2Redirect(w http.ResponseWriter, r *http.Request) { 18 | sess := session.New(h.store, request.SessionID(r)) 19 | 20 | provider := request.RouteStringParam(r, "provider") 21 | if provider == "" { 22 | slog.Warn("Invalid or missing OAuth2 provider") 23 | html.Redirect(w, r, route.Path(h.router, "login")) 24 | return 25 | } 26 | 27 | authProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider) 28 | if err != nil { 29 | slog.Error("Unable to initialize OAuth2 provider", 30 | slog.String("provider", provider), 31 | slog.Any("error", err), 32 | ) 33 | html.Redirect(w, r, route.Path(h.router, "login")) 34 | return 35 | } 36 | 37 | auth := oauth2.GenerateAuthorization(authProvider.GetConfig()) 38 | 39 | sess.SetOAuth2State(auth.State()) 40 | sess.SetOAuth2CodeVerifier(auth.CodeVerifier()) 41 | 42 | html.Redirect(w, r, auth.RedirectURL()) 43 | } 44 | -------------------------------------------------------------------------------- /internal/oauth2/authorization.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package oauth2 // import "miniflux.app/v2/internal/oauth2" 5 | 6 | import ( 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "io" 10 | 11 | "golang.org/x/oauth2" 12 | 13 | "miniflux.app/v2/internal/crypto" 14 | ) 15 | 16 | type Authorization struct { 17 | url string 18 | state string 19 | codeVerifier string 20 | } 21 | 22 | func (u *Authorization) RedirectURL() string { 23 | return u.url 24 | } 25 | 26 | func (u *Authorization) State() string { 27 | return u.state 28 | } 29 | 30 | func (u *Authorization) CodeVerifier() string { 31 | return u.codeVerifier 32 | } 33 | 34 | func GenerateAuthorization(config *oauth2.Config) *Authorization { 35 | codeVerifier := crypto.GenerateRandomStringHex(32) 36 | 37 | sha2 := sha256.New() 38 | io.WriteString(sha2, codeVerifier) 39 | codeChallenge := base64.RawURLEncoding.EncodeToString(sha2.Sum(nil)) 40 | 41 | state := crypto.GenerateRandomStringHex(24) 42 | 43 | authUrl := config.AuthCodeURL( 44 | state, 45 | oauth2.SetAuthURLParam("code_challenge_method", "S256"), 46 | oauth2.SetAuthURLParam("code_challenge", codeChallenge), 47 | ) 48 | 49 | return &Authorization{ 50 | url: authUrl, 51 | state: state, 52 | codeVerifier: codeVerifier, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/reader/encoding/encoding.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package encoding // import "miniflux.app/v2/internal/reader/encoding" 5 | 6 | import ( 7 | "bytes" 8 | "io" 9 | "unicode/utf8" 10 | 11 | "golang.org/x/net/html/charset" 12 | ) 13 | 14 | // CharsetReader is used when the XML encoding is specified for the input document. 15 | // 16 | // The document is converted in UTF-8 only if a different encoding is specified 17 | // and the document is not already UTF-8. 18 | // 19 | // Several edge cases could exists: 20 | // 21 | // - Feeds with encoding specified only in Content-Type header and not in XML document 22 | // - Feeds with encoding specified in both places 23 | // - Feeds with encoding specified only in XML document and not in HTTP header 24 | // - Feeds with wrong encoding defined and already in UTF-8 25 | func CharsetReader(label string, input io.Reader) (io.Reader, error) { 26 | buffer, _ := io.ReadAll(input) 27 | r := bytes.NewReader(buffer) 28 | 29 | // The document is already UTF-8, do not do anything (avoid double-encoding). 30 | // That means the specified encoding in XML prolog is wrong. 31 | if utf8.Valid(buffer) { 32 | return r, nil 33 | } 34 | 35 | // Transform document to UTF-8 from the specified encoding in XML prolog. 36 | return charset.NewReaderLabel(label, r) 37 | } 38 | -------------------------------------------------------------------------------- /internal/ui/entry_enclosure_save_position.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | json2 "encoding/json" 8 | "io" 9 | "net/http" 10 | 11 | "miniflux.app/v2/internal/http/request" 12 | "miniflux.app/v2/internal/http/response/json" 13 | ) 14 | 15 | func (h *handler) saveEnclosureProgression(w http.ResponseWriter, r *http.Request) { 16 | enclosureID := request.RouteInt64Param(r, "enclosureID") 17 | enclosure, err := h.store.GetEnclosure(enclosureID) 18 | if err != nil { 19 | json.ServerError(w, r, err) 20 | return 21 | } 22 | 23 | if enclosure == nil { 24 | json.NotFound(w, r) 25 | return 26 | } 27 | 28 | type enclosurePositionSaveRequest struct { 29 | Progression int64 `json:"progression"` 30 | } 31 | 32 | var postData enclosurePositionSaveRequest 33 | body, err := io.ReadAll(r.Body) 34 | if err != nil { 35 | json.ServerError(w, r, err) 36 | return 37 | } 38 | 39 | json2.Unmarshal(body, &postData) 40 | if err != nil { 41 | json.ServerError(w, r, err) 42 | return 43 | } 44 | enclosure.MediaProgression = postData.Progression 45 | 46 | err = h.store.UpdateEnclosure(enclosure) 47 | if err != nil { 48 | json.ServerError(w, r, err) 49 | return 50 | } 51 | 52 | json.Created(w, r, map[string]string{"message": "saved"}) 53 | } 54 | -------------------------------------------------------------------------------- /internal/api/icon.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package api // import "miniflux.app/v2/internal/api" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/json" 11 | ) 12 | 13 | func (h *handler) getIconByFeedID(w http.ResponseWriter, r *http.Request) { 14 | feedID := request.RouteInt64Param(r, "feedID") 15 | 16 | if !h.store.HasIcon(feedID) { 17 | json.NotFound(w, r) 18 | return 19 | } 20 | 21 | icon, err := h.store.IconByFeedID(request.UserID(r), feedID) 22 | if err != nil { 23 | json.ServerError(w, r, err) 24 | return 25 | } 26 | 27 | if icon == nil { 28 | json.NotFound(w, r) 29 | return 30 | } 31 | 32 | json.OK(w, r, &feedIconResponse{ 33 | ID: icon.ID, 34 | MimeType: icon.MimeType, 35 | Data: icon.DataURL(), 36 | }) 37 | } 38 | 39 | func (h *handler) getIconByIconID(w http.ResponseWriter, r *http.Request) { 40 | iconID := request.RouteInt64Param(r, "iconID") 41 | 42 | icon, err := h.store.IconByID(iconID) 43 | if err != nil { 44 | json.ServerError(w, r, err) 45 | return 46 | } 47 | 48 | if icon == nil { 49 | json.NotFound(w, r) 50 | return 51 | } 52 | 53 | json.OK(w, r, &feedIconResponse{ 54 | ID: icon.ID, 55 | MimeType: icon.MimeType, 56 | Data: icon.DataURL(), 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /internal/integration/rssbridge/rssbridge.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package rssbridge // import "miniflux.app/integration/rssbridge" 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "net/http" 10 | "net/url" 11 | ) 12 | 13 | type Bridge struct { 14 | URL string `json:"url"` 15 | BridgeMeta BridgeMeta `json:"bridgeMeta"` 16 | } 17 | 18 | type BridgeMeta struct { 19 | Name string `json:"name"` 20 | } 21 | 22 | func DetectBridges(rssbridgeURL, websiteURL string) (bridgeResponse []Bridge, err error) { 23 | u, err := url.Parse(rssbridgeURL) 24 | if err != nil { 25 | return nil, err 26 | } 27 | values := u.Query() 28 | values.Add("action", "findfeed") 29 | values.Add("format", "atom") 30 | values.Add("url", websiteURL) 31 | u.RawQuery = values.Encode() 32 | 33 | response, err := http.Get(u.String()) 34 | if err != nil { 35 | return nil, fmt.Errorf("RSS-Bridge: unable to excute request: %w", err) 36 | } 37 | defer response.Body.Close() 38 | if response.StatusCode == http.StatusNotFound { 39 | return 40 | } 41 | if response.StatusCode > 400 { 42 | return nil, fmt.Errorf("RSS-Bridge: unexpected status code %d", response.StatusCode) 43 | } 44 | if err := json.NewDecoder(response.Body).Decode(&bridgeResponse); err != nil { 45 | return nil, fmt.Errorf("RSS-Bridge: unable to decode bridge response: %w", err) 46 | } 47 | return 48 | } 49 | -------------------------------------------------------------------------------- /internal/ui/subscription_add.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/config" 10 | "miniflux.app/v2/internal/http/request" 11 | "miniflux.app/v2/internal/http/response/html" 12 | "miniflux.app/v2/internal/ui/form" 13 | "miniflux.app/v2/internal/ui/session" 14 | "miniflux.app/v2/internal/ui/view" 15 | ) 16 | 17 | func (h *handler) showAddSubscriptionPage(w http.ResponseWriter, r *http.Request) { 18 | sess := session.New(h.store, request.SessionID(r)) 19 | view := view.New(h.tpl, r, sess) 20 | 21 | user, err := h.store.UserByID(request.UserID(r)) 22 | if err != nil { 23 | html.ServerError(w, r, err) 24 | return 25 | } 26 | 27 | categories, err := h.store.Categories(user.ID) 28 | if err != nil { 29 | html.ServerError(w, r, err) 30 | return 31 | } 32 | 33 | view.Set("categories", categories) 34 | view.Set("menu", "feeds") 35 | view.Set("user", user) 36 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 37 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 38 | view.Set("defaultUserAgent", config.Opts.HTTPClientUserAgent()) 39 | view.Set("form", &form.SubscriptionForm{CategoryID: 0}) 40 | view.Set("hasProxyConfigured", config.Opts.HasHTTPClientProxyConfigured()) 41 | 42 | html.OK(w, r, view.Render("add_subscription")) 43 | } 44 | -------------------------------------------------------------------------------- /internal/http/request/client_ip.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package request // import "miniflux.app/v2/internal/http/request" 5 | 6 | import ( 7 | "net" 8 | "net/http" 9 | "strings" 10 | ) 11 | 12 | // FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers. 13 | func FindClientIP(r *http.Request) string { 14 | headers := []string{"X-Forwarded-For", "X-Real-Ip"} 15 | for _, header := range headers { 16 | value := r.Header.Get(header) 17 | 18 | if value != "" { 19 | addresses := strings.Split(value, ",") 20 | address := strings.TrimSpace(addresses[0]) 21 | address = dropIPv6zone(address) 22 | 23 | if net.ParseIP(address) != nil { 24 | return address 25 | } 26 | } 27 | } 28 | 29 | // Fallback to TCP/IP source IP address. 30 | return FindRemoteIP(r) 31 | } 32 | 33 | // FindRemoteIP returns remote client IP address. 34 | func FindRemoteIP(r *http.Request) string { 35 | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) 36 | if err != nil { 37 | remoteIP = r.RemoteAddr 38 | } 39 | remoteIP = dropIPv6zone(remoteIP) 40 | 41 | // When listening on a Unix socket, RemoteAddr is empty. 42 | if remoteIP == "" { 43 | remoteIP = "127.0.0.1" 44 | } 45 | 46 | return remoteIP 47 | } 48 | 49 | func dropIPv6zone(address string) string { 50 | i := strings.IndexByte(address, '%') 51 | if i != -1 { 52 | address = address[:i] 53 | } 54 | return address 55 | } 56 | -------------------------------------------------------------------------------- /internal/reader/opml/parser.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package opml // import "miniflux.app/v2/internal/reader/opml" 5 | 6 | import ( 7 | "encoding/xml" 8 | "fmt" 9 | "io" 10 | 11 | "miniflux.app/v2/internal/reader/encoding" 12 | ) 13 | 14 | // Parse reads an OPML file and returns a SubcriptionList. 15 | func Parse(data io.Reader) (SubcriptionList, error) { 16 | opmlDocument := NewOPMLDocument() 17 | decoder := xml.NewDecoder(data) 18 | decoder.Entity = xml.HTMLEntity 19 | decoder.Strict = false 20 | decoder.CharsetReader = encoding.CharsetReader 21 | 22 | err := decoder.Decode(opmlDocument) 23 | if err != nil { 24 | return nil, fmt.Errorf("opml: unable to parse document: %w", err) 25 | } 26 | 27 | return getSubscriptionsFromOutlines(opmlDocument.Outlines, ""), nil 28 | } 29 | 30 | func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category string) (subscriptions SubcriptionList) { 31 | for _, outline := range outlines { 32 | if outline.IsSubscription() { 33 | subscriptions = append(subscriptions, &Subcription{ 34 | Title: outline.GetTitle(), 35 | FeedURL: outline.FeedURL, 36 | SiteURL: outline.GetSiteURL(), 37 | CategoryName: category, 38 | }) 39 | } else if outline.Outlines.HasChildren() { 40 | subscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.Text)...) 41 | } 42 | } 43 | return subscriptions 44 | } 45 | -------------------------------------------------------------------------------- /internal/template/templates/views/about.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.about.title" }}{{ end }} 2 | 3 | {{ define "content"}} 4 | 8 | 9 |
10 |

Miniflux

11 |
    12 |
  • {{ t "page.about.version" }} {{ .version }}
  • 13 |
  • Git Commit {{ .commit }}
  • 14 |
  • {{ t "page.about.build_date" }} {{ .build_date }}
  • 15 | {{ if .user.IsAdmin }}
  • {{ t "page.about.postgres_version" }} {{ .postgres_version }}
  • {{ end }} 16 |
  • {{t "page.about.go_version" }} {{ .go_version }}
  • 17 |
18 |
19 | 20 |
21 |

{{ t "page.about.credits" }}

22 |
    23 |
  • {{ t "page.about.author" }} Frédéric Guillot
  • 24 |
  • {{ t "page.about.license" }} Apache 2.0
  • 25 |
26 |
27 | 28 | {{ if .user.IsAdmin }} 29 |
30 |

{{ t "page.about.global_config_options" }}

31 |
    32 | {{ range .globalConfigOptions }} 33 |
  • {{ .Key }}={{ .Value }}
  • 34 | {{ end }} 35 |
36 |
37 | {{ end }} 38 | 39 | {{ end }} 40 | -------------------------------------------------------------------------------- /internal/ui/shared_entries.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/session" 12 | "miniflux.app/v2/internal/ui/view" 13 | ) 14 | 15 | func (h *handler) sharedEntries(w http.ResponseWriter, r *http.Request) { 16 | user, err := h.store.UserByID(request.UserID(r)) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | builder := h.store.NewEntryQueryBuilder(user.ID) 23 | builder.WithShareCodeNotEmpty() 24 | builder.WithSorting(user.EntryOrder, user.EntryDirection) 25 | 26 | entries, err := builder.GetEntries() 27 | if err != nil { 28 | html.ServerError(w, r, err) 29 | return 30 | } 31 | 32 | count, err := builder.CountEntries() 33 | if err != nil { 34 | html.ServerError(w, r, err) 35 | return 36 | } 37 | 38 | sess := session.New(h.store, request.SessionID(r)) 39 | view := view.New(h.tpl, r, sess) 40 | view.Set("entries", entries) 41 | view.Set("total", count) 42 | view.Set("menu", "history") 43 | view.Set("user", user) 44 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 45 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 46 | view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID)) 47 | 48 | html.OK(w, r, view.Render("shared_entries")) 49 | } 50 | -------------------------------------------------------------------------------- /internal/integration/matrixbot/matrixbot.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package matrixbot // import "miniflux.app/v2/internal/integration/matrixbot" 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | 10 | "miniflux.app/v2/internal/model" 11 | ) 12 | 13 | // PushEntry pushes entries to matrix chat using integration settings provided 14 | func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixUsername, matrixPassword, matrixRoomID string) error { 15 | client := NewClient(matrixBaseURL) 16 | discovery, err := client.DiscoverEndpoints() 17 | if err != nil { 18 | return err 19 | } 20 | 21 | loginResponse, err := client.Login(discovery.HomeServerInformation.BaseURL, matrixUsername, matrixPassword) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | var textMessages []string 27 | var formattedTextMessages []string 28 | 29 | for _, entry := range entries { 30 | textMessages = append(textMessages, fmt.Sprintf(`[%s] %s - %s`, feed.Title, entry.Title, entry.URL)) 31 | formattedTextMessages = append(formattedTextMessages, fmt.Sprintf(`
  • %s: %s
  • `, feed.Title, entry.URL, entry.Title)) 32 | } 33 | 34 | _, err = client.SendFormattedTextMessage( 35 | discovery.HomeServerInformation.BaseURL, 36 | loginResponse.AccessToken, 37 | matrixRoomID, 38 | strings.Join(textMessages, "\n"), 39 | "
      "+strings.Join(formattedTextMessages, "\n")+"
    ", 40 | ) 41 | 42 | return err 43 | } 44 | -------------------------------------------------------------------------------- /internal/ui/category_feeds.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/session" 12 | "miniflux.app/v2/internal/ui/view" 13 | ) 14 | 15 | func (h *handler) showCategoryFeedsPage(w http.ResponseWriter, r *http.Request) { 16 | user, err := h.store.UserByID(request.UserID(r)) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | categoryID := request.RouteInt64Param(r, "categoryID") 23 | category, err := h.store.Category(request.UserID(r), categoryID) 24 | if err != nil { 25 | html.ServerError(w, r, err) 26 | return 27 | } 28 | 29 | if category == nil { 30 | html.NotFound(w, r) 31 | return 32 | } 33 | 34 | feeds, err := h.store.FeedsByCategoryWithCounters(user.ID, categoryID) 35 | if err != nil { 36 | html.ServerError(w, r, err) 37 | return 38 | } 39 | 40 | sess := session.New(h.store, request.SessionID(r)) 41 | view := view.New(h.tpl, r, sess) 42 | view.Set("category", category) 43 | view.Set("feeds", feeds) 44 | view.Set("total", len(feeds)) 45 | view.Set("menu", "categories") 46 | view.Set("user", user) 47 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 48 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 49 | 50 | html.OK(w, r, view.Render("category_feeds")) 51 | } 52 | -------------------------------------------------------------------------------- /packaging/debian/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | PKG_ARCH=$(dpkg --print-architecture) 4 | PKG_DATE=$(date -R) 5 | PKG_VERSION=$(cd /src && git describe --tags --abbrev=0 | sed 's/^v//') 6 | 7 | echo "PKG_VERSION=$PKG_VERSION" 8 | echo "PKG_ARCH=$PKG_ARCH" 9 | echo "PKG_DATE=$PKG_DATE" 10 | 11 | cd /src && \ 12 | make miniflux && \ 13 | mkdir -p /build/debian && \ 14 | cd /build && \ 15 | cp /src/miniflux /build/ && \ 16 | cp /src/miniflux.1 /build/ && \ 17 | cp /src/LICENSE /build/ && \ 18 | cp /src/packaging/miniflux.conf /build/ && \ 19 | cp /src/packaging/systemd/miniflux.service /build/debian/ && \ 20 | cp /src/packaging/debian/compat /build/debian/compat && \ 21 | cp /src/packaging/debian/copyright /build/debian/copyright && \ 22 | cp /src/packaging/debian/miniflux.manpages /build/debian/miniflux.manpages && \ 23 | cp /src/packaging/debian/miniflux.postinst /build/debian/miniflux.postinst && \ 24 | cp /src/packaging/debian/rules /build/debian/rules && \ 25 | cp /src/packaging/debian/miniflux.dirs /build/debian/miniflux.dirs && \ 26 | echo "miniflux ($PKG_VERSION) experimental; urgency=low" > /build/debian/changelog && \ 27 | echo " * Miniflux version $PKG_VERSION" >> /build/debian/changelog && \ 28 | echo " -- Frédéric Guillot $PKG_DATE" >> /build/debian/changelog && \ 29 | sed "s/__PKG_ARCH__/${PKG_ARCH}/g" /src/packaging/debian/control > /build/debian/control && \ 30 | dpkg-buildpackage -us -uc -b && \ 31 | lintian --check --color always ../*.deb && \ 32 | cp ../*.deb /pkg/ 33 | -------------------------------------------------------------------------------- /internal/reader/atom/parser.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package atom // import "miniflux.app/v2/internal/reader/atom" 5 | 6 | import ( 7 | "bytes" 8 | "encoding/xml" 9 | "fmt" 10 | "io" 11 | 12 | "miniflux.app/v2/internal/model" 13 | xml_decoder "miniflux.app/v2/internal/reader/xml" 14 | ) 15 | 16 | type atomFeed interface { 17 | Transform(baseURL string) *model.Feed 18 | } 19 | 20 | // Parse returns a normalized feed struct from a Atom feed. 21 | func Parse(baseURL string, r io.Reader) (*model.Feed, error) { 22 | var buf bytes.Buffer 23 | tee := io.TeeReader(r, &buf) 24 | 25 | var rawFeed atomFeed 26 | if getAtomFeedVersion(tee) == "0.3" { 27 | rawFeed = new(atom03Feed) 28 | } else { 29 | rawFeed = new(atom10Feed) 30 | } 31 | 32 | if err := xml_decoder.NewDecoder(&buf).Decode(rawFeed); err != nil { 33 | return nil, fmt.Errorf("atom: unable to parse feed: %w", err) 34 | } 35 | 36 | return rawFeed.Transform(baseURL), nil 37 | } 38 | 39 | func getAtomFeedVersion(data io.Reader) string { 40 | decoder := xml_decoder.NewDecoder(data) 41 | for { 42 | token, _ := decoder.Token() 43 | if token == nil { 44 | break 45 | } 46 | 47 | if element, ok := token.(xml.StartElement); ok { 48 | if element.Name.Local == "feed" { 49 | for _, attr := range element.Attr { 50 | if attr.Name.Local == "version" && attr.Value == "0.3" { 51 | return "0.3" 52 | } 53 | } 54 | return "1.0" 55 | } 56 | } 57 | } 58 | return "1.0" 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | permissions: read-all 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | unit-tests: 11 | name: Unit Tests 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | max-parallel: 4 15 | matrix: 16 | os: [ubuntu-latest, windows-latest, macOS-latest] 17 | go-version: ["1.21"] 18 | steps: 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | - name: Run unit tests 26 | run: make test 27 | 28 | integration-tests: 29 | name: Integration Tests 30 | runs-on: ubuntu-latest 31 | services: 32 | postgres: 33 | image: postgres:9.5 34 | env: 35 | POSTGRES_USER: postgres 36 | POSTGRES_PASSWORD: postgres 37 | POSTGRES_DB: postgres 38 | ports: 39 | - 5432:5432 40 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 41 | steps: 42 | - name: Set up Go 43 | uses: actions/setup-go@v4 44 | with: 45 | go-version: "1.21" 46 | - name: Checkout 47 | uses: actions/checkout@v4 48 | - name: Install Postgres client 49 | run: sudo apt update && sudo apt install -y postgresql-client 50 | - name: Run integration tests 51 | run: make integration-test 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | PGHOST: 127.0.0.1 55 | PGPASSWORD: postgres 56 | -------------------------------------------------------------------------------- /internal/locale/catalog.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package locale // import "miniflux.app/v2/internal/locale" 5 | 6 | import ( 7 | "embed" 8 | "encoding/json" 9 | "fmt" 10 | ) 11 | 12 | type translationDict map[string]interface{} 13 | type catalog map[string]translationDict 14 | 15 | var defaultCatalog catalog 16 | 17 | //go:embed translations/*.json 18 | var translationFiles embed.FS 19 | 20 | // LoadCatalogMessages loads and parses all translations encoded in JSON. 21 | func LoadCatalogMessages() error { 22 | var err error 23 | defaultCatalog = make(catalog) 24 | 25 | for language := range AvailableLanguages() { 26 | defaultCatalog[language], err = loadTranslationFile(language) 27 | if err != nil { 28 | return err 29 | } 30 | } 31 | return nil 32 | } 33 | 34 | func loadTranslationFile(language string) (translationDict, error) { 35 | translationFileData, err := translationFiles.ReadFile(fmt.Sprintf("translations/%s.json", language)) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | translationMessages, err := parseTranslationMessages(translationFileData) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return translationMessages, nil 46 | } 47 | 48 | func parseTranslationMessages(data []byte) (translationDict, error) { 49 | var translationMessages translationDict 50 | if err := json.Unmarshal(data, &translationMessages); err != nil { 51 | return nil, fmt.Errorf(`invalid translation file: %w`, err) 52 | } 53 | return translationMessages, nil 54 | } 55 | -------------------------------------------------------------------------------- /internal/template/templates/views/bookmark_entries.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.starred.title" }} ({{ .total }}){{ end }} 2 | 3 | {{ define "content"}} 4 | 7 | 8 | {{ if not .entries }} 9 |

    {{ t "alert.no_bookmark" }}

    10 | {{ else }} 11 |
    12 | {{ template "pagination" .pagination }} 13 |
    14 |
    15 | {{ range .entries }} 16 |
    17 |
    18 | 19 | {{ if ne .Feed.Icon.IconID 0 }} 20 | {{ .Feed.Title }} 21 | {{ end }} 22 | {{ .Title }} 23 | 24 | {{ .Feed.Category.Title }} 25 |
    26 | {{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }} 27 |
    28 | {{ end }} 29 |
    30 |
    31 | {{ template "pagination" .pagination }} 32 |
    33 | {{ end }} 34 | 35 | {{ end }} 36 | -------------------------------------------------------------------------------- /internal/ui/category_edit.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/form" 12 | "miniflux.app/v2/internal/ui/session" 13 | "miniflux.app/v2/internal/ui/view" 14 | ) 15 | 16 | func (h *handler) showEditCategoryPage(w http.ResponseWriter, r *http.Request) { 17 | sess := session.New(h.store, request.SessionID(r)) 18 | view := view.New(h.tpl, r, sess) 19 | 20 | user, err := h.store.UserByID(request.UserID(r)) 21 | if err != nil { 22 | html.ServerError(w, r, err) 23 | return 24 | } 25 | 26 | categoryID := request.RouteInt64Param(r, "categoryID") 27 | category, err := h.store.Category(request.UserID(r), categoryID) 28 | if err != nil { 29 | html.ServerError(w, r, err) 30 | return 31 | } 32 | 33 | if category == nil { 34 | html.NotFound(w, r) 35 | return 36 | } 37 | 38 | categoryForm := form.CategoryForm{ 39 | Title: category.Title, 40 | HideGlobally: "", 41 | } 42 | if category.HideGlobally { 43 | categoryForm.HideGlobally = "checked" 44 | } 45 | 46 | view.Set("form", categoryForm) 47 | view.Set("category", category) 48 | view.Set("menu", "categories") 49 | view.Set("user", user) 50 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 51 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 52 | 53 | html.OK(w, r, view.Render("edit_category")) 54 | } 55 | -------------------------------------------------------------------------------- /internal/template/templates/views/edit_category.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.edit_category.title" .category.Title }}{{ end }} 2 | 3 | {{ define "content"}} 4 | 18 | 19 |
    20 | 21 | 22 | {{ if .errorMessage }} 23 |
    {{ .errorMessage }}
    24 | {{ end }} 25 | 26 | 27 | 28 | 29 | 33 | 34 |
    35 | 36 |
    37 |
    38 | {{ end }} 39 | -------------------------------------------------------------------------------- /internal/template/templates/views/search_entries.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.search.title" }} ({{ .total }}){{ end }} 2 | 3 | {{ define "content"}} 4 | 7 | 8 | {{ if not .entries }} 9 |

    {{ t "alert.no_search_result" }}

    10 | {{ else }} 11 |
    12 | {{ template "pagination" .pagination }} 13 |
    14 |
    15 | {{ range .entries }} 16 |
    17 |
    18 | 19 | {{ if ne .Feed.Icon.IconID 0 }} 20 | {{ .Feed.Title }} 21 | {{ end }} 22 | {{ .Title }} 23 | 24 | {{ .Feed.Category.Title }} 25 |
    26 | {{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }} 27 |
    28 | {{ end }} 29 |
    30 |
    31 | {{ template "pagination" .pagination }} 32 |
    33 | {{ end }} 34 | 35 | {{ end }} 36 | -------------------------------------------------------------------------------- /internal/ui/user_edit.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package ui // import "miniflux.app/v2/internal/ui" 5 | 6 | import ( 7 | "net/http" 8 | 9 | "miniflux.app/v2/internal/http/request" 10 | "miniflux.app/v2/internal/http/response/html" 11 | "miniflux.app/v2/internal/ui/form" 12 | "miniflux.app/v2/internal/ui/session" 13 | "miniflux.app/v2/internal/ui/view" 14 | ) 15 | 16 | // EditUser shows the form to edit a user. 17 | func (h *handler) showEditUserPage(w http.ResponseWriter, r *http.Request) { 18 | sess := session.New(h.store, request.SessionID(r)) 19 | view := view.New(h.tpl, r, sess) 20 | 21 | user, err := h.store.UserByID(request.UserID(r)) 22 | if err != nil { 23 | html.ServerError(w, r, err) 24 | return 25 | } 26 | 27 | if !user.IsAdmin { 28 | html.Forbidden(w, r) 29 | return 30 | } 31 | 32 | userID := request.RouteInt64Param(r, "userID") 33 | selectedUser, err := h.store.UserByID(userID) 34 | if err != nil { 35 | html.ServerError(w, r, err) 36 | return 37 | } 38 | 39 | if selectedUser == nil { 40 | html.NotFound(w, r) 41 | return 42 | } 43 | 44 | userForm := &form.UserForm{ 45 | Username: selectedUser.Username, 46 | IsAdmin: selectedUser.IsAdmin, 47 | } 48 | 49 | view.Set("form", userForm) 50 | view.Set("selected_user", selectedUser) 51 | view.Set("menu", "settings") 52 | view.Set("user", user) 53 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 54 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 55 | 56 | html.OK(w, r, view.Render("edit_user")) 57 | } 58 | -------------------------------------------------------------------------------- /internal/worker/worker.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package worker // import "miniflux.app/v2/internal/worker" 5 | 6 | import ( 7 | "log/slog" 8 | "time" 9 | 10 | "miniflux.app/v2/internal/config" 11 | "miniflux.app/v2/internal/metric" 12 | "miniflux.app/v2/internal/model" 13 | feedHandler "miniflux.app/v2/internal/reader/handler" 14 | "miniflux.app/v2/internal/storage" 15 | ) 16 | 17 | // Worker refreshes a feed in the background. 18 | type Worker struct { 19 | id int 20 | store *storage.Storage 21 | } 22 | 23 | // Run wait for a job and refresh the given feed. 24 | func (w *Worker) Run(c chan model.Job) { 25 | slog.Debug("Worker started", 26 | slog.Int("worker_id", w.id), 27 | ) 28 | 29 | for { 30 | job := <-c 31 | slog.Debug("Job received by worker", 32 | slog.Int("worker_id", w.id), 33 | slog.Int64("user_id", job.UserID), 34 | slog.Int64("feed_id", job.FeedID), 35 | ) 36 | 37 | startTime := time.Now() 38 | localizedError := feedHandler.RefreshFeed(w.store, job.UserID, job.FeedID, false) 39 | 40 | if config.Opts.HasMetricsCollector() { 41 | status := "success" 42 | if localizedError != nil { 43 | status = "error" 44 | } 45 | metric.BackgroundFeedRefreshDuration.WithLabelValues(status).Observe(time.Since(startTime).Seconds()) 46 | } 47 | 48 | if localizedError != nil { 49 | slog.Warn("Unable to refresh a feed", 50 | slog.Int64("user_id", job.UserID), 51 | slog.Int64("feed_id", job.FeedID), 52 | slog.Any("error", localizedError.Error()), 53 | ) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/tests/subscription_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | //go:build integration 5 | // +build integration 6 | 7 | package tests 8 | 9 | import ( 10 | "testing" 11 | 12 | miniflux "miniflux.app/v2/client" 13 | ) 14 | 15 | func TestDiscoverSubscriptions(t *testing.T) { 16 | client := createClient(t) 17 | subscriptions, err := client.Discover(testWebsiteURL) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | if len(subscriptions) != 1 { 23 | t.Fatalf(`Invalid number of subscriptions, got "%v" instead of "%v"`, len(subscriptions), 2) 24 | } 25 | 26 | if subscriptions[0].Title != testSubscriptionTitle { 27 | t.Fatalf(`Invalid feed title, got "%v" instead of "%v"`, subscriptions[0].Title, testSubscriptionTitle) 28 | } 29 | 30 | if subscriptions[0].Type != "atom" { 31 | t.Fatalf(`Invalid feed type, got "%v" instead of "%v"`, subscriptions[0].Type, "atom") 32 | } 33 | 34 | if subscriptions[0].URL != testFeedURL { 35 | t.Fatalf(`Invalid feed URL, got "%v" instead of "%v"`, subscriptions[0].URL, testFeedURL) 36 | } 37 | } 38 | 39 | func TestDiscoverSubscriptionsWithInvalidURL(t *testing.T) { 40 | client := createClient(t) 41 | _, err := client.Discover("invalid") 42 | if err == nil { 43 | t.Fatal(`Invalid URLs should be rejected`) 44 | } 45 | } 46 | 47 | func TestDiscoverSubscriptionsWithNoSubscription(t *testing.T) { 48 | client := createClient(t) 49 | _, err := client.Discover(testBaseURL) 50 | if err != miniflux.ErrNotFound { 51 | t.Fatal(`A 404 should be returned when there is no subscription`) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/locale/error.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package locale // import "miniflux.app/v2/internal/locale" 5 | 6 | import "errors" 7 | 8 | type LocalizedErrorWrapper struct { 9 | originalErr error 10 | translationKey string 11 | translationArgs []any 12 | } 13 | 14 | func NewLocalizedErrorWrapper(originalErr error, translationKey string, translationArgs ...any) *LocalizedErrorWrapper { 15 | return &LocalizedErrorWrapper{ 16 | originalErr: originalErr, 17 | translationKey: translationKey, 18 | translationArgs: translationArgs, 19 | } 20 | } 21 | 22 | func (l *LocalizedErrorWrapper) Error() error { 23 | return l.originalErr 24 | } 25 | 26 | func (l *LocalizedErrorWrapper) Translate(language string) string { 27 | if l.translationKey == "" { 28 | return l.originalErr.Error() 29 | } 30 | return NewPrinter(language).Printf(l.translationKey, l.translationArgs...) 31 | } 32 | 33 | type LocalizedError struct { 34 | translationKey string 35 | translationArgs []any 36 | } 37 | 38 | func NewLocalizedError(translationKey string, translationArgs ...any) *LocalizedError { 39 | return &LocalizedError{translationKey: translationKey, translationArgs: translationArgs} 40 | } 41 | 42 | func (v *LocalizedError) String() string { 43 | return NewPrinter("en_US").Printf(v.translationKey, v.translationArgs...) 44 | } 45 | 46 | func (v *LocalizedError) Error() error { 47 | return errors.New(v.String()) 48 | } 49 | 50 | func (v *LocalizedError) Translate(language string) string { 51 | return NewPrinter(language).Printf(v.translationKey, v.translationArgs...) 52 | } 53 | -------------------------------------------------------------------------------- /internal/template/templates/views/create_user.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.new_user.title" }}{{ end }} 2 | 3 | {{ define "content"}} 4 | 8 | 9 |
    10 | 11 | 12 | {{ if .errorMessage }} 13 |
    {{ .errorMessage }}
    14 | {{ end }} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
    28 | {{ t "action.or" }} {{ t "action.cancel" }} 29 |
    30 |
    31 | {{ end }} 32 | -------------------------------------------------------------------------------- /internal/template/templates/views/sessions.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.sessions.title" }}{{ end }} 2 | 3 | {{ define "content"}} 4 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {{ range .sessions }} 17 | 18 | 19 | 20 | 21 | 34 | 35 | {{ end }} 36 |
    {{ t "page.sessions.table.date" }}{{ t "page.sessions.table.ip" }}{{ t "page.sessions.table.user_agent" }}{{ t "page.sessions.table.actions" }}
    {{ elapsed $.user.Timezone .CreatedAt }}{{ .IP }}{{ .UserAgent }} 22 | {{ if eq .Token $.currentSessionToken }} 23 | {{ t "page.sessions.table.current_session" }} 24 | {{ else }} 25 | {{ icon "delete" }}{{ t "action.remove" }} 32 | {{ end }} 33 |
    37 | 38 | {{ end }} 39 | -------------------------------------------------------------------------------- /internal/crypto/crypto.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package crypto // import "miniflux.app/v2/internal/crypto" 5 | 6 | import ( 7 | "crypto/hmac" 8 | "crypto/rand" 9 | "crypto/sha256" 10 | "encoding/base64" 11 | "encoding/hex" 12 | "fmt" 13 | 14 | "golang.org/x/crypto/bcrypt" 15 | ) 16 | 17 | // HashFromBytes returns a SHA-256 checksum of the input. 18 | func HashFromBytes(value []byte) string { 19 | sum := sha256.Sum256(value) 20 | return fmt.Sprintf("%x", sum) 21 | } 22 | 23 | // Hash returns a SHA-256 checksum of a string. 24 | func Hash(value string) string { 25 | return HashFromBytes([]byte(value)) 26 | } 27 | 28 | // GenerateRandomBytes returns random bytes. 29 | func GenerateRandomBytes(size int) []byte { 30 | b := make([]byte, size) 31 | if _, err := rand.Read(b); err != nil { 32 | panic(err) 33 | } 34 | 35 | return b 36 | } 37 | 38 | // GenerateRandomString returns a random string. 39 | func GenerateRandomString(size int) string { 40 | return base64.URLEncoding.EncodeToString(GenerateRandomBytes(size)) 41 | } 42 | 43 | // GenerateRandomStringHex returns a random hexadecimal string. 44 | func GenerateRandomStringHex(size int) string { 45 | return hex.EncodeToString(GenerateRandomBytes(size)) 46 | } 47 | 48 | func HashPassword(password string) (string, error) { 49 | bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 50 | return string(bytes), err 51 | } 52 | 53 | func GenerateSHA256Hmac(secret string, data []byte) string { 54 | h := hmac.New(sha256.New, []byte(secret)) 55 | h.Write(data) 56 | return hex.EncodeToString(h.Sum(nil)) 57 | } 58 | -------------------------------------------------------------------------------- /internal/template/templates/views/category_feeds.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ .category.Title }} > {{ t "page.feeds.title" }} ({{ .total }}){{ end }} 2 | 3 | {{ define "content"}} 4 | 30 | 31 | {{ if not .feeds }} 32 |

    {{ t "alert.no_feed_in_category" }}

    33 | {{ else }} 34 | {{ template "feed_list" dict "user" .user "feeds" .feeds "ParsingErrorCount" .ParsingErrorCount }} 35 | {{ end }} 36 | 37 | {{ end }} 38 | --------------------------------------------------------------------------------