├── .devcontainer ├── devcontainer.json └── docker-compose.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ ├── documentation.yml │ ├── feature_request.yml │ ├── feed_issue.yml │ └── proposal.yml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── build_binaries.yml │ ├── codeql-analysis.yml │ ├── debian_packages.yml │ ├── docker.yml │ ├── linters.yml │ ├── rpm_packages.yml │ ├── scripts │ └── commit-checker.py │ └── tests.yml ├── .gitignore ├── ChangeLog ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── SECURITY.md ├── client ├── README.md ├── client.go ├── doc.go ├── model.go └── request.go ├── contrib ├── README.md ├── ansible │ ├── inventories │ │ └── group_vars │ │ │ └── miniflux_vars.yml │ ├── playbooks │ │ └── playbook.yml │ └── roles │ │ └── mgrote.miniflux │ │ ├── README.md │ │ ├── defaults │ │ └── main.yml │ │ ├── handlers │ │ └── main.yml │ │ ├── tasks │ │ └── main.yml │ │ └── templates │ │ └── miniflux.conf ├── bruno │ ├── README.md │ └── miniflux │ │ ├── Bookmark an entry.bru │ │ ├── Create a feed.bru │ │ ├── Create a new category.bru │ │ ├── Create a new user.bru │ │ ├── Delete a category.bru │ │ ├── Delete a feed.bru │ │ ├── Delete a user.bru │ │ ├── Discover feeds.bru │ │ ├── Fetch entry website content.bru │ │ ├── Flush history.bru │ │ ├── Get a single entry.bru │ │ ├── Get a single feed entry.bru │ │ ├── Get a single feed.bru │ │ ├── Get a single user by ID.bru │ │ ├── Get a single user by username.bru │ │ ├── Get all categories.bru │ │ ├── Get all entries.bru │ │ ├── Get all feeds.bru │ │ ├── Get all users.bru │ │ ├── Get category entries.bru │ │ ├── Get category entry.bru │ │ ├── Get category feeds.bru │ │ ├── Get current user.bru │ │ ├── Get feed counters.bru │ │ ├── Get feed entries.bru │ │ ├── Get feed icon by feed ID.bru │ │ ├── Get feed icon by icon ID.bru │ │ ├── Get version and build information.bru │ │ ├── Mark all category entries as read.bru │ │ ├── Mark all user entries as read.bru │ │ ├── Mark feed as read.bru │ │ ├── OPML Export.bru │ │ ├── OPML Import.bru │ │ ├── Refresh a single feed.bru │ │ ├── Refresh all feeds.bru │ │ ├── Refresh category feeds.bru │ │ ├── Save an entry.bru │ │ ├── Update a category.bru │ │ ├── Update a feed.bru │ │ ├── Update a user.bru │ │ ├── Update entries status.bru │ │ ├── Update entry.bru │ │ ├── bruno.json │ │ └── environments │ │ └── Local.bru ├── docker-compose │ ├── Caddyfile │ ├── README.md │ ├── basic.yml │ ├── caddy.yml │ └── traefik.yml ├── grafana │ ├── README.md │ └── dashboard.json ├── sysvinit │ ├── README.md │ └── etc │ │ ├── default │ │ └── miniflux │ │ └── init.d │ │ └── miniflux └── thunder_client │ ├── README.md │ └── collection.json ├── go.mod ├── go.sum ├── internal ├── api │ ├── api.go │ ├── api_integration_test.go │ ├── api_key.go │ ├── category.go │ ├── enclosure.go │ ├── entry.go │ ├── feed.go │ ├── icon.go │ ├── middleware.go │ ├── opml.go │ ├── payload.go │ ├── subscription.go │ └── user.go ├── cli │ ├── ask_credentials.go │ ├── cleanup_tasks.go │ ├── cli.go │ ├── create_admin.go │ ├── daemon.go │ ├── export_feeds.go │ ├── flush_sessions.go │ ├── health_check.go │ ├── info.go │ ├── logger.go │ ├── refresh_feeds.go │ ├── reset_password.go │ └── scheduler.go ├── config │ ├── config.go │ ├── config_test.go │ ├── options.go │ ├── parser.go │ └── parser_test.go ├── crypto │ └── crypto.go ├── database │ ├── database.go │ ├── migrations.go │ ├── postgresql.go │ └── sqlite.go ├── fever │ ├── handler.go │ ├── middleware.go │ └── response.go ├── googlereader │ ├── handler.go │ ├── item.go │ ├── item_test.go │ ├── middleware.go │ ├── parameters.go │ ├── prefix_suffix.go │ ├── request_modifier.go │ ├── response.go │ └── stream.go ├── http │ ├── cookie │ │ └── cookie.go │ ├── request │ │ ├── client_ip.go │ │ ├── client_ip_test.go │ │ ├── context.go │ │ ├── context_test.go │ │ ├── cookie.go │ │ ├── cookie_test.go │ │ ├── params.go │ │ └── params_test.go │ ├── response │ │ ├── builder.go │ │ ├── builder_test.go │ │ ├── html │ │ │ ├── html.go │ │ │ └── html_test.go │ │ ├── json │ │ │ ├── json.go │ │ │ └── json_test.go │ │ ├── response.go │ │ └── xml │ │ │ ├── xml.go │ │ │ └── xml_test.go │ ├── route │ │ └── route.go │ └── server │ │ ├── httpd.go │ │ └── middleware.go ├── integration │ ├── apprise │ │ └── apprise.go │ ├── betula │ │ └── betula.go │ ├── cubox │ │ └── cubox.go │ ├── discord │ │ └── discord.go │ ├── espial │ │ └── espial.go │ ├── instapaper │ │ └── instapaper.go │ ├── integration.go │ ├── linkace │ │ └── linkace.go │ ├── linkding │ │ └── linkding.go │ ├── linkwarden │ │ └── linkwarden.go │ ├── matrixbot │ │ ├── client.go │ │ └── matrixbot.go │ ├── notion │ │ └── notion.go │ ├── ntfy │ │ └── ntfy.go │ ├── nunuxkeeper │ │ └── nunuxkeeper.go │ ├── omnivore │ │ └── omnivore.go │ ├── pinboard │ │ ├── pinboard.go │ │ └── post.go │ ├── pocket │ │ ├── connector.go │ │ └── pocket.go │ ├── pushover │ │ └── pushover.go │ ├── raindrop │ │ └── raindrop.go │ ├── readeck │ │ └── readeck.go │ ├── readwise │ │ └── readwise.go │ ├── rssbridge │ │ └── rssbridge.go │ ├── shaarli │ │ └── shaarli.go │ ├── shiori │ │ └── shiori.go │ ├── slack │ │ └── slack.go │ ├── telegrambot │ │ ├── client.go │ │ └── telegrambot.go │ ├── wallabag │ │ └── wallabag.go │ └── webhook │ │ └── webhook.go ├── locale │ ├── catalog.go │ ├── catalog_test.go │ ├── error.go │ ├── locale.go │ ├── locale_test.go │ ├── plural.go │ ├── plural_test.go │ ├── printer.go │ ├── printer_test.go │ └── translations │ │ ├── de_DE.json │ │ ├── el_EL.json │ │ ├── en_US.json │ │ ├── es_ES.json │ │ ├── fi_FI.json │ │ ├── fr_FR.json │ │ ├── hi_IN.json │ │ ├── id_ID.json │ │ ├── it_IT.json │ │ ├── ja_JP.json │ │ ├── nan_Latn_pehoeji.json │ │ ├── nl_NL.json │ │ ├── pl_PL.json │ │ ├── pt_BR.json │ │ ├── ro_RO.json │ │ ├── ru_RU.json │ │ ├── tr_TR.json │ │ ├── uk_UA.json │ │ ├── zh_CN.json │ │ └── zh_TW.json ├── mediaproxy │ ├── media_proxy_test.go │ ├── rewriter.go │ └── url.go ├── metric │ └── metric.go ├── model │ ├── api_key.go │ ├── app_session.go │ ├── categories_sort_options.go │ ├── category.go │ ├── enclosure.go │ ├── enclosure_test.go │ ├── entry.go │ ├── feed.go │ ├── feed_test.go │ ├── home_page.go │ ├── icon.go │ ├── integration.go │ ├── job.go │ ├── model.go │ ├── subscription.go │ ├── theme.go │ ├── user.go │ ├── user_session.go │ └── webauthn.go ├── oauth2 │ ├── authorization.go │ ├── google.go │ ├── manager.go │ ├── oidc.go │ ├── profile.go │ └── provider.go ├── proxyrotator │ ├── proxyrotator.go │ └── proxyrotator_test.go ├── reader │ ├── atom │ │ ├── atom_03.go │ │ ├── atom_03_adapter.go │ │ ├── atom_03_test.go │ │ ├── atom_10.go │ │ ├── atom_10_adapter.go │ │ ├── atom_10_test.go │ │ ├── atom_common.go │ │ └── parser.go │ ├── date │ │ ├── parser.go │ │ └── parser_test.go │ ├── dublincore │ │ └── dublincore.go │ ├── encoding │ │ ├── encoding.go │ │ ├── encoding_test.go │ │ └── testdata │ │ │ ├── invalid-prolog.xml │ │ │ ├── iso-8859-1-meta-after-1024.html │ │ │ ├── iso-8859-1.html │ │ │ ├── iso-8859-1.xml │ │ │ ├── utf8-incorrect-prolog.xml │ │ │ ├── utf8-meta-after-1024.html │ │ │ ├── utf8.html │ │ │ ├── utf8.xml │ │ │ ├── windows-1252-incorrect-prolog.xml │ │ │ └── windows-1252.xml │ ├── fetcher │ │ ├── encoding_wrappers.go │ │ ├── request_builder.go │ │ ├── response_handler.go │ │ └── response_handler_test.go │ ├── googleplay │ │ └── googleplay.go │ ├── handler │ │ └── handler.go │ ├── icon │ │ ├── checker.go │ │ ├── finder.go │ │ └── finder_test.go │ ├── itunes │ │ └── itunes.go │ ├── json │ │ ├── adapter.go │ │ ├── json.go │ │ ├── parser.go │ │ └── parser_test.go │ ├── media │ │ ├── media.go │ │ └── media_test.go │ ├── opml │ │ ├── handler.go │ │ ├── opml.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ ├── serializer.go │ │ ├── serializer_test.go │ │ └── subscription.go │ ├── parser │ │ ├── format.go │ │ ├── format_test.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ └── testdata │ │ │ ├── encoding_ISO-8859-1.xml │ │ │ ├── encoding_WINDOWS-1251.xml │ │ │ ├── large_atom.xml │ │ │ ├── large_rss.xml │ │ │ ├── no_encoding_ISO-8859-1.xml │ │ │ ├── rdf_UTF8.xml │ │ │ ├── small_atom.xml │ │ │ └── urdu_UTF8.xml │ ├── processor │ │ ├── bilibili.go │ │ ├── filters.go │ │ ├── nebula.go │ │ ├── odysee.go │ │ ├── processor.go │ │ ├── processor_test.go │ │ ├── reading_time.go │ │ ├── youtube.go │ │ └── youtube_test.go │ ├── rdf │ │ ├── adapter.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ └── rdf.go │ ├── readability │ │ ├── readability.go │ │ ├── readability_test.go │ │ └── testdata │ ├── readingtime │ │ ├── readingtime.go │ │ └── readingtime_test.go │ ├── rewrite │ │ ├── referer_override_test.go │ │ ├── rewrite_functions.go │ │ ├── rewriter.go │ │ ├── rewriter_test.go │ │ └── rules.go │ ├── rss │ │ ├── adapter.go │ │ ├── atom.go │ │ ├── feedburner.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ ├── podcast.go │ │ └── rss.go │ ├── sanitizer │ │ ├── sanitizer.go │ │ ├── sanitizer_test.go │ │ ├── srcset.go │ │ ├── srcset_test.go │ │ ├── strip_tags.go │ │ ├── strip_tags_test.go │ │ ├── testdata │ │ │ ├── miniflux_github.html │ │ │ └── miniflux_wikipedia.html │ │ ├── truncate.go │ │ └── truncate_test.go │ ├── scraper │ │ ├── rules.go │ │ ├── scraper.go │ │ ├── scraper_test.go │ │ └── testdata │ │ │ ├── iframe.html │ │ │ ├── iframe.html-result │ │ │ ├── img.html │ │ │ ├── img.html-result │ │ │ ├── p.html │ │ │ └── p.html-result │ ├── subscription │ │ ├── finder.go │ │ ├── finder_test.go │ │ └── subscription.go │ ├── urlcleaner │ │ ├── urlcleaner.go │ │ └── urlcleaner_test.go │ └── xml │ │ ├── decoder.go │ │ └── decoder_test.go ├── storage │ ├── api_key.go │ ├── batch.go │ ├── category.go │ ├── certificate_cache.go │ ├── enclosure.go │ ├── entry.go │ ├── entry_pagination_builder.go │ ├── entry_query_builder.go │ ├── feed.go │ ├── feed_query_builder.go │ ├── icon.go │ ├── integration.go │ ├── session.go │ ├── storage.go │ ├── timezone.go │ ├── user.go │ ├── user_session.go │ └── webauthn.go ├── systemd │ └── systemd.go ├── template │ ├── engine.go │ ├── functions.go │ ├── functions_test.go │ └── templates │ │ ├── common │ │ ├── enclosure_media_controls.html │ │ ├── entry_pagination.html │ │ ├── feed_list.html │ │ ├── feed_menu.html │ │ ├── item_meta.html │ │ ├── layout.html │ │ ├── pagination.html │ │ └── settings_menu.html │ │ ├── standalone │ │ └── offline.html │ │ └── views │ │ ├── about.html │ │ ├── add_subscription.html │ │ ├── api_keys.html │ │ ├── bookmark_entries.html │ │ ├── categories.html │ │ ├── category_entries.html │ │ ├── category_feeds.html │ │ ├── choose_subscription.html │ │ ├── create_api_key.html │ │ ├── create_category.html │ │ ├── create_user.html │ │ ├── edit_category.html │ │ ├── edit_feed.html │ │ ├── edit_user.html │ │ ├── entry.html │ │ ├── feed_entries.html │ │ ├── feeds.html │ │ ├── history_entries.html │ │ ├── import.html │ │ ├── integrations.html │ │ ├── login.html │ │ ├── search.html │ │ ├── sessions.html │ │ ├── settings.html │ │ ├── shared_entries.html │ │ ├── tag_entries.html │ │ ├── unread_entries.html │ │ ├── users.html │ │ └── webauthn_rename.html ├── timezone │ ├── timezone.go │ └── timezone_test.go ├── ui │ ├── about.go │ ├── api_key_create.go │ ├── api_key_list.go │ ├── api_key_remove.go │ ├── api_key_save.go │ ├── bookmark_entries.go │ ├── category_create.go │ ├── category_edit.go │ ├── category_entries.go │ ├── category_entries_all.go │ ├── category_entries_starred.go │ ├── category_feeds.go │ ├── category_list.go │ ├── category_mark_as_read.go │ ├── category_refresh.go │ ├── category_remove.go │ ├── category_remove_feed.go │ ├── category_save.go │ ├── category_update.go │ ├── entry_bookmark.go │ ├── entry_category.go │ ├── entry_enclosure_save_position.go │ ├── entry_feed.go │ ├── entry_read.go │ ├── entry_save.go │ ├── entry_scraper.go │ ├── entry_search.go │ ├── entry_tag.go │ ├── entry_toggle_bookmark.go │ ├── entry_unread.go │ ├── entry_update_status.go │ ├── feed_edit.go │ ├── feed_entries.go │ ├── feed_entries_all.go │ ├── feed_icon.go │ ├── feed_list.go │ ├── feed_mark_as_read.go │ ├── feed_refresh.go │ ├── feed_remove.go │ ├── feed_update.go │ ├── form │ │ ├── api_key.go │ │ ├── auth.go │ │ ├── category.go │ │ ├── feed.go │ │ ├── integration.go │ │ ├── settings.go │ │ ├── settings_test.go │ │ ├── subscription.go │ │ ├── user.go │ │ └── webauthn.go │ ├── handler.go │ ├── history_entries.go │ ├── history_flush.go │ ├── integration_pocket.go │ ├── integration_show.go │ ├── integration_update.go │ ├── login_check.go │ ├── login_show.go │ ├── logout.go │ ├── middleware.go │ ├── oauth2.go │ ├── oauth2_callback.go │ ├── oauth2_redirect.go │ ├── oauth2_unlink.go │ ├── offline.go │ ├── opml_export.go │ ├── opml_import.go │ ├── opml_upload.go │ ├── pagination.go │ ├── proxy.go │ ├── search.go │ ├── session │ │ └── session.go │ ├── session_list.go │ ├── session_remove.go │ ├── settings_show.go │ ├── settings_update.go │ ├── share.go │ ├── shared_entries.go │ ├── static │ │ ├── bin │ │ │ ├── favicon-16.png │ │ │ ├── favicon-32.png │ │ │ ├── favicon.ico │ │ │ ├── icon-120.png │ │ │ ├── icon-128.png │ │ │ ├── icon-152.png │ │ │ ├── icon-167.png │ │ │ ├── icon-180.png │ │ │ ├── icon-192.png │ │ │ ├── icon-512.png │ │ │ ├── maskable-icon-120.png │ │ │ ├── maskable-icon-192.png │ │ │ ├── maskable-icon-512.png │ │ │ └── sprite.svg │ │ ├── css │ │ │ ├── common.css │ │ │ ├── dark.css │ │ │ ├── light.css │ │ │ ├── sans_serif.css │ │ │ ├── serif.css │ │ │ └── system.css │ │ ├── js │ │ │ ├── .eslintrc.json │ │ │ ├── .jshintrc │ │ │ ├── app.js │ │ │ ├── bootstrap.js │ │ │ ├── keyboard_handler.js │ │ │ ├── modal_handler.js │ │ │ ├── request_builder.js │ │ │ ├── service_worker.js │ │ │ ├── touch_handler.js │ │ │ ├── tt.js │ │ │ └── webauthn_handler.js │ │ └── static.go │ ├── static_app_icon.go │ ├── static_favicon.go │ ├── static_javascript.go │ ├── static_manifest.go │ ├── static_stylesheet.go │ ├── subscription_add.go │ ├── subscription_bookmarklet.go │ ├── subscription_choose.go │ ├── subscription_submit.go │ ├── tag_entries_all.go │ ├── ui.go │ ├── unread_entries.go │ ├── unread_entry_category.go │ ├── unread_entry_feed.go │ ├── unread_mark_all_read.go │ ├── user_create.go │ ├── user_edit.go │ ├── user_list.go │ ├── user_remove.go │ ├── user_save.go │ ├── user_update.go │ ├── view │ │ └── view.go │ └── webauthn.go ├── urllib │ ├── url.go │ └── url_test.go ├── validator │ ├── api_key.go │ ├── category.go │ ├── enclosure.go │ ├── entry.go │ ├── entry_test.go │ ├── feed.go │ ├── subscription.go │ ├── user.go │ ├── validator.go │ └── validator_test.go ├── version │ └── version.go └── worker │ ├── pool.go │ └── worker.go ├── main.go ├── miniflux.1 └── packaging ├── debian ├── Dockerfile ├── build.sh ├── compat ├── control ├── copyright ├── miniflux.dirs ├── miniflux.manpages ├── miniflux.postinst └── rules ├── docker ├── alpine │ └── Dockerfile └── distroless │ └── Dockerfile ├── miniflux.conf ├── rpm ├── Dockerfile └── miniflux.spec └── systemd └── miniflux.service /.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 | } -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | app: 4 | image: mcr.microsoft.com/devcontainers/go:1.23 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:1.0 28 | restart: unless-stopped 29 | hostname: apprise 30 | volumes: 31 | postgres-data: null 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "docker" 9 | directory: "/packaging/docker/alpine" 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: "docker" 14 | directory: "/packaging/docker/distroless" 15 | schedule: 16 | interval: "weekly" 17 | 18 | - package-ecosystem: "docker" 19 | directory: "packaging/debian" 20 | schedule: 21 | interval: "weekly" 22 | 23 | - package-ecosystem: "docker" 24 | directory: "packaging/rpm" 25 | schedule: 26 | interval: "weekly" 27 | 28 | - package-ecosystem: "github-actions" 29 | directory: "/" 30 | schedule: 31 | interval: "weekly" 32 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Have you followed these guidelines? 2 | 3 | - [ ] I have tested my changes 4 | - [ ] There are no breaking changes 5 | - [ ] I have thoroughly tested my changes and verified there are no regressions 6 | - [ ] My commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/) 7 | - [ ] I have read this document: https://miniflux.app/faq.html#pull-request 8 | -------------------------------------------------------------------------------- /.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: Checkout 13 | uses: actions/checkout@v4 14 | - name: Set up Golang 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: "1.24.x" 18 | check-latest: true 19 | - name: Compile binaries 20 | env: 21 | CGO_ENABLED: 0 22 | run: make build 23 | - name: Upload binaries 24 | uses: actions/upload-artifact@v4 25 | with: 26 | name: binaries 27 | path: miniflux-* 28 | if-no-files-found: error 29 | retention-days: 5 30 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | permissions: read-all 4 | 5 | on: 6 | push: 7 | branches: [ main ] 8 | paths: 9 | - '**.js' 10 | - '**.go' 11 | - '!**_test.go' 12 | pull_request: 13 | # The branches below must be a subset of the branches above 14 | branches: [ main ] 15 | paths: 16 | - '**.js' 17 | - '**.go' 18 | - '!**_test.go' 19 | schedule: 20 | - cron: '45 22 * * 3' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze 25 | runs-on: ubuntu-latest 26 | permissions: 27 | actions: read 28 | contents: read 29 | security-events: write 30 | 31 | strategy: 32 | fail-fast: false 33 | 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v4 37 | 38 | - uses: actions/setup-go@v5 39 | with: 40 | go-version: "1.24.x" 41 | 42 | - name: Initialize CodeQL 43 | uses: github/codeql-action/init@v3 44 | 45 | - name: Autobuild 46 | uses: github/codeql-action/autobuild@v3 47 | 48 | - name: Perform CodeQL Analysis 49 | uses: github/codeql-action/analyze@v3 50 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | permissions: read-all 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | workflow_dispatch: 9 | 10 | jobs: 11 | jshint: 12 | name: Javascript Linter 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Install linters 17 | run: | 18 | sudo npm install -g jshint@2.13.6 eslint@8.57.0 19 | - name: Run jshint 20 | run: jshint internal/ui/static/js/*.js 21 | - name: Run ESLint 22 | run: eslint internal/ui/static/js/*.js 23 | 24 | golangci: 25 | name: Golang Linters 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: actions/setup-go@v5 30 | with: 31 | go-version: "1.24.x" 32 | - uses: golangci/golangci-lint-action@v8 33 | with: 34 | args: > 35 | --timeout 10m 36 | --disable errcheck 37 | --enable sqlclosecheck,misspell,whitespace,gocritic 38 | - name: Run gofmt linter 39 | run: gofmt -d -e . 40 | 41 | commitlint: 42 | if: github.event_name == 'pull_request' 43 | name: Commit Linter 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | with: 48 | fetch-depth: 0 49 | - name: Set up Python 50 | uses: actions/setup-python@v5 51 | with: 52 | python-version: '3.13' 53 | - name: Validate PR commits 54 | run: python3 .github/workflows/scripts/commit-checker.py --base ${{ github.event.pull_request.base.sha }} --head ${{ github.event.pull_request.head.sha }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ./*.sha256 2 | /miniflux 3 | .idea 4 | .vscode 5 | *.deb 6 | *.rpm 7 | miniflux-* 8 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: miniflux.app 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.NewClient("https://api.example.org", "admin", "secret") 31 | 32 | // Authentication with an API Key: 33 | client := miniflux.NewClient("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 | -------------------------------------------------------------------------------- /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.NewClient("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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/ansible/playbooks/playbook.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: miniflux 3 | roles: 4 | - { role: mgrote.miniflux, tags: "miniflux" } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /contrib/ansible/roles/mgrote.miniflux/defaults/main.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/contrib/ansible/roles/mgrote.miniflux/defaults/main.yml -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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 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/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 | -------------------------------------------------------------------------------- /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/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/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 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 | -------------------------------------------------------------------------------- /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 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 | -------------------------------------------------------------------------------- /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 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 | -------------------------------------------------------------------------------- /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 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 | -------------------------------------------------------------------------------- /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 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 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/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 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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /contrib/bruno/miniflux/bruno.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1", 3 | "name": "Miniflux", 4 | "type": "collection" 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/docker-compose/Caddyfile: -------------------------------------------------------------------------------- 1 | miniflux.example.org 2 | reverse_proxy miniflux:8080 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | - POSTGRES_DB=miniflux 28 | volumes: 29 | - miniflux-db:/var/lib/postgresql/data 30 | healthcheck: 31 | test: ["CMD", "pg_isready", "-U", "miniflux"] 32 | interval: 10s 33 | start_period: 30s 34 | volumes: 35 | miniflux-db: 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /contrib/grafana/README.md: -------------------------------------------------------------------------------- 1 | Grafana Dashboard for Miniflux 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module miniflux.app/v2 2 | 3 | // +heroku goVersion go1.23 4 | 5 | require ( 6 | github.com/PuerkitoBio/goquery v1.10.3 7 | github.com/andybalholm/brotli v1.1.1 8 | github.com/coreos/go-oidc/v3 v3.14.1 9 | github.com/go-webauthn/webauthn v0.13.0 10 | github.com/gorilla/mux v1.8.1 11 | github.com/lib/pq v1.10.9 12 | github.com/mattn/go-sqlite3 v1.14.28 13 | github.com/prometheus/client_golang v1.22.0 14 | github.com/tdewolff/minify/v2 v2.23.8 15 | golang.org/x/crypto v0.38.0 16 | golang.org/x/image v0.27.0 17 | golang.org/x/net v0.40.0 18 | golang.org/x/oauth2 v0.30.0 19 | golang.org/x/term v0.32.0 20 | ) 21 | 22 | require ( 23 | github.com/go-webauthn/x v0.1.21 // indirect 24 | github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 25 | github.com/google/go-tpm v0.9.5 // indirect 26 | ) 27 | 28 | require ( 29 | github.com/andybalholm/cascadia v1.3.3 // indirect 30 | github.com/beorn7/perks v1.0.1 // indirect 31 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 32 | github.com/fxamacker/cbor/v2 v2.8.0 // indirect 33 | github.com/go-jose/go-jose/v4 v4.0.5 // indirect 34 | github.com/google/uuid v1.6.0 // indirect 35 | github.com/mitchellh/mapstructure v1.5.0 // indirect 36 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 37 | github.com/prometheus/client_model v0.6.1 // indirect 38 | github.com/prometheus/common v0.62.0 // indirect 39 | github.com/prometheus/procfs v0.15.1 // indirect 40 | github.com/tdewolff/parse/v2 v2.8.1 // indirect 41 | github.com/x448/float16 v0.8.4 // indirect 42 | golang.org/x/sys v0.33.0 // indirect 43 | golang.org/x/text v0.25.0 // indirect 44 | google.golang.org/protobuf v1.36.5 // indirect 45 | ) 46 | 47 | go 1.23.0 48 | 49 | toolchain go1.24.1 50 | -------------------------------------------------------------------------------- /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.HasFeedIcon(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/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/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/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 an interactive 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/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 createAdminUserFromEnvironmentVariables(store *storage.Storage) { 16 | createAdminUser(store, config.Opts.AdminUsername(), config.Opts.AdminPassword()) 17 | } 18 | 19 | func createAdminUserFromInteractiveTerminal(store *storage.Storage) { 20 | username, password := askCredentials() 21 | createAdminUser(store, username, password) 22 | } 23 | 24 | func createAdminUser(store *storage.Storage, username, password string) { 25 | userCreationRequest := &model.UserCreationRequest{ 26 | Username: username, 27 | Password: password, 28 | IsAdmin: true, 29 | } 30 | 31 | if store.UserExists(userCreationRequest.Username) { 32 | slog.Info("Skipping admin user creation because it already exists", 33 | slog.String("username", userCreationRequest.Username), 34 | ) 35 | return 36 | } 37 | 38 | if validationErr := validator.ValidateUserCreationWithPassword(store, userCreationRequest); validationErr != nil { 39 | printErrorAndExit(validationErr.Error()) 40 | } 41 | 42 | if user, err := store.CreateUser(userCreationRequest); err != nil { 43 | printErrorAndExit(err) 44 | } else { 45 | slog.Info("Created new admin user", 46 | slog.String("username", user.Username), 47 | slog.Int64("user_id", user.ID), 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /internal/cli/scheduler.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 | "time" 9 | 10 | "miniflux.app/v2/internal/config" 11 | "miniflux.app/v2/internal/storage" 12 | "miniflux.app/v2/internal/worker" 13 | ) 14 | 15 | func runScheduler(store *storage.Storage, pool *worker.Pool) { 16 | slog.Debug(`Starting background scheduler...`) 17 | 18 | go feedScheduler( 19 | store, 20 | pool, 21 | config.Opts.PollingFrequency(), 22 | config.Opts.BatchSize(), 23 | config.Opts.PollingParsingErrorLimit(), 24 | ) 25 | 26 | go cleanupScheduler( 27 | store, 28 | config.Opts.CleanupFrequencyHours(), 29 | ) 30 | } 31 | 32 | func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit int) { 33 | for range time.Tick(time.Duration(frequency) * time.Minute) { 34 | // Generate a batch of feeds for any user that has feeds to refresh. 35 | batchBuilder := store.NewBatchBuilder() 36 | batchBuilder.WithBatchSize(batchSize) 37 | batchBuilder.WithErrorLimit(errorLimit) 38 | batchBuilder.WithoutDisabledFeeds() 39 | batchBuilder.WithNextCheckExpired() 40 | 41 | if jobs, err := batchBuilder.FetchJobs(); err != nil { 42 | slog.Error("Unable to fetch jobs from database", slog.Any("error", err)) 43 | } else if len(jobs) > 0 { 44 | slog.Info("Created a batch of feeds", 45 | slog.Int("nb_jobs", len(jobs)), 46 | ) 47 | pool.Push(jobs) 48 | } 49 | } 50 | } 51 | 52 | func cleanupScheduler(store *storage.Storage, frequency int) { 53 | for range time.Tick(time.Duration(frequency) * time.Hour) { 54 | runCleanupTasks(store) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /internal/config/parser_test.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 | import ( 7 | "testing" 8 | ) 9 | 10 | func TestParseBoolValue(t *testing.T) { 11 | scenarios := map[string]bool{ 12 | "": true, 13 | "1": true, 14 | "Yes": true, 15 | "yes": true, 16 | "True": true, 17 | "true": true, 18 | "on": true, 19 | "false": false, 20 | "off": false, 21 | "invalid": false, 22 | } 23 | 24 | for input, expected := range scenarios { 25 | result := parseBool(input, true) 26 | if result != expected { 27 | t.Errorf(`Unexpected result for %q, got %v instead of %v`, input, result, expected) 28 | } 29 | } 30 | } 31 | 32 | func TestParseStringValueWithUnsetVariable(t *testing.T) { 33 | if parseString("", "defaultValue") != "defaultValue" { 34 | t.Errorf(`Unset variables should returns the default value`) 35 | } 36 | } 37 | 38 | func TestParseStringValue(t *testing.T) { 39 | if parseString("test", "defaultValue") != "test" { 40 | t.Errorf(`Defined variables should returns the specified value`) 41 | } 42 | } 43 | 44 | func TestParseIntValueWithUnsetVariable(t *testing.T) { 45 | if parseInt("", 42) != 42 { 46 | t.Errorf(`Unset variables should returns the default value`) 47 | } 48 | } 49 | 50 | func TestParseIntValueWithInvalidInput(t *testing.T) { 51 | if parseInt("invalid integer", 42) != 42 { 52 | t.Errorf(`Invalid integer should returns the default value`) 53 | } 54 | } 55 | 56 | func TestParseIntValue(t *testing.T) { 57 | if parseInt("2018", 42) != 2018 { 58 | t.Errorf(`Defined variables should returns the specified value`) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /internal/database/postgresql.go: -------------------------------------------------------------------------------- 1 | //go:build !sqlite 2 | 3 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 4 | // SPDX-License-Identifier: Apache-2.0 5 | 6 | package database // import "miniflux.app/v2/internal/database" 7 | 8 | import ( 9 | "database/sql" 10 | "time" 11 | 12 | _ "github.com/lib/pq" 13 | ) 14 | 15 | // NewConnectionPool configures the database connection pool. 16 | func NewConnectionPool(dsn string, minConnections, maxConnections int, connectionLifetime time.Duration) (*sql.DB, error) { 17 | db, err := sql.Open("postgres", dsn) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | db.SetMaxOpenConns(maxConnections) 23 | db.SetMaxIdleConns(minConnections) 24 | db.SetConnMaxLifetime(connectionLifetime) 25 | 26 | return db, nil 27 | } 28 | 29 | func getDriverStr() string { 30 | return "postgresql" 31 | } 32 | -------------------------------------------------------------------------------- /internal/database/sqlite.go: -------------------------------------------------------------------------------- 1 | //go:build sqlite 2 | 3 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 4 | // SPDX-License-Identifier: Apache-2.0 5 | 6 | package database // import "miniflux.app/v2/internal/database" 7 | 8 | import ( 9 | "database/sql" 10 | "time" 11 | 12 | _ "github.com/mattn/go-sqlite3" 13 | ) 14 | 15 | // NewConnectionPool configures the database connection pool. 16 | func NewConnectionPool(dsn string, _, _ int, _ time.Duration) (*sql.DB, error) { 17 | db, err := sql.Open("sqlite3", dsn) 18 | if err != nil { 19 | return nil, err 20 | } 21 | return db, nil 22 | } 23 | 24 | func getDriverStr() string { 25 | return "sqlite3" 26 | } 27 | -------------------------------------------------------------------------------- /internal/googlereader/prefix_suffix.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package googlereader // import "miniflux.app/v2/internal/googlereader" 5 | 6 | const ( 7 | // StreamPrefix is the prefix for astreams (read/starred/reading list and so on) 8 | StreamPrefix = "user/-/state/com.google/" 9 | // UserStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on) 10 | UserStreamPrefix = "user/%d/state/com.google/" 11 | // LabelPrefix is the prefix for a label stream 12 | LabelPrefix = "user/-/label/" 13 | // UserLabelPrefix is the user specific prefix prefix for a label stream 14 | UserLabelPrefix = "user/%d/label/" 15 | // FeedPrefix is the prefix for a feed stream 16 | FeedPrefix = "feed/" 17 | // Read is the suffix for read stream 18 | Read = "read" 19 | // Starred is the suffix for starred stream 20 | Starred = "starred" 21 | // ReadingList is the suffix for reading list stream 22 | ReadingList = "reading-list" 23 | // KeptUnread is the suffix for kept unread stream 24 | KeptUnread = "kept-unread" 25 | // Broadcast is the suffix for broadcast stream 26 | Broadcast = "broadcast" 27 | // BroadcastFriends is the suffix for broadcast friends stream 28 | BroadcastFriends = "broadcast-friends" 29 | // Like is the suffix for like stream 30 | Like = "like" 31 | ) 32 | -------------------------------------------------------------------------------- /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 | "miniflux.app/v2/internal/config" 11 | ) 12 | 13 | // Cookie names. 14 | const ( 15 | CookieAppSessionID = "MinifluxAppSessionID" 16 | CookieUserSessionID = "MinifluxUserSessionID" 17 | ) 18 | 19 | // New creates a new cookie. 20 | func New(name, value string, isHTTPS bool, path string) *http.Cookie { 21 | return &http.Cookie{ 22 | Name: name, 23 | Value: value, 24 | Path: basePath(path), 25 | Secure: isHTTPS, 26 | HttpOnly: true, 27 | Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour), 28 | SameSite: http.SameSiteLaxMode, 29 | } 30 | } 31 | 32 | // Expired returns an expired cookie. 33 | func Expired(name string, isHTTPS bool, path string) *http.Cookie { 34 | return &http.Cookie{ 35 | Name: name, 36 | Value: "", 37 | Path: basePath(path), 38 | Secure: isHTTPS, 39 | HttpOnly: true, 40 | MaxAge: -1, 41 | Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), 42 | SameSite: http.SameSiteLaxMode, 43 | } 44 | } 45 | 46 | func basePath(path string) string { 47 | if path == "" { 48 | return "/" 49 | } 50 | return path 51 | } 52 | -------------------------------------------------------------------------------- /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 without considering HTTP headers. 34 | func FindRemoteIP(r *http.Request) string { 35 | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) 36 | if err != nil { 37 | remoteIP = r.RemoteAddr 38 | } 39 | return dropIPv6zone(remoteIP) 40 | } 41 | 42 | func dropIPv6zone(address string) string { 43 | i := strings.IndexByte(address, '%') 44 | if i != -1 { 45 | address = address[:i] 46 | } 47 | return address 48 | } 49 | -------------------------------------------------------------------------------- /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 != nil { 12 | return "" 13 | } 14 | 15 | return cookie.Value 16 | } 17 | -------------------------------------------------------------------------------- /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/http/response/response.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package response // import "miniflux.app/v2/internal/http/response" 5 | 6 | // ContentSecurityPolicyForUntrustedContent is the default CSP for untrusted content. 7 | // default-src 'none' disables all content sources 8 | // form-action 'none' disables all form submissions 9 | // sandbox enables a sandbox for the requested resource 10 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 11 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/form-action 12 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox 13 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src 14 | const ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-action 'none'; sandbox;` 15 | -------------------------------------------------------------------------------- /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/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/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/integration/betula/betula.go: -------------------------------------------------------------------------------- 1 | package betula 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | "time" 9 | 10 | "miniflux.app/v2/internal/urllib" 11 | "miniflux.app/v2/internal/version" 12 | ) 13 | 14 | const defaultClientTimeout = 10 * time.Second 15 | 16 | type Client struct { 17 | url string 18 | token string 19 | } 20 | 21 | func NewClient(url, token string) *Client { 22 | return &Client{url: url, token: token} 23 | } 24 | 25 | func (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) error { 26 | apiEndpoint, err := urllib.JoinBaseURLAndPath(c.url, "/save-link") 27 | if err != nil { 28 | return fmt.Errorf("betula: unable to generate save-link endpoint: %v", err) 29 | } 30 | 31 | values := url.Values{} 32 | values.Add("url", entryURL) 33 | values.Add("title", entryTitle) 34 | values.Add("tags", strings.Join(tags, ",")) 35 | 36 | request, err := http.NewRequest(http.MethodPost, apiEndpoint+"?"+values.Encode(), nil) 37 | if err != nil { 38 | return fmt.Errorf("betula: unable to create request: %v", err) 39 | } 40 | 41 | request.Header.Set("Content-Type", "application/x-www-form-urlencoded") 42 | request.Header.Set("User-Agent", "Miniflux/"+version.Version) 43 | request.AddCookie(&http.Cookie{Name: "betula-token", Value: c.token}) 44 | 45 | httpClient := &http.Client{Timeout: defaultClientTimeout} 46 | response, err := httpClient.Do(request) 47 | if err != nil { 48 | return fmt.Errorf("betula: unable to send request: %v", err) 49 | } 50 | defer response.Body.Close() 51 | 52 | if response.StatusCode >= 400 { 53 | return fmt.Errorf("betula: unable to create bookmark: url=%s status=%d", apiEndpoint, response.StatusCode) 54 | } 55 | 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /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 | // PushEntries 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 | "", 40 | ) 41 | 42 | return err 43 | } 44 | -------------------------------------------------------------------------------- /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/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 is the list of available languages. 7 | var AvailableLanguages = map[string]string{ 8 | "de_DE": "Deutsch", 9 | "el_EL": "Ελληνικά", 10 | "en_US": "English", 11 | "es_ES": "Español", 12 | "fi_FI": "Suomi", 13 | "fr_FR": "Français", 14 | "hi_IN": "हिन्दी", 15 | "id_ID": "Bahasa Indonesia", 16 | "it_IT": "Italiano", 17 | "ja_JP": "日本語", 18 | "nan_Latn_pehoeji": "Pe̍h-ōe-jī", 19 | "nl_NL": "Nederlands", 20 | "pl_PL": "Polski", 21 | "pt_BR": "Português Brasileiro", 22 | "ro_RO": "Română", 23 | "ru_RU": "Русский", 24 | "tr_TR": "Türkçe", 25 | "uk_UA": "Українська", 26 | "zh_CN": "简体中文", 27 | "zh_TW": "繁體中文", 28 | } 29 | -------------------------------------------------------------------------------- /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/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 | 10 | // APIKey represents an application API key. 11 | type APIKey struct { 12 | ID int64 `json:"id"` 13 | UserID int64 `json:"user_id"` 14 | Token string `json:"token"` 15 | Description string `json:"description"` 16 | LastUsedAt *time.Time `json:"last_used_at"` 17 | CreatedAt time.Time `json:"created_at"` 18 | } 19 | 20 | // APIKeys represents a collection of API Key. 21 | type APIKeys []*APIKey 22 | 23 | // APIKeyCreationRequest represents the request to create a new API Key. 24 | type APIKeyCreationRequest struct { 25 | Description string `json:"description"` 26 | } 27 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | type CategoryCreationRequest struct { 23 | Title string `json:"title"` 24 | HideGlobally bool `json:"hide_globally"` 25 | } 26 | 27 | type CategoryModificationRequest struct { 28 | Title *string `json:"title"` 29 | HideGlobally *bool `json:"hide_globally"` 30 | } 31 | 32 | func (c *CategoryModificationRequest) Patch(category *Category) { 33 | if c.Title != nil { 34 | category.Title = *c.Title 35 | } 36 | 37 | if c.HideGlobally != nil { 38 | category.HideGlobally = *c.HideGlobally 39 | } 40 | } 41 | 42 | // Categories represents a list of categories. 43 | type Categories []*Category 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/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 | -------------------------------------------------------------------------------- /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 | ExternalID string `json:"external_id"` 18 | } 19 | 20 | // DataURL returns the data URL of the icon. 21 | func (i *Icon) DataURL() string { 22 | return fmt.Sprintf("%s;base64,%s", i.MimeType, base64.StdEncoding.EncodeToString(i.Content)) 23 | } 24 | 25 | // Icons represents a list of icons. 26 | type Icons []*Icon 27 | 28 | // FeedIcon is a junction table between feeds and icons. 29 | type FeedIcon struct { 30 | FeedID int64 `json:"feed_id"` 31 | IconID int64 `json:"icon_id"` 32 | ExternalIconID string `json:"external_icon_id"` 33 | } 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | type Number interface { 7 | int | int64 | float64 8 | } 9 | 10 | func OptionalNumber[T Number](value T) *T { 11 | if value > 0 { 12 | return &value 13 | } 14 | return nil 15 | } 16 | 17 | func OptionalString(value string) *string { 18 | if value != "" { 19 | return &value 20 | } 21 | return nil 22 | } 23 | 24 | func SetOptionalField[T any](value T) *T { 25 | return &value 26 | } 27 | -------------------------------------------------------------------------------- /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 | ProxyURL string `json:"proxy_url"` 14 | FetchViaProxy bool `json:"fetch_via_proxy"` 15 | AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"` 16 | DisableHTTP2 bool `json:"disable_http2"` 17 | } 18 | -------------------------------------------------------------------------------- /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/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=%q, UserID=%q, IP=%q, Token=%q`, 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/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.Challenge, s.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/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 | 10 | "golang.org/x/oauth2" 11 | 12 | "miniflux.app/v2/internal/crypto" 13 | ) 14 | 15 | type Authorization struct { 16 | url string 17 | state string 18 | codeVerifier string 19 | } 20 | 21 | func (u *Authorization) RedirectURL() string { 22 | return u.url 23 | } 24 | 25 | func (u *Authorization) State() string { 26 | return u.state 27 | } 28 | 29 | func (u *Authorization) CodeVerifier() string { 30 | return u.codeVerifier 31 | } 32 | 33 | func GenerateAuthorization(config *oauth2.Config) *Authorization { 34 | codeVerifier := crypto.GenerateRandomStringHex(32) 35 | sum := sha256.Sum256([]byte(codeVerifier)) 36 | 37 | state := crypto.GenerateRandomStringHex(24) 38 | 39 | authUrl := config.AuthCodeURL( 40 | state, 41 | oauth2.SetAuthURLParam("code_challenge_method", "S256"), 42 | oauth2.SetAuthURLParam("code_challenge", base64.RawURLEncoding.EncodeToString(sum[:])), 43 | ) 44 | 45 | return &Authorization{ 46 | url: authUrl, 47 | state: state, 48 | codeVerifier: codeVerifier, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /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 | if clientSecret == "" { 43 | slog.Warn("OIDC client secret is empty or missing.") 44 | } 45 | 46 | return m 47 | } 48 | -------------------------------------------------------------------------------- /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/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/proxyrotator/proxyrotator.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package proxyrotator // import "miniflux.app/v2/internal/proxyrotator" 5 | 6 | import ( 7 | "net/url" 8 | "sync" 9 | ) 10 | 11 | var ProxyRotatorInstance *ProxyRotator 12 | 13 | // ProxyRotator manages a list of proxies and rotates through them. 14 | type ProxyRotator struct { 15 | proxies []*url.URL 16 | currentIndex int 17 | mutex sync.Mutex 18 | } 19 | 20 | // NewProxyRotator creates a new ProxyRotator with the given proxy URLs. 21 | func NewProxyRotator(proxyURLs []string) (*ProxyRotator, error) { 22 | parsedProxies := make([]*url.URL, 0, len(proxyURLs)) 23 | 24 | for _, p := range proxyURLs { 25 | proxyURL, err := url.Parse(p) 26 | if err != nil { 27 | return nil, err 28 | } 29 | parsedProxies = append(parsedProxies, proxyURL) 30 | } 31 | 32 | return &ProxyRotator{ 33 | proxies: parsedProxies, 34 | currentIndex: 0, 35 | mutex: sync.Mutex{}, 36 | }, nil 37 | } 38 | 39 | // GetNextProxy returns the next proxy in the rotation. 40 | func (pr *ProxyRotator) GetNextProxy() *url.URL { 41 | pr.mutex.Lock() 42 | defer pr.mutex.Unlock() 43 | 44 | if len(pr.proxies) == 0 { 45 | return nil 46 | } 47 | 48 | proxy := pr.proxies[pr.currentIndex] 49 | pr.currentIndex = (pr.currentIndex + 1) % len(pr.proxies) 50 | 51 | return proxy 52 | } 53 | 54 | // HasProxies checks if there are any proxies available in the rotator. 55 | func (pr *ProxyRotator) HasProxies() bool { 56 | pr.mutex.Lock() 57 | defer pr.mutex.Unlock() 58 | 59 | return len(pr.proxies) > 0 60 | } 61 | -------------------------------------------------------------------------------- /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 | "fmt" 8 | "io" 9 | 10 | "miniflux.app/v2/internal/model" 11 | xml_decoder "miniflux.app/v2/internal/reader/xml" 12 | ) 13 | 14 | // Parse returns a normalized feed struct from a Atom feed. 15 | func Parse(baseURL string, r io.ReadSeeker, version string) (*model.Feed, error) { 16 | switch version { 17 | case "0.3": 18 | atomFeed := new(Atom03Feed) 19 | if err := xml_decoder.NewXMLDecoder(r).Decode(atomFeed); err != nil { 20 | return nil, fmt.Errorf("atom: unable to parse Atom 0.3 feed: %w", err) 21 | } 22 | return NewAtom03Adapter(atomFeed).BuildFeed(baseURL), nil 23 | default: 24 | atomFeed := new(Atom10Feed) 25 | if err := xml_decoder.NewXMLDecoder(r).Decode(atomFeed); err != nil { 26 | return nil, fmt.Errorf("atom: unable to parse Atom 1.0 feed: %w", err) 27 | } 28 | return NewAtom10Adapter(atomFeed).BuildFeed(baseURL), nil 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /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 | type DublinCoreChannelElement struct { 7 | DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"` 8 | } 9 | 10 | type DublinCoreItemElement struct { 11 | DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"` 12 | DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"` 13 | DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"` 14 | DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"` 15 | } 16 | -------------------------------------------------------------------------------- /internal/reader/encoding/testdata/invalid-prolog.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 테스트 피드 4 | 5 | Café 6 | 7 | -------------------------------------------------------------------------------- /internal/reader/encoding/testdata/iso-8859-1-meta-after-1024.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/reader/encoding/testdata/iso-8859-1-meta-after-1024.html -------------------------------------------------------------------------------- /internal/reader/encoding/testdata/iso-8859-1.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/reader/encoding/testdata/iso-8859-1.html -------------------------------------------------------------------------------- /internal/reader/encoding/testdata/iso-8859-1.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/reader/encoding/testdata/iso-8859-1.xml -------------------------------------------------------------------------------- /internal/reader/encoding/testdata/utf8-incorrect-prolog.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 테스트 피드 4 | 5 | Café 6 | 7 | -------------------------------------------------------------------------------- /internal/reader/encoding/testdata/utf8-meta-after-1024.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | 42 | 43 | Frédéric 44 | 45 | 46 |

    Café

    47 | 48 | -------------------------------------------------------------------------------- /internal/reader/encoding/testdata/utf8.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Café 6 | 7 | 8 |

    Café

    9 | 10 | -------------------------------------------------------------------------------- /internal/reader/encoding/testdata/utf8.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 테스트 피드 4 | 5 | Café 6 | 7 | -------------------------------------------------------------------------------- /internal/reader/encoding/testdata/windows-1252-incorrect-prolog.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Euro € 5 | 6 | -------------------------------------------------------------------------------- /internal/reader/encoding/testdata/windows-1252.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/reader/encoding/testdata/windows-1252.xml -------------------------------------------------------------------------------- /internal/reader/fetcher/encoding_wrappers.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "compress/gzip" 5 | "io" 6 | 7 | "github.com/andybalholm/brotli" 8 | ) 9 | 10 | type brotliReadCloser struct { 11 | body io.ReadCloser 12 | brotliReader io.Reader 13 | } 14 | 15 | func NewBrotliReadCloser(body io.ReadCloser) *brotliReadCloser { 16 | return &brotliReadCloser{ 17 | body: body, 18 | brotliReader: brotli.NewReader(body), 19 | } 20 | } 21 | 22 | func (b *brotliReadCloser) Read(p []byte) (n int, err error) { 23 | return b.brotliReader.Read(p) 24 | } 25 | 26 | func (b *brotliReadCloser) Close() error { 27 | return b.body.Close() 28 | } 29 | 30 | type gzipReadCloser struct { 31 | body io.ReadCloser 32 | gzipReader io.Reader 33 | gzipErr error 34 | } 35 | 36 | func NewGzipReadCloser(body io.ReadCloser) *gzipReadCloser { 37 | return &gzipReadCloser{body: body} 38 | } 39 | 40 | func (gz *gzipReadCloser) Read(p []byte) (n int, err error) { 41 | if gz.gzipReader == nil { 42 | if gz.gzipErr == nil { 43 | gz.gzipReader, gz.gzipErr = gzip.NewReader(gz.body) 44 | } 45 | if gz.gzipErr != nil { 46 | return 0, gz.gzipErr 47 | } 48 | } 49 | 50 | return gz.gzipReader.Read(p) 51 | } 52 | 53 | func (gz *gzipReadCloser) Close() error { 54 | return gz.body.Close() 55 | } 56 | -------------------------------------------------------------------------------- /internal/reader/googleplay/googleplay.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package googleplay // import "miniflux.app/v2/internal/reader/googleplay" 5 | 6 | // Specs: 7 | // https://support.google.com/googleplay/podcasts/answer/6260341 8 | // https://www.google.com/schemas/play-podcasts/1.0/play-podcasts.xsd 9 | type GooglePlayChannelElement struct { 10 | GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 author"` 11 | GooglePlayEmail string `xml:"http://www.google.com/schemas/play-podcasts/1.0 email"` 12 | GooglePlayImage GooglePlayImageElement `xml:"http://www.google.com/schemas/play-podcasts/1.0 image"` 13 | GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"` 14 | GooglePlayCategory GooglePlayCategoryElement `xml:"http://www.google.com/schemas/play-podcasts/1.0 category"` 15 | } 16 | 17 | type GooglePlayItemElement struct { 18 | GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 author"` 19 | GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"` 20 | GooglePlayExplicit string `xml:"http://www.google.com/schemas/play-podcasts/1.0 explicit"` 21 | GooglePlayBlock string `xml:"http://www.google.com/schemas/play-podcasts/1.0 block"` 22 | GooglePlayNewFeedURL string `xml:"http://www.google.com/schemas/play-podcasts/1.0 new-feed-url"` 23 | } 24 | 25 | type GooglePlayImageElement struct { 26 | Href string `xml:"href,attr"` 27 | } 28 | 29 | type GooglePlayCategoryElement struct { 30 | Text string `xml:"text,attr"` 31 | } 32 | -------------------------------------------------------------------------------- /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 | jsonFeed := new(JSONFeed) 17 | if err := json.NewDecoder(data).Decode(&jsonFeed); err != nil { 18 | return nil, fmt.Errorf("json: unable to parse feed: %w", err) 19 | } 20 | 21 | return NewJSONAdapter(jsonFeed).BuildFeed(baseURL), nil 22 | } 23 | -------------------------------------------------------------------------------- /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 | Description: outline.Description, 38 | CategoryName: category, 39 | }) 40 | } else if outline.Outlines.HasChildren() { 41 | subscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.GetTitle())...) 42 | } 43 | } 44 | return subscriptions 45 | } 46 | -------------------------------------------------------------------------------- /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 | Description string 13 | } 14 | 15 | // Equals compare two subscriptions. 16 | func (s Subcription) Equals(subscription *Subcription) bool { 17 | return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL && 18 | s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName && 19 | s.Description == subscription.Description 20 | } 21 | 22 | // SubcriptionList is a list of subscriptions. 23 | type SubcriptionList []*Subcription 24 | -------------------------------------------------------------------------------- /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, 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.NewXMLDecoder(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 | for _, attr := range element.Attr { 47 | if attr.Name.Local == "version" && attr.Value == "0.3" { 48 | return FormatAtom, "0.3" 49 | } 50 | } 51 | return FormatAtom, "1.0" 52 | case "RDF": 53 | return FormatRDF, "" 54 | } 55 | } 56 | } 57 | 58 | return FormatUnknown, "" 59 | } 60 | -------------------------------------------------------------------------------- /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 | format, version := DetectFeedFormat(r) 23 | switch format { 24 | case FormatAtom: 25 | r.Seek(0, io.SeekStart) 26 | return atom.Parse(baseURL, r, version) 27 | case FormatRSS: 28 | r.Seek(0, io.SeekStart) 29 | return rss.Parse(baseURL, r) 30 | case FormatJSON: 31 | r.Seek(0, io.SeekStart) 32 | return json.Parse(baseURL, r) 33 | case FormatRDF: 34 | r.Seek(0, io.SeekStart) 35 | return rdf.Parse(baseURL, r) 36 | default: 37 | return nil, ErrFeedFormatNotDetected 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/reader/parser/testdata/encoding_ISO-8859-1.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/reader/parser/testdata/encoding_ISO-8859-1.xml -------------------------------------------------------------------------------- /internal/reader/parser/testdata/encoding_WINDOWS-1251.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/reader/parser/testdata/encoding_WINDOWS-1251.xml -------------------------------------------------------------------------------- /internal/reader/parser/testdata/no_encoding_ISO-8859-1.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/reader/parser/testdata/no_encoding_ISO-8859-1.xml -------------------------------------------------------------------------------- /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.ReadSeeker) (*model.Feed, error) { 16 | xmlFeed := new(RDF) 17 | if err := xml.NewXMLDecoder(data).Decode(xmlFeed); err != nil { 18 | return nil, fmt.Errorf("rdf: unable to parse feed: %w", err) 19 | } 20 | 21 | return NewRDFAdapter(xmlFeed).BuildFeed(baseURL), nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/reader/rdf/rdf.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 | "encoding/xml" 8 | 9 | "miniflux.app/v2/internal/reader/dublincore" 10 | ) 11 | 12 | // RDF sepcs: https://web.resource.org/rss/1.0/spec 13 | type RDF struct { 14 | XMLName xml.Name `xml:"http://www.w3.org/1999/02/22-rdf-syntax-ns# RDF"` 15 | Channel RDFChannel `xml:"channel"` 16 | Items []RDFItem `xml:"item"` 17 | } 18 | 19 | type RDFChannel struct { 20 | Title string `xml:"title"` 21 | Link string `xml:"link"` 22 | Description string `xml:"description"` 23 | dublincore.DublinCoreChannelElement 24 | } 25 | 26 | type RDFItem struct { 27 | Title string `xml:"http://purl.org/rss/1.0/ title"` 28 | Link string `xml:"link"` 29 | Description string `xml:"description"` 30 | dublincore.DublinCoreItemElement 31 | } 32 | -------------------------------------------------------------------------------- /internal/reader/readability/testdata: -------------------------------------------------------------------------------- 1 | ../../reader/sanitizer/testdata/ -------------------------------------------------------------------------------- /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 readingtime provides a function to estimate the reading time of an article. 5 | package readingtime 6 | 7 | import ( 8 | "math" 9 | "strings" 10 | "unicode" 11 | "unicode/utf8" 12 | 13 | "miniflux.app/v2/internal/reader/sanitizer" 14 | ) 15 | 16 | // EstimateReadingTime returns the estimated reading time of an article in minute. 17 | func EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed int) int { 18 | sanitizedContent := sanitizer.StripTags(content) 19 | truncationPoint := min(len(sanitizedContent), 50) 20 | 21 | if isCJK(sanitizedContent[:truncationPoint]) { 22 | return int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed))) 23 | } 24 | return int(math.Ceil(float64(len(strings.Fields(sanitizedContent))) / float64(defaultReadingSpeed))) 25 | } 26 | 27 | func isCJK(text string) bool { 28 | totalCJK := 0 29 | 30 | for _, r := range text[:min(len(text), 50)] { 31 | if unicode.Is(unicode.Han, r) || 32 | unicode.Is(unicode.Hangul, r) || 33 | unicode.Is(unicode.Hiragana, r) || 34 | unicode.Is(unicode.Katakana, r) || 35 | unicode.Is(unicode.Yi, r) || 36 | unicode.Is(unicode.Bopomofo, r) { 37 | totalCJK++ 38 | } 39 | } 40 | 41 | // if at least 50% of the text is CJK, odds are that the text is in CJK. 42 | return totalCJK > len(text)/50 43 | } 44 | -------------------------------------------------------------------------------- /internal/reader/rss/atom.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 | "miniflux.app/v2/internal/reader/atom" 8 | ) 9 | 10 | type AtomAuthor struct { 11 | Author atom.AtomPerson `xml:"http://www.w3.org/2005/Atom author"` 12 | } 13 | 14 | func (a *AtomAuthor) PersonName() string { 15 | return a.Author.PersonName() 16 | } 17 | 18 | type AtomLinks struct { 19 | Links []*atom.AtomLink `xml:"http://www.w3.org/2005/Atom link"` 20 | } 21 | -------------------------------------------------------------------------------- /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 | // FeedBurnerItemElement represents FeedBurner XML elements. 7 | type FeedBurnerItemElement 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/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.ReadSeeker) (*model.Feed, error) { 16 | rssFeed := new(RSS) 17 | decoder := xml.NewXMLDecoder(data) 18 | decoder.DefaultSpace = "rss" 19 | if err := decoder.Decode(rssFeed); err != nil { 20 | return nil, fmt.Errorf("rss: unable to parse feed: %w", err) 21 | } 22 | return NewRSSAdapter(rssFeed).BuildFeed(baseURL), nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/reader/rss/podcast.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 | "errors" 8 | "math" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | var ErrInvalidDurationFormat = errors.New("rss: invalid duration format") 14 | 15 | func getDurationInMinutes(rawDuration string) (int, error) { 16 | var sumSeconds int 17 | 18 | durationParts := strings.Split(rawDuration, ":") 19 | if len(durationParts) > 3 { 20 | return 0, ErrInvalidDurationFormat 21 | } 22 | 23 | for i, durationPart := range durationParts { 24 | durationPartValue, err := strconv.Atoi(durationPart) 25 | if err != nil { 26 | return 0, ErrInvalidDurationFormat 27 | } 28 | 29 | sumSeconds += int(math.Pow(60, float64(len(durationParts)-i-1))) * durationPartValue 30 | } 31 | 32 | return sumSeconds / 60, nil 33 | } 34 | -------------------------------------------------------------------------------- /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 | "io" 8 | "strings" 9 | 10 | "golang.org/x/net/html" 11 | ) 12 | 13 | // StripTags removes all HTML/XML tags from the input string. 14 | // This function must *only* be used for cosmetic purposes, not to prevent code injections like XSS. 15 | func StripTags(input string) string { 16 | tokenizer := html.NewTokenizer(strings.NewReader(input)) 17 | var buffer strings.Builder 18 | 19 | for { 20 | if tokenizer.Next() == html.ErrorToken { 21 | err := tokenizer.Err() 22 | if err == io.EOF { 23 | return buffer.String() 24 | } 25 | 26 | return "" 27 | } 28 | 29 | token := tokenizer.Token() 30 | if token.Type == html.TextToken { 31 | buffer.WriteString(token.Data) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /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/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 | 11 | // Collapse multiple spaces into a single space 12 | text = strings.Join(strings.Fields(text), " ") 13 | 14 | // Convert to runes to be safe with unicode 15 | runes := []rune(text) 16 | if len(runes) > max { 17 | return strings.TrimSpace(string(runes[:max])) + "…" 18 | } 19 | 20 | return text 21 | } 22 | -------------------------------------------------------------------------------- /internal/reader/scraper/testdata/iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
    5 | 6 | 7 | 8 | 9 | 10 |
    11 | 12 | 13 | -------------------------------------------------------------------------------- /internal/reader/scraper/testdata/iframe.html-result: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /internal/reader/scraper/testdata/img.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
    5 | 6 | 7 | 8 | 9 | 10 |
    11 | 12 | 13 | -------------------------------------------------------------------------------- /internal/reader/scraper/testdata/img.html-result: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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=%q, URL=%q, Type=%q`, s.Title, s.URL, s.Type) 21 | } 22 | 23 | // Subscriptions represents a list of subscription. 24 | type Subscriptions []*Subscription 25 | -------------------------------------------------------------------------------- /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 | 46 | // DBSize returns how much size the database is using in a pretty way. 47 | func (s *Storage) DBSize() (string, error) { 48 | var size string 49 | 50 | err := s.db.QueryRow("SELECT pg_size_pretty(pg_database_size(current_database()))").Scan(&size) 51 | if err != nil { 52 | return "", err 53 | } 54 | 55 | return size, nil 56 | } 57 | -------------------------------------------------------------------------------- /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/template/templates/common/entry_pagination.html: -------------------------------------------------------------------------------- 1 | {{ define "entry_pagination" }} 2 | 19 | {{ end }} 20 | -------------------------------------------------------------------------------- /internal/template/templates/common/feed_menu.html: -------------------------------------------------------------------------------- 1 | {{ define "feed_menu" }} 2 | 23 | {{ end }} 24 | -------------------------------------------------------------------------------- /internal/template/templates/common/settings_menu.html: -------------------------------------------------------------------------------- 1 | {{ define "settings_menu" }} 2 | 26 | {{ end }} 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/template/templates/views/create_api_key.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.new_api_key.title" }}{{ end }} 2 | 3 | {{ define "page_header"}} 4 | 8 | {{ end }} 9 | 10 | {{ define "content"}} 11 |
    12 | 13 | 14 | {{ if .errorMessage }} 15 | 16 | {{ end }} 17 | 18 | 19 | 20 | 21 |
    22 | {{ t "action.or" }} {{ t "action.cancel" }} 23 |
    24 |
    25 | {{ end }} 26 | -------------------------------------------------------------------------------- /internal/template/templates/views/create_category.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.new_category.title" }}{{ end }} 2 | 3 | {{ define "page_header"}} 4 | 14 | {{ end }} 15 | 16 | {{ define "content"}} 17 |
    18 | 19 | 20 | {{ if .errorMessage }} 21 | 22 | {{ end }} 23 | 24 | 25 | 26 | 27 |
    28 | {{ t "action.or" }} {{ t "action.cancel" }} 29 |
    30 |
    31 | {{ end }} 32 | -------------------------------------------------------------------------------- /internal/template/templates/views/feeds.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.feeds.title" }} ({{ .total }}){{ end }} 2 | 3 | {{ define "page_header"}} 4 | 8 | {{ end }} 9 | 10 | {{ define "content"}} 11 | {{ if not .feeds }} 12 | 13 | {{ else }} 14 | {{ template "feed_list" dict "user" .user "feeds" .feeds "ParsingErrorCount" .ParsingErrorCount }} 15 | {{ end }} 16 | 17 | {{ end }} 18 | -------------------------------------------------------------------------------- /internal/template/templates/views/import.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.import.title" }}{{ end }} 2 | 3 | {{ define "page_header"}} 4 | 8 | {{ end }} 9 | 10 | {{ define "content"}} 11 | {{ if .errorMessage }} 12 | 13 | {{ end }} 14 | 15 |
    16 | 17 | 18 | 19 | 20 | 21 |
    22 | 23 |
    24 |
    25 |
    26 |
    27 | 28 | 29 | 30 | 31 | 32 |
    33 | 34 |
    35 |
    36 | 37 | {{ end }} 38 | -------------------------------------------------------------------------------- /internal/template/templates/views/sessions.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.sessions.title" }}{{ end }} 2 | 3 | {{ define "page_header"}} 4 | 8 | {{ end }} 9 | 10 | {{ define "content"}} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {{ range .sessions }} 19 | 20 | 21 | 22 | 23 | 36 | 37 | {{ end }} 38 |
    {{ 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 }} 24 | {{ if eq .Token $.currentSessionToken }} 25 | {{ t "page.sessions.table.current_session" }} 26 | {{ else }} 27 | {{ icon "delete" }}{{ t "action.remove" }} 34 | {{ end }} 35 |
    39 | 40 | {{ end }} 41 | -------------------------------------------------------------------------------- /internal/template/templates/views/webauthn_rename.html: -------------------------------------------------------------------------------- 1 | {{ define "title"}}{{ t "page.webauthn_rename.title" }}{{ end }} 2 | 3 | {{ define "page_header"}} 4 | 7 | {{ end }} 8 | 9 | {{ define "content"}} 10 |
    11 | 12 | 13 | {{ if .errorMessage }} 14 | 15 | {{ end }} 16 | 17 | 18 | 19 | 20 |
    21 | 22 |
    23 |
    24 | {{ end }} 25 | -------------------------------------------------------------------------------- /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 | if t.Before(time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)) { 16 | return time.Date(0, time.January, 1, 0, 0, 0, 0, userTimezone) 17 | } 18 | 19 | // In this case, the provided date is already converted to the user timezone by Postgres, 20 | // but the timezone information is not set in the time struct. 21 | // We cannot use time.In() because the date will be converted a second time. 22 | return time.Date( 23 | t.Year(), 24 | t.Month(), 25 | t.Day(), 26 | t.Hour(), 27 | t.Minute(), 28 | t.Second(), 29 | t.Nanosecond(), 30 | userTimezone, 31 | ) 32 | } else if t.Location() != userTimezone { 33 | return t.In(userTimezone) 34 | } 35 | 36 | return t 37 | } 38 | 39 | // Now returns the current time with the given timezone. 40 | func Now(tz string) time.Time { 41 | return time.Now().In(getLocation(tz)) 42 | } 43 | 44 | func getLocation(tz string) *time.Location { 45 | loc, err := time.LoadLocation(tz) 46 | if err != nil { 47 | loc = time.Local 48 | } 49 | return loc 50 | } 51 | -------------------------------------------------------------------------------- /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 | dbSize, dbErr := h.store.DBSize() 26 | 27 | sess := session.New(h.store, request.SessionID(r)) 28 | view := view.New(h.tpl, r, sess) 29 | view.Set("version", version.Version) 30 | view.Set("commit", version.Commit) 31 | view.Set("build_date", version.BuildDate) 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 | view.Set("globalConfigOptions", config.Opts.SortedOptions(true)) 37 | view.Set("postgres_version", h.store.DatabaseVersion()) 38 | view.Set("go_version", runtime.Version()) 39 | 40 | if dbErr != nil { 41 | view.Set("db_usage", dbErr) 42 | } else { 43 | view.Set("db_usage", dbSize) 44 | } 45 | 46 | html.OK(w, r, view.Render("about")) 47 | } 48 | -------------------------------------------------------------------------------- /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 | user, err := h.store.UserByID(request.UserID(r)) 18 | if err != nil { 19 | html.ServerError(w, r, err) 20 | return 21 | } 22 | 23 | sess := session.New(h.store, request.SessionID(r)) 24 | view := view.New(h.tpl, r, sess) 25 | view.Set("form", &form.APIKeyForm{}) 26 | view.Set("menu", "settings") 27 | view.Set("user", user) 28 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 29 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 30 | 31 | html.OK(w, r, view.Render("create_api_key")) 32 | } 33 | -------------------------------------------------------------------------------- /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 | user, err := h.store.UserByID(request.UserID(r)) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | apiKeys, err := h.store.APIKeys(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("apiKeys", apiKeys) 31 | view.Set("menu", "settings") 32 | view.Set("user", user) 33 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 34 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 35 | 36 | html.OK(w, r, view.Render("api_keys")) 37 | } 38 | -------------------------------------------------------------------------------- /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) deleteAPIKey(w http.ResponseWriter, r *http.Request) { 15 | keyID := request.RouteInt64Param(r, "keyID") 16 | if err := h.store.DeleteAPIKey(request.UserID(r), keyID); err != nil { 17 | html.ServerError(w, r, err) 18 | return 19 | } 20 | 21 | html.Redirect(w, r, route.Path(h.router, "apiKeys")) 22 | } 23 | -------------------------------------------------------------------------------- /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_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 | user, err := h.store.UserByID(request.UserID(r)) 18 | if err != nil { 19 | html.ServerError(w, r, err) 20 | return 21 | } 22 | 23 | category, err := h.store.Category(request.UserID(r), request.RouteInt64Param(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 | categoryForm := form.CategoryForm{ 35 | Title: category.Title, 36 | HideGlobally: category.HideGlobally, 37 | } 38 | 39 | sess := session.New(h.store, request.SessionID(r)) 40 | view := view.New(h.tpl, r, sess) 41 | view.Set("form", categoryForm) 42 | view.Set("category", category) 43 | view.Set("menu", "categories") 44 | view.Set("user", user) 45 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 46 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 47 | 48 | html.OK(w, r, view.Render("edit_category")) 49 | } 50 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/category_remove_feed.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) removeCategoryFeed(w http.ResponseWriter, r *http.Request) { 15 | feedID := request.RouteInt64Param(r, "feedID") 16 | categoryID := request.RouteInt64Param(r, "categoryID") 17 | 18 | if !h.store.CategoryFeedExists(request.UserID(r), categoryID, feedID) { 19 | html.NotFound(w, r) 20 | return 21 | } 22 | 23 | if err := h.store.RemoveFeed(request.UserID(r), feedID); err != nil { 24 | html.ServerError(w, r, err) 25 | return 26 | } 27 | 28 | html.Redirect(w, r, route.Path(h.router, "categoryFeeds", "categoryID", categoryID)) 29 | } 30 | -------------------------------------------------------------------------------- /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 | json_parser "encoding/json" 8 | "net/http" 9 | 10 | "miniflux.app/v2/internal/http/request" 11 | "miniflux.app/v2/internal/http/response/json" 12 | ) 13 | 14 | func (h *handler) saveEnclosureProgression(w http.ResponseWriter, r *http.Request) { 15 | enclosureID := request.RouteInt64Param(r, "enclosureID") 16 | enclosure, err := h.store.GetEnclosure(enclosureID) 17 | if err != nil { 18 | json.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | if enclosure == nil { 23 | json.NotFound(w, r) 24 | return 25 | } 26 | 27 | type enclosurePositionSaveRequest struct { 28 | Progression int64 `json:"progression"` 29 | } 30 | 31 | var postData enclosurePositionSaveRequest 32 | if err := json_parser.NewDecoder(r.Body).Decode(&postData); err != nil { 33 | json.ServerError(w, r, err) 34 | return 35 | } 36 | enclosure.MediaProgression = postData.Progression 37 | 38 | if err := h.store.UpdateEnclosure(enclosure); err != nil { 39 | json.ServerError(w, r, err) 40 | return 41 | } 42 | 43 | json.Created(w, r, map[string]string{"message": "saved"}) 44 | } 45 | -------------------------------------------------------------------------------- /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_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/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_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) showFeedIcon(w http.ResponseWriter, r *http.Request) { 16 | externalIconID := request.RouteStringParam(r, "externalIconID") 17 | icon, err := h.store.IconByExternalID(externalIconID) 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", response.ContentSecurityPolicyForUntrustedContent) 30 | b.WithHeader("Content-Type", icon.MimeType) 31 | b.WithBody(icon.Content) 32 | if icon.MimeType != "image/svg+xml" { 33 | b.WithoutCompression() 34 | } 35 | b.Write() 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /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/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/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/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 | "strings" 9 | ) 10 | 11 | // APIKeyForm represents the API Key form. 12 | type APIKeyForm struct { 13 | Description string 14 | } 15 | 16 | // NewAPIKeyForm returns a new APIKeyForm. 17 | func NewAPIKeyForm(r *http.Request) *APIKeyForm { 18 | return &APIKeyForm{ 19 | Description: strings.TrimSpace(r.FormValue("description")), 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /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/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 bool 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") == "1", 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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/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 | ShowLast bool 13 | ShowFirst bool 14 | ShowPrev bool 15 | NextOffset int 16 | LastOffset int 17 | PrevOffset int 18 | FirstOffset int 19 | SearchQuery string 20 | } 21 | 22 | func getPagination(route string, total, offset, nbItemsPerPage int) pagination { 23 | nextOffset := 0 24 | prevOffset := 0 25 | 26 | firstOffset := 0 27 | lastOffset := (total / nbItemsPerPage) * nbItemsPerPage 28 | if lastOffset == total { 29 | lastOffset -= nbItemsPerPage 30 | } 31 | 32 | showNext := (total - offset) > nbItemsPerPage 33 | showPrev := offset > 0 34 | showLast := showNext 35 | showFirst := showPrev 36 | 37 | if showNext { 38 | nextOffset = offset + nbItemsPerPage 39 | } 40 | 41 | if showPrev { 42 | prevOffset = offset - nbItemsPerPage 43 | } 44 | 45 | return pagination{ 46 | Route: route, 47 | Total: total, 48 | Offset: offset, 49 | ItemsPerPage: nbItemsPerPage, 50 | ShowNext: showNext, 51 | ShowLast: showLast, 52 | NextOffset: nextOffset, 53 | LastOffset: lastOffset, 54 | ShowPrev: showPrev, 55 | ShowFirst: showFirst, 56 | PrevOffset: prevOffset, 57 | FirstOffset: firstOffset, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /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 | user, err := h.store.UserByID(request.UserID(r)) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | sessions, err := h.store.UserSessions(user.ID) 23 | if err != nil { 24 | html.ServerError(w, r, err) 25 | return 26 | } 27 | 28 | sessions.UseTimezone(user.Timezone) 29 | 30 | sess := session.New(h.store, request.SessionID(r)) 31 | view := view.New(h.tpl, r, sess) 32 | view.Set("currentSessionToken", request.UserSessionToken(r)) 33 | view.Set("sessions", sessions) 34 | view.Set("menu", "settings") 35 | view.Set("user", user) 36 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 37 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 38 | 39 | html.OK(w, r, view.Render("sessions")) 40 | } 41 | -------------------------------------------------------------------------------- /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/ui/static/bin/favicon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/favicon-16.png -------------------------------------------------------------------------------- /internal/ui/static/bin/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/favicon-32.png -------------------------------------------------------------------------------- /internal/ui/static/bin/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/favicon.ico -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/icon-120.png -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/icon-128.png -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/icon-152.png -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/icon-167.png -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/icon-180.png -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/icon-192.png -------------------------------------------------------------------------------- /internal/ui/static/bin/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/icon-512.png -------------------------------------------------------------------------------- /internal/ui/static/bin/maskable-icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/maskable-icon-120.png -------------------------------------------------------------------------------- /internal/ui/static/bin/maskable-icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/maskable-icon-192.png -------------------------------------------------------------------------------- /internal/ui/static/bin/maskable-icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miniflux/v2/0369f03940273646bb910673fc5f441297f28696/internal/ui/static/bin/maskable-icon-512.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /internal/ui/static/js/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2020": true 5 | }, 6 | "rules": { 7 | "indent": ["error", 4] 8 | } 9 | } -------------------------------------------------------------------------------- /internal/ui/static/js/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": true, 3 | "browser": true, 4 | "eqeqeq": true, 5 | "esversion": 11, 6 | "freeze": true, 7 | "latedef": "nofunc", 8 | "noarg": true, 9 | "nocomma": true, 10 | "nonbsp": true, 11 | "nonew": true, 12 | "noreturnawait": true, 13 | "shadow": true, 14 | "varstmt": true 15 | } -------------------------------------------------------------------------------- /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/ui/static/js/tt.js: -------------------------------------------------------------------------------- 1 | let ttpolicy; 2 | if (window.trustedTypes && trustedTypes.createPolicy) { 3 | //TODO: use an allow-list for `createScriptURL` 4 | if (!ttpolicy) { 5 | ttpolicy = trustedTypes.createPolicy('ttpolicy', { 6 | createScriptURL: src => src, 7 | createHTML: html => html, 8 | }); 9 | } 10 | } else { 11 | ttpolicy = { 12 | createScriptURL: src => src, 13 | createHTML: html => html, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /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.WithoutCompression() 35 | b.WithHeader("Content-Type", "image/png") 36 | case ".svg": 37 | b.WithHeader("Content-Type", "image/svg+xml") 38 | } 39 | 40 | b.WithBody(blob) 41 | b.Write() 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /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/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 | "strings" 10 | "time" 11 | 12 | "miniflux.app/v2/internal/http/request" 13 | "miniflux.app/v2/internal/http/response" 14 | "miniflux.app/v2/internal/http/response/html" 15 | "miniflux.app/v2/internal/http/route" 16 | "miniflux.app/v2/internal/ui/static" 17 | ) 18 | 19 | const licensePrefix = "//@license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0\n" 20 | const licenseSuffix = "\n//@license-end" 21 | 22 | func (h *handler) showJavascript(w http.ResponseWriter, r *http.Request) { 23 | filename := request.RouteStringParam(r, "name") 24 | etag, found := static.JavascriptBundleChecksums[filename] 25 | if !found { 26 | html.NotFound(w, r) 27 | return 28 | } 29 | 30 | response.New(w, r).WithCaching(etag, 48*time.Hour, func(b *response.Builder) { 31 | contents := static.JavascriptBundles[filename] 32 | 33 | if filename == "service-worker" { 34 | variables := fmt.Sprintf(`const OFFLINE_URL=%q;`, route.Path(h.router, "offline")) 35 | contents = append([]byte(variables), contents...) 36 | } 37 | 38 | // cloning the prefix since `append` mutates its first argument 39 | contents = append([]byte(strings.Clone(licensePrefix)), contents...) 40 | contents = append(contents, []byte(licenseSuffix)...) 41 | 42 | b.WithHeader("Content-Type", "text/javascript; charset=utf-8") 43 | b.WithBody(contents) 44 | b.Write() 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | user, err := h.store.UserByID(request.UserID(r)) 19 | if err != nil { 20 | html.ServerError(w, r, err) 21 | return 22 | } 23 | 24 | categories, err := h.store.Categories(user.ID) 25 | if err != nil { 26 | html.ServerError(w, r, err) 27 | return 28 | } 29 | 30 | sess := session.New(h.store, request.SessionID(r)) 31 | view := view.New(h.tpl, r, sess) 32 | view.Set("categories", categories) 33 | view.Set("menu", "feeds") 34 | view.Set("user", user) 35 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 36 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 37 | view.Set("defaultUserAgent", config.Opts.HTTPClientUserAgent()) 38 | view.Set("form", &form.SubscriptionForm{CategoryID: 0}) 39 | view.Set("hasProxyConfigured", config.Opts.HasHTTPClientProxyURLConfigured()) 40 | 41 | html.OK(w, r, view.Render("add_subscription")) 42 | } 43 | -------------------------------------------------------------------------------- /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/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 | user, err := h.store.UserByID(request.UserID(r)) 18 | if err != nil { 19 | html.ServerError(w, r, err) 20 | return 21 | } 22 | 23 | if !user.IsAdmin { 24 | html.Forbidden(w, r) 25 | return 26 | } 27 | 28 | sess := session.New(h.store, request.SessionID(r)) 29 | view := view.New(h.tpl, r, sess) 30 | view.Set("form", &form.UserForm{}) 31 | view.Set("menu", "settings") 32 | view.Set("user", user) 33 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 34 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 35 | 36 | html.OK(w, r, view.Render("create_user")) 37 | } 38 | -------------------------------------------------------------------------------- /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 | user, err := h.store.UserByID(request.UserID(r)) 19 | if err != nil { 20 | html.ServerError(w, r, err) 21 | return 22 | } 23 | 24 | if !user.IsAdmin { 25 | html.Forbidden(w, r) 26 | return 27 | } 28 | 29 | userID := request.RouteInt64Param(r, "userID") 30 | selectedUser, err := h.store.UserByID(userID) 31 | if err != nil { 32 | html.ServerError(w, r, err) 33 | return 34 | } 35 | 36 | if selectedUser == nil { 37 | html.NotFound(w, r) 38 | return 39 | } 40 | 41 | userForm := &form.UserForm{ 42 | Username: selectedUser.Username, 43 | IsAdmin: selectedUser.IsAdmin, 44 | } 45 | 46 | sess := session.New(h.store, request.SessionID(r)) 47 | view := view.New(h.tpl, r, sess) 48 | view.Set("form", userForm) 49 | view.Set("selected_user", selectedUser) 50 | view.Set("menu", "settings") 51 | view.Set("user", user) 52 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 53 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 54 | 55 | html.OK(w, r, view.Render("edit_user")) 56 | } 57 | -------------------------------------------------------------------------------- /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 | user, err := h.store.UserByID(request.UserID(r)) 17 | if err != nil { 18 | html.ServerError(w, r, err) 19 | return 20 | } 21 | 22 | if !user.IsAdmin { 23 | html.Forbidden(w, r) 24 | return 25 | } 26 | 27 | users, err := h.store.Users() 28 | if err != nil { 29 | html.ServerError(w, r, err) 30 | return 31 | } 32 | 33 | users.UseTimezone(user.Timezone) 34 | 35 | sess := session.New(h.store, request.SessionID(r)) 36 | view := view.New(h.tpl, r, sess) 37 | view.Set("users", users) 38 | view.Set("menu", "settings") 39 | view.Set("user", user) 40 | view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) 41 | view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) 42 | 43 | html.OK(w, r, view.Render("users")) 44 | } 45 | -------------------------------------------------------------------------------- /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/api_key.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 | func ValidateAPIKeyCreation(store *storage.Storage, userID int64, request *model.APIKeyCreationRequest) *locale.LocalizedError { 13 | if request.Description == "" { 14 | return locale.NewLocalizedError("error.fields_mandatory") 15 | } 16 | 17 | if store.APIKeyExists(userID, request.Description) { 18 | return locale.NewLocalizedError("error.api_key_already_exists") 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /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.CategoryCreationRequest) *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.CategoryModificationRequest) *locale.LocalizedError { 27 | if request.Title != nil { 28 | if *request.Title == "" { 29 | return locale.NewLocalizedError("error.title_required") 30 | } 31 | 32 | if store.AnotherCategoryExists(userID, categoryID, *request.Title) { 33 | return locale.NewLocalizedError("error.category_already_exists") 34 | } 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/validator/enclosure.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package validator 5 | 6 | import ( 7 | "fmt" 8 | 9 | "miniflux.app/v2/internal/model" 10 | ) 11 | 12 | func ValidateEnclosureUpdateRequest(request *model.EnclosureUpdateRequest) error { 13 | if request.MediaProgression < 0 { 14 | return fmt.Errorf(`media progression must an positive integer`) 15 | } 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /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 | if request.ProxyURL != "" && !IsValidURL(request.ProxyURL) { 18 | return locale.NewLocalizedError("error.invalid_proxy_url") 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 := range nbWorkers { 30 | worker := &Worker{id: i, store: store} 31 | go worker.Run(workerPool.queue) 32 | } 33 | 34 | return workerPool 35 | } 36 | -------------------------------------------------------------------------------- /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 | } 49 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packaging/debian/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/golang:1.24-bookworm AS build 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | 5 | RUN apt-get update -q && \ 6 | apt-get install -y -qq build-essential devscripts dh-make debhelper && \ 7 | mkdir -p /build/debian 8 | 9 | ADD . /src 10 | 11 | CMD ["/src/packaging/debian/build.sh"] 12 | -------------------------------------------------------------------------------- /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 | 13 | if [ "$PKG_ARCH" = "armhf" ]; then 14 | make miniflux-no-pie 15 | else 16 | CGO_ENABLED=0 make miniflux 17 | fi 18 | 19 | mkdir -p /build/debian && \ 20 | cd /build && \ 21 | cp /src/miniflux /build/ && \ 22 | cp /src/miniflux.1 /build/ && \ 23 | cp /src/LICENSE /build/ && \ 24 | cp /src/packaging/miniflux.conf /build/ && \ 25 | cp /src/packaging/systemd/miniflux.service /build/debian/ && \ 26 | cp /src/packaging/debian/compat /build/debian/compat && \ 27 | cp /src/packaging/debian/copyright /build/debian/copyright && \ 28 | cp /src/packaging/debian/miniflux.manpages /build/debian/miniflux.manpages && \ 29 | cp /src/packaging/debian/miniflux.postinst /build/debian/miniflux.postinst && \ 30 | cp /src/packaging/debian/rules /build/debian/rules && \ 31 | cp /src/packaging/debian/miniflux.dirs /build/debian/miniflux.dirs && \ 32 | echo "miniflux ($PKG_VERSION) experimental; urgency=low" > /build/debian/changelog && \ 33 | echo " * Miniflux version $PKG_VERSION" >> /build/debian/changelog && \ 34 | echo " -- Frédéric Guillot $PKG_DATE" >> /build/debian/changelog && \ 35 | sed "s/__PKG_ARCH__/${PKG_ARCH}/g" /src/packaging/debian/control > /build/debian/control && \ 36 | dpkg-buildpackage -us -uc -b && \ 37 | lintian --check --color always ../*.deb && \ 38 | cp ../*.deb /pkg/ 39 | -------------------------------------------------------------------------------- /packaging/debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packaging/debian/copyright: -------------------------------------------------------------------------------- 1 | Files: * 2 | Copyright: 2017-2023 Frederic Guillot 3 | License: Apache -------------------------------------------------------------------------------- /packaging/debian/miniflux.dirs: -------------------------------------------------------------------------------- 1 | etc 2 | usr/bin 3 | -------------------------------------------------------------------------------- /packaging/debian/miniflux.manpages: -------------------------------------------------------------------------------- 1 | miniflux.1 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/docker/alpine/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/library/golang:alpine3.20 AS build 2 | RUN apk add --no-cache build-base git make 3 | ADD . /go/src/app 4 | WORKDIR /go/src/app 5 | RUN make miniflux 6 | 7 | FROM docker.io/library/alpine:3.21 8 | 9 | LABEL org.opencontainers.image.title=Miniflux 10 | LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader" 11 | LABEL org.opencontainers.image.vendor="Frédéric Guillot" 12 | LABEL org.opencontainers.image.licenses=Apache-2.0 13 | LABEL org.opencontainers.image.url=https://miniflux.app 14 | LABEL org.opencontainers.image.source=https://github.com/miniflux/v2 15 | LABEL org.opencontainers.image.documentation=https://miniflux.app/docs/ 16 | 17 | EXPOSE 8080 18 | ENV LISTEN_ADDR=0.0.0.0:8080 19 | RUN apk --no-cache add ca-certificates tzdata 20 | COPY --from=build /go/src/app/miniflux /usr/bin/miniflux 21 | USER 65534 22 | CMD ["/usr/bin/miniflux"] 23 | -------------------------------------------------------------------------------- /packaging/docker/distroless/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/library/golang:bookworm AS build 2 | ADD . /go/src/app 3 | WORKDIR /go/src/app 4 | RUN make miniflux 5 | 6 | FROM gcr.io/distroless/base-debian12:nonroot 7 | 8 | LABEL org.opencontainers.image.title=Miniflux 9 | LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader" 10 | LABEL org.opencontainers.image.vendor="Frédéric Guillot" 11 | LABEL org.opencontainers.image.licenses=Apache-2.0 12 | LABEL org.opencontainers.image.url=https://miniflux.app 13 | LABEL org.opencontainers.image.source=https://github.com/miniflux/v2 14 | LABEL org.opencontainers.image.documentation=https://miniflux.app/docs/ 15 | 16 | EXPOSE 8080 17 | ENV LISTEN_ADDR=0.0.0.0:8080 18 | COPY --from=build /go/src/app/miniflux /usr/bin/miniflux 19 | CMD ["/usr/bin/miniflux"] 20 | -------------------------------------------------------------------------------- /packaging/miniflux.conf: -------------------------------------------------------------------------------- 1 | # See https://miniflux.app/docs/configuration.html 2 | 3 | RUN_MIGRATIONS=1 4 | -------------------------------------------------------------------------------- /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 --setopt=install_weak_deps=False -y rpm-build systemd-rpm-macros 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 | --------------------------------------------------------------------------------