├── Docker └── Dockerfile ├── .jshintignore ├── xExtension-WordHighlighter ├── static │ ├── .gitignore │ ├── style.css │ ├── style.rtl.css │ ├── word-highlighter.js │ └── mark.min.js ├── snapshot.png ├── snapshot-dark.png ├── metadata.json ├── i18n │ ├── en │ │ └── ext.php │ ├── tr │ │ └── ext.php │ └── fr │ │ └── ext.php ├── README.md ├── configure.phtml └── extension.php ├── .stylelintignore ├── .gitignore ├── .markdownlintignore ├── xExtension-Captcha ├── screenshot.png ├── metadata.json ├── Controllers │ ├── userController.php │ └── authController.php ├── static │ ├── recaptcha-v3.js │ └── captchaConfig.js ├── i18n │ ├── en │ │ └── ext.php │ └── pl │ │ └── ext.php ├── README.md ├── configure.phtml └── extension.php ├── xExtension-ShareByEmail ├── static │ ├── shareByEmail.css │ └── shareByEmail.rtl.css ├── views │ ├── share_mailer │ │ └── article.txt.php │ └── shareByEmail │ │ └── share.phtml ├── metadata.json ├── Models │ └── View.php ├── configure.phtml ├── mailers │ └── Share.php ├── extension.php ├── README.md ├── i18n │ ├── en │ │ └── shareByEmail.php │ ├── tr │ │ └── shareByEmail.php │ ├── de │ │ └── shareByEmail.php │ └── fr │ │ └── shareByEmail.php └── Controllers │ └── shareByEmailController.php ├── xExtension-ColorfulList ├── snapshot.png ├── metadata.json ├── extension.php ├── README.md ├── LICENSE └── static │ └── script.js ├── xExtension-QuickCollapse ├── i18n │ ├── en │ │ └── gen.php │ ├── tr │ │ └── gen.php │ ├── de │ │ └── gen.php │ └── fr │ │ └── gen.php ├── static │ ├── style.css │ ├── style.rtl.css │ ├── in.svg │ ├── out.svg │ └── script.js ├── metadata.json ├── README.md └── extension.php ├── .jshintrc ├── xExtension-showFeedID ├── i18n │ ├── en │ │ └── ext.php │ └── pl │ │ └── ext.php ├── metadata.json ├── README.md ├── extension.php └── static │ └── showfeedid.js ├── xExtension-ImageProxy ├── metadata.json ├── i18n │ ├── en │ │ └── ext.php │ ├── fr │ │ └── ext.php │ ├── nl │ │ └── ext.php │ ├── de │ │ └── ext.php │ └── tr │ │ └── ext.php ├── configure.phtml ├── README.md └── extension.php ├── xExtension-YouTube ├── metadata.json ├── i18n │ ├── en │ │ └── ext.php │ ├── de │ │ └── ext.php │ ├── tr │ │ └── ext.php │ ├── pl │ │ └── ext.php │ └── fr │ │ └── ext.php ├── README.md ├── configure.phtml ├── static │ └── fetchIcons.js └── extension.php ├── xExtension-ReadingTime ├── metadata.json ├── i18n │ ├── ja │ │ └── ext.php │ ├── en │ │ └── ext.php │ └── fr │ │ └── ext.php ├── LICENSE ├── README.md ├── configure.phtml ├── extension.php └── static │ └── readingtime.js ├── xExtension-UnsafeAutologin ├── extension.php ├── metadata.json ├── README.md └── Controllers │ └── authController.php ├── xExtension-StickyFeeds ├── metadata.json ├── extension.php ├── README.md └── static │ ├── style.css │ ├── style.rtl.css │ └── script.js ├── xExtension-TitleWrap ├── metadata.json ├── extension.php ├── static │ ├── title_wrap_legacy.css │ ├── title_wrap_legacy.rtl.css │ ├── title_wrap.css │ └── title_wrap.rtl.css └── README.md ├── .typos.toml ├── .markdownlint.json ├── .editorconfig ├── phpstan-third-party.neon ├── .github ├── dependabot.yml └── workflows │ ├── generate.yml │ └── tests.yml ├── phpstan.dist.neon ├── eslint.config.js ├── package.json ├── .stylelintrc.json ├── composer.json ├── Makefile ├── repositories.json ├── generate.php ├── phpcs.xml ├── composer.lock └── README.md /Docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.1-cli-alpine 2 | 3 | RUN apk add --no-cache git 4 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | node_modules/ 3 | symbolic/ 4 | third-party/ 5 | tmp/ 6 | vendor/ 7 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/static/.gitignore: -------------------------------------------------------------------------------- 1 | config-words.*.js 2 | config-words.*.txt 3 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | node_modules/ 3 | symbolic/ 4 | third-party/ 5 | tmp/ 6 | vendor/ 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | bin/ 3 | node_modules/ 4 | symbolic/ 5 | third-party/ 6 | tmp/ 7 | vendor/ 8 | -------------------------------------------------------------------------------- /.markdownlintignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | node_modules/ 3 | symbolic/ 4 | third-party/ 5 | tmp/ 6 | vendor/ 7 | -------------------------------------------------------------------------------- /xExtension-Captcha/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreshRSS/Extensions/HEAD/xExtension-Captcha/screenshot.png -------------------------------------------------------------------------------- /xExtension-ShareByEmail/static/shareByEmail.css: -------------------------------------------------------------------------------- 1 | .sbe-form-share textarea { 2 | width: 600px; 3 | height: 300px; 4 | } 5 | -------------------------------------------------------------------------------- /xExtension-ColorfulList/snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreshRSS/Extensions/HEAD/xExtension-ColorfulList/snapshot.png -------------------------------------------------------------------------------- /xExtension-ShareByEmail/static/shareByEmail.rtl.css: -------------------------------------------------------------------------------- 1 | .sbe-form-share textarea { 2 | width: 600px; 3 | height: 300px; 4 | } 5 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreshRSS/Extensions/HEAD/xExtension-WordHighlighter/snapshot.png -------------------------------------------------------------------------------- /xExtension-WordHighlighter/snapshot-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreshRSS/Extensions/HEAD/xExtension-WordHighlighter/snapshot-dark.png -------------------------------------------------------------------------------- /xExtension-QuickCollapse/i18n/en/gen.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'toggle_collapse' => 'Toggle collapse', 6 | ], 7 | ]; 8 | -------------------------------------------------------------------------------- /xExtension-QuickCollapse/i18n/tr/gen.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'toggle_collapse' => 'Daraltmayı değiştir', 6 | ], 7 | ]; 8 | -------------------------------------------------------------------------------- /xExtension-QuickCollapse/i18n/de/gen.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'toggle_collapse' => 'Artikel auf/zu-klappen', 6 | ], 7 | ]; 8 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion" : 8, 3 | "browser" : true, 4 | "globals": { 5 | "confirm": true, 6 | "console": true 7 | }, 8 | "strict": "global" 9 | } 10 | -------------------------------------------------------------------------------- /xExtension-QuickCollapse/i18n/fr/gen.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'toggle_collapse' => 'Afficher/masquer le contenu des articles', 6 | ], 7 | ]; 8 | -------------------------------------------------------------------------------- /xExtension-showFeedID/i18n/en/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'show' => 'Show IDs', 6 | 'hide' => 'Hide IDs', 7 | ), 8 | ); 9 | -------------------------------------------------------------------------------- /xExtension-showFeedID/i18n/pl/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'show' => 'Pokaż ID', 6 | 'hide' => 'Ukryj ID', 7 | ), 8 | ); 9 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/views/share_mailer/article.txt.php: -------------------------------------------------------------------------------- 1 | content; 7 | -------------------------------------------------------------------------------- /xExtension-Captcha/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Form Captcha", 3 | "author": "Inverle", 4 | "description": "Protect register/login forms with captcha", 5 | "version": "1.0.2", 6 | "entrypoint": "Captcha", 7 | "type": "system" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-showFeedID/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ShowFeedID", 3 | "author": "math-GH, Inverle", 4 | "description": "Show the ID of feed and category", 5 | "version": "0.4.2", 6 | "entrypoint": "ShowFeedID", 7 | "type": "user" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Word highlighter", 3 | "author": "Lukas Melega", 4 | "description": "Highlight specific words", 5 | "version": "0.0.3", 6 | "entrypoint": "WordHighlighter", 7 | "type": "user" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-ColorfulList/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Colorful List", 3 | "author": "Claud Xiao", 4 | "description": "Colorful Entry Title based on RSS source", 5 | "version": "0.3.2", 6 | "entrypoint": "ColorfulList", 7 | "type": "user" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Share By Email", 3 | "author": "Marien Fressinaud", 4 | "description": "Improve the sharing by email system.", 5 | "version": "0.3.3", 6 | "entrypoint": "ShareByEmail", 7 | "type": "user" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-ImageProxy/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Image Proxy", 3 | "author": "Frans de Jonge", 4 | "description": "No insecure content warnings or disappearing images.", 5 | "version": "1.0", 6 | "entrypoint": "ImageProxy", 7 | "type": "user" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-YouTube/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "YouTube Video Feed", 3 | "author": "Kevin Papst, Inverle", 4 | "description": "Embed YouTube feeds inside article content.", 5 | "version": "1.1.0", 6 | "entrypoint": "YouTube", 7 | "type": "user" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-QuickCollapse/static/style.css: -------------------------------------------------------------------------------- 1 | #toggle-collapse .uncollapse { 2 | display: none; 3 | } 4 | 5 | #toggle-collapse.collapsed .collapse { 6 | display: none; 7 | } 8 | 9 | #toggle-collapse.collapsed .uncollapse { 10 | display: inherit; 11 | } 12 | -------------------------------------------------------------------------------- /xExtension-ReadingTime/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ReadingTime", 3 | "author": "Lapineige, hkcomori", 4 | "description": "Add a reading time estimation next to each article", 5 | "version": "1.6.1", 6 | "entrypoint": "ReadingTime", 7 | "type": "user" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-UnsafeAutologin/extension.php: -------------------------------------------------------------------------------- 1 | registerController('auth'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /xExtension-QuickCollapse/static/style.rtl.css: -------------------------------------------------------------------------------- 1 | #toggle-collapse .uncollapse { 2 | display: none; 3 | } 4 | 5 | #toggle-collapse.collapsed .collapse { 6 | display: none; 7 | } 8 | 9 | #toggle-collapse.collapsed .uncollapse { 10 | display: inherit; 11 | } 12 | -------------------------------------------------------------------------------- /xExtension-UnsafeAutologin/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Unsafe Autologin", 3 | "author": "Inverle", 4 | "description": "Brings back removed unsafe autologin feature from FreshRSS", 5 | "version": "1.0.0", 6 | "entrypoint": "UnsafeAutologin", 7 | "type": "system" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-QuickCollapse/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Quick Collapse", 3 | "author": "romibi and Marien Fressinaud", 4 | "description": "Quickly change from folded to unfolded articles", 5 | "version": "1.0.2", 6 | "entrypoint": "QuickCollapse", 7 | "type": "user" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-StickyFeeds/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sticky Feeds", 3 | "author": "Marien Fressinaud", 4 | "description": "Set the feed aside in the main stream following the window scroll.", 5 | "version": "0.2.2", 6 | "entrypoint": "StickyFeeds", 7 | "type": "user" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-ColorfulList/extension.php: -------------------------------------------------------------------------------- 1 | getFileUrl('script.js')); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /xExtension-UnsafeAutologin/README.md: -------------------------------------------------------------------------------- 1 | # Unsafe Autologin 2 | 3 | You can install this extension to bring back unsafe autologin functionality after enabling it. 4 | 5 | This extension should not be used on public multi-user instances. 6 | 7 | ## Changelog 8 | 9 | * 1.0.0 10 | * Initial release 11 | -------------------------------------------------------------------------------- /xExtension-showFeedID/README.md: -------------------------------------------------------------------------------- 1 | Extension for FreshRSS () 2 | 3 | It helps to find the feed IDs of each feed. 4 | 5 | Feed IDs are used f.e. for the search or user queries. 6 | 7 | Adds a button to the subscription management page. It will show the feed IDs of each feed in this overview. 8 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/Models/View.php: -------------------------------------------------------------------------------- 1 | getFileUrl('style.css')); 9 | Minz_View::appendScript($this->getFileUrl('script.js')); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /xExtension-ReadingTime/i18n/ja/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'speed' => array( 6 | 'label' => '読書速度', 7 | 'help' => '基準値を読書時間に変換する係数', 8 | ), 9 | 'metrics' => array( 10 | 'label' => '基準値', 11 | 'help' => '読書時間を推定する基準値', 12 | 'words' => '単語数', 13 | 'letters' => '文字数', 14 | ), 15 | ), 16 | ); 17 | -------------------------------------------------------------------------------- /xExtension-ImageProxy/i18n/en/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'proxy_url' => 'Proxy URL', 6 | 'scheme_http' => 'Proxy HTTP', 7 | 'scheme_https' => 'Proxy HTTPS', 8 | 'scheme_default' => 'Proxy protocol-relative URL', 9 | 'scheme_include' => 'Include http*:// in URL', 10 | 'url_encode' => 'Encode the URL' 11 | ), 12 | ); 13 | -------------------------------------------------------------------------------- /xExtension-ImageProxy/i18n/fr/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'proxy_url' => 'URL du proxy', 6 | 'scheme_http' => 'Proxy HTTP', 7 | 'scheme_https' => 'Proxy HTTPS', 8 | 'scheme_default' => 'Proxy URLs sans protocole', 9 | 'scheme_include' => 'Inclure http*:// dans l\'URL', 10 | 'url_encode' => 'Encoder l\'URL' 11 | ), 12 | ); 13 | -------------------------------------------------------------------------------- /xExtension-ImageProxy/i18n/nl/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'proxy_url' => 'Proxy-url', 6 | 'scheme_http' => 'Http proxyen', 7 | 'scheme_https' => 'Https proxyen', 8 | 'scheme_default' => 'Protocol-relatieve url’s proxyen', 9 | 'scheme_include' => 'http*:// in url opnemen', 10 | 'url_encode' => 'Url encoderen' 11 | ), 12 | ); 13 | -------------------------------------------------------------------------------- /xExtension-ImageProxy/i18n/de/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'proxy_url' => 'Proxy-URL', 6 | 'scheme_http' => 'HTTP-Proxy', 7 | 'scheme_https' => 'HTTPS-Proxy', 8 | 'scheme_default' => 'Proxy protokoll-relative URL', 9 | 'scheme_include' => 'http*:// in die URL einfügen', 10 | 'url_encode' => 'URL-Prozentkodierung' 11 | ), 12 | ); 13 | -------------------------------------------------------------------------------- /xExtension-StickyFeeds/README.md: -------------------------------------------------------------------------------- 1 | # Sticky Feeds extension (Deprecated) 2 | 3 | A FreshRSS extension which set the feed aside in the main stream following the window scroll. 4 | 5 | To use it, upload this directory in your `./extensions` directory and enable it on the extension panel in FreshRSS. 6 | 7 | ## Deprecated 8 | 9 | This function is already implemented into FreshRSS. 10 | -------------------------------------------------------------------------------- /xExtension-ColorfulList/README.md: -------------------------------------------------------------------------------- 1 | # FreshRSS Colorful List 2 | 3 | Generate light different background color for article list rows (relying on the feed name) 4 | 5 | ## Installation 6 | 7 | To use it, upload the *xExtension-ColorfulList* folder in your ./extensions directory and enable it on the extension panel in FreshRSS. 8 | 9 | ## Preview 10 | 11 | ![snapshot](snapshot.png) 12 | -------------------------------------------------------------------------------- /xExtension-ImageProxy/i18n/tr/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'proxy_url' => 'Vekil Bağlantısı', 6 | 'scheme_http' => 'HTTP Vekil Sunucusu', 7 | 'scheme_https' => 'HTTPS Vekil Sunucusu', 8 | 'scheme_default' => 'Belirtilmemiş Vekil URL Şeması', 9 | 'scheme_include' => 'Bağlantıya http*:// ekle', 10 | 'url_encode' => 'Bağlantıyı kodla', 11 | ), 12 | ); 13 | -------------------------------------------------------------------------------- /xExtension-StickyFeeds/static/style.css: -------------------------------------------------------------------------------- 1 | #aside_feed.sticky { 2 | position: relative; 3 | } 4 | 5 | #aside_feed.sticky .tree { 6 | position: absolute; 7 | left: 0; 8 | width: 100%; 9 | margin-top: 0; 10 | overflow-y: auto; 11 | } 12 | 13 | @media (max-width: 840px) { 14 | /* No effect on mobile. And yep, under 840px it is a mobile... a big one. */ 15 | #aside_feed.sticky .tree { 16 | position: static; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /xExtension-StickyFeeds/static/style.rtl.css: -------------------------------------------------------------------------------- 1 | #aside_feed.sticky { 2 | position: relative; 3 | } 4 | 5 | #aside_feed.sticky .tree { 6 | position: absolute; 7 | right: 0; 8 | width: 100%; 9 | margin-top: 0; 10 | overflow-y: auto; 11 | } 12 | 13 | @media (max-width: 840px) { 14 | /* No effect on mobile. And yep, under 840px it is a mobile... a big one. */ 15 | #aside_feed.sticky .tree { 16 | position: static; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /xExtension-QuickCollapse/README.md: -------------------------------------------------------------------------------- 1 | # xExtension-QuickCollapse 2 | 3 | This FreshRSS extension allows to quickly change from/to collapsed articles 4 | 5 | To install this extension, you must upload this directory in your `./extensions` directory and enable it on the extension panel in FreshRSS. 6 | 7 | ## Changelog 8 | 9 | - 1.0 Refactored using the js_vars extension hook 10 | - 0.2.2 Turkish language support added 11 | - 0.1 initial version 12 | -------------------------------------------------------------------------------- /xExtension-ReadingTime/i18n/en/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'speed' => array( 6 | 'label' => 'Reading speed', 7 | 'help' => 'Conversion factor to reading time from metrics', 8 | ), 9 | 'metrics' => array( 10 | 'label' => 'Source metrics', 11 | 'help' => 'Source of reading time calculation', 12 | 'words' => 'Number of words', 13 | 'letters' => 'Number of letters', 14 | ), 15 | ), 16 | ); 17 | -------------------------------------------------------------------------------- /xExtension-ReadingTime/i18n/fr/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'speed' => array( 6 | 'label' => 'Vitesse de lecture', 7 | 'help' => 'Paramètre de calcul, dépendant de la méthode', 8 | ), 9 | 'metrics' => array( 10 | 'label' => 'Méthodes', 11 | 'help' => 'Méthode de calcul du temps de lecture', 12 | 'words' => 'Nombre de mots', 13 | 'letters' => 'Nombre de lettres', 14 | ), 15 | ), 16 | ); 17 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | [default.extend-identifiers] 2 | ot = "ot" 3 | Ths2 = "Ths2" 4 | 5 | [default.extend-words] 6 | referer = "referer" 7 | 8 | [files] 9 | extend-exclude = [ 10 | ".git/", 11 | "*.fr.md", 12 | "*.map", 13 | "*.min.js", 14 | "*.rtl.css", 15 | "*/i18n/de", 16 | "*/i18n/fr", 17 | "*/i18n/pl", 18 | "bin/", 19 | "node_modules/", 20 | "symbolic/", 21 | "third-party/", 22 | "tmp/", 23 | "vendor/", 24 | "xExtension-ReadingTime/README.md" 25 | ] 26 | -------------------------------------------------------------------------------- /xExtension-TitleWrap/extension.php: -------------------------------------------------------------------------------- 1 | 0) { 9 | Minz_View::appendStyle($this->getFileUrl('title_wrap.css')); 10 | } else { 11 | // legacy <1.24.0 (= 1.23.2-dev) 12 | Minz_View::appendStyle($this->getFileUrl('title_wrap_legacy.css')); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/static/style.css: -------------------------------------------------------------------------------- 1 | /* WordHighlighter v0.0.2 (FreshRSS Extension) CSS */ 2 | #stream mark { 3 | padding: 2px; 4 | padding-right: 0; /* because in case when part of word is highlighted */ 5 | border-radius: 4px; 6 | } 7 | 8 | #stream mark.mark-secondary { 9 | background-color: rgba(255, 255, 0, 0.3) !important; 10 | } 11 | 12 | html[class*="darkMode"] #stream mark.mark-secondary { 13 | background-color: rgba(255, 255, 0, 0.5) !important; 14 | } 15 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/static/style.rtl.css: -------------------------------------------------------------------------------- 1 | /* WordHighlighter v0.0.2 (FreshRSS Extension) CSS */ 2 | #stream mark { 3 | padding: 2px; 4 | padding-left: 0; /* because in case when part of word is highlighted */ 5 | border-radius: 4px; 6 | } 7 | 8 | #stream mark.mark-secondary { 9 | background-color: rgba(255, 255, 0, 0.3) !important; 10 | } 11 | 12 | html[class*="darkMode"] #stream mark.mark-secondary { 13 | background-color: rgba(255, 255, 0, 0.5) !important; 14 | } 15 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "blanks-around-fences": false, 4 | "blanks-around-lists": false, 5 | "first-line-heading": false, 6 | "line-length": false, 7 | "no-hard-tabs": false, 8 | "no-inline-html": { 9 | "allowed_elements": ["br", "img", "kbd", "details", "summary"] 10 | }, 11 | "no-multiple-blanks": { 12 | "maximum": 2 13 | }, 14 | "no-trailing-spaces": true, 15 | "ul-indent": false, 16 | "ul-style": { 17 | "style": "consistent" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /xExtension-TitleWrap/static/title_wrap_legacy.css: -------------------------------------------------------------------------------- 1 | .horizontal-list { 2 | display: flex; 3 | } 4 | 5 | .horizontal-list.bottom { 6 | display: table; 7 | } 8 | 9 | .flux .flux_header .item { 10 | flex-shrink: 0; 11 | line-height: normal; 12 | } 13 | 14 | .flux .flux_header .item > a { 15 | white-space: normal; 16 | } 17 | 18 | .flux:not(.current):hover .flux_header .item.title { 19 | position: relative; 20 | max-width: inherit; 21 | } 22 | 23 | .flux .flux_header .title { 24 | flex: auto; 25 | } 26 | -------------------------------------------------------------------------------- /xExtension-TitleWrap/static/title_wrap_legacy.rtl.css: -------------------------------------------------------------------------------- 1 | .horizontal-list { 2 | display: flex; 3 | } 4 | 5 | .horizontal-list.bottom { 6 | display: table; 7 | } 8 | 9 | .flux .flux_header .item { 10 | flex-shrink: 0; 11 | line-height: normal; 12 | } 13 | 14 | .flux .flux_header .item > a { 15 | white-space: normal; 16 | } 17 | 18 | .flux:not(.current):hover .flux_header .item.title { 19 | position: relative; 20 | max-width: inherit; 21 | } 22 | 23 | .flux .flux_header .title { 24 | flex: auto; 25 | } 26 | -------------------------------------------------------------------------------- /xExtension-Captcha/Controllers/userController.php: -------------------------------------------------------------------------------- 1 | _csp($csp); 16 | 17 | parent::createAction(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://EditorConfig.org 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.{css,scss}] 10 | indent_style = tab 11 | 12 | [*.{html,php,phtml}] 13 | indent_style = tab 14 | 15 | [*.js] 16 | indent_style = tab 17 | 18 | [*.md] 19 | indent_size = 4 20 | indent_style = tab 21 | 22 | [*.neon] 23 | indent_style = tab 24 | 25 | [*.xml] 26 | indent_style = tab 27 | 28 | [*.yml] 29 | indent_size = 2 30 | indent_style = space 31 | 32 | [Makefile] 33 | indent_style = tab 34 | -------------------------------------------------------------------------------- /xExtension-YouTube/i18n/en/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'height' => 'Player height', 6 | 'width' => 'Player width', 7 | 'show_content' => 'Display the feeds content', 8 | 'download_channel_icons' => 'Automatically use the channels’ icons', 9 | 'fetch_channel_icons' => 'Fetch icons of all channels', 10 | 'reset_channel_icons' => 'Reset icons of all channels', 11 | 'fetching_icons' => 'Fetching icons', 12 | 'finished_fetching_icons' => 'Finished fetching icons.', 13 | 'use_nocookie' => 'Use the cookie-free domain www.youtube-nocookie.com', 14 | ), 15 | ); 16 | -------------------------------------------------------------------------------- /xExtension-YouTube/i18n/de/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'height' => 'Höhe des Players', 6 | 'width' => 'Breite des Players', 7 | 'show_content' => 'Zeige den Inhalt des Feeds an', 8 | 'download_channel_icons' => 'Automatically use the channels’ icons', 9 | 'fetch_channel_icons' => 'Fetch icons of all channels', 10 | 'reset_channel_icons' => 'Reset icons of all channels', 11 | 'fetching_icons' => 'Fetching icons', 12 | 'finished_fetching_icons' => 'Finished fetching icons.', 13 | 'use_nocookie' => 'Verwende die Cookie-freie Domain www.youtube-nocookie.com', 14 | ), 15 | ); 16 | -------------------------------------------------------------------------------- /xExtension-YouTube/i18n/tr/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'height' => 'Oynatıcı yükseklik', 6 | 'width' => 'Oynatıcı genişlik', 7 | 'show_content' => 'Yayın içeriğini görüntüle', 8 | 'download_channel_icons' => 'Automatically use the channels’ icons', 9 | 'fetch_channel_icons' => 'Fetch icons of all channels', 10 | 'reset_channel_icons' => 'Reset icons of all channels', 11 | 'fetching_icons' => 'Fetching icons', 12 | 'finished_fetching_icons' => 'Finished fetching icons.', 13 | 'use_nocookie' => 'Çerezsiz olan "www.youtube-nocookie.com" alan adını kullanın', 14 | ), 15 | ); 16 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/i18n/en/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'write_words' => 'Words to highlight', 6 | 'write_words_more' => '(separated by newline)', 7 | 'enable_in_article' => 'Enable highlighting also in article', 8 | 'enable_in_article_more' => '(⚠️ may be slower with a lot of words)', 9 | 'enable_logs' => 'Enable logs', 10 | 'case_sensitive' => 'Case sensitive', 11 | 'separate_word_search' => 'Separate word search', 12 | 'test_highlighting_word' => 'highlight', 13 | 'permission_problem' => 'Your config file is not writable, please change the file permissions for %s', 14 | ), 15 | ); 16 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/configure.phtml: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 9 |
10 | mailer) { 11 | 'mail' => _t('shareByEmail.share.manage.mail'), 12 | 'smtp' => _t('shareByEmail.share.manage.smtp', FreshRSS_Context::systemConf()->smtp['from']), 13 | default => _t('shareByEmail.share.manage.error') 14 | } ?> 15 |

16 |
17 |
18 | -------------------------------------------------------------------------------- /phpstan-third-party.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | phpVersion: 3 | min: 80100 # PHP 8.1 4 | max: 80499 # PHP 8.4 5 | level: 0 # https://phpstan.org/user-guide/rule-levels 6 | fileExtensions: 7 | - php 8 | - phtml 9 | paths: 10 | - ../FreshRSS 11 | - third-party/ 12 | excludePaths: 13 | analyse: 14 | - ../FreshRSS 15 | - third-party/*/vendor/*? 16 | analyseAndScan: 17 | - .git/*? 18 | - node_modules/*? 19 | - symbolic/*? 20 | - third-party/*/tests/*? 21 | - tmp/*? 22 | - vendor/ 23 | - xExtension-* 24 | dynamicConstantNames: 25 | - TYPE_GIT 26 | reportMaybesInPropertyPhpDocTypes: false 27 | treatPhpDocTypesAsCertain: false 28 | -------------------------------------------------------------------------------- /xExtension-YouTube/i18n/pl/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'height' => 'Wysokość odtwarzacza wideo', 6 | 'width' => 'Szerokość odtwarzacza wideo', 7 | 'show_content' => 'Wyświetlaj zawartość kanałów', 8 | 'download_channel_icons' => 'Automatycznie używaj ikon kanałów', 9 | 'fetch_channel_icons' => 'Pobierz ikony wszystkich kanałów', 10 | 'reset_channel_icons' => 'Przywróć domyślne ikony wszystkich kanałów', 11 | 'fetching_icons' => 'Pobieranie ikon', 12 | 'finished_fetching_icons' => 'Zakończono pobieranie ikon.', 13 | 'use_nocookie' => 'Używaj domeny bez ciasteczek www.youtube-nocookie.com', 14 | ), 15 | ); 16 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/i18n/tr/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'write_words' => 'Vurgulanacak kelimeler', 6 | 'write_words_more' => '(yeni satırla ayrılmış)', 7 | 'enable_in_article' => 'Makalede vurgulamayı da etkinleştir', 8 | 'enable_in_article_more' => '(⚠️ çok fazla kelimeyle daha yavaş olabilir)', 9 | 'enable_logs' => 'Günlükleri etkinleştir', 10 | 'case_sensitive' => 'Harfe duyarlı', 11 | 'separate_word_search' => 'Ayrı kelime araması', 12 | 'test_highlighting_word' => 'vurgulamak', 13 | 'permission_problem' => 'Yapılandırma dosyanız yazılabilir değil, lütfen %s için dosya izinlerini değiştirin', 14 | ), 15 | ); 16 | -------------------------------------------------------------------------------- /xExtension-YouTube/i18n/fr/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'height' => 'Hauteur du lecteur', 6 | 'width' => 'Largeur du lecteur', 7 | 'show_content' => 'Afficher le contenu du flux', 8 | 'download_channel_icons' => 'Utiliser automatiquement les icônes des chaînes', 9 | 'fetch_channel_icons' => 'Récupérer les icônes de toutes les chaînes', 10 | 'reset_channel_icons' => 'Réinitialiser les icônes de toutes les chaînes', 11 | 'fetching_icons' => 'Récupération des icônes', 12 | 'finished_fetching_icons' => 'Récupération des icônes terminée.', 13 | 'use_nocookie' => 'Utiliser le domaine www.youtube-nocookie.com pour éviter les cookies', 14 | ), 15 | ); 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | groups: 14 | eslint: 15 | patterns: 16 | - "*eslint*" 17 | - "globals" 18 | - "neostandard" 19 | stylelint: 20 | patterns: 21 | - "*stylelint*" 22 | - package-ecosystem: "composer" 23 | directory: "/" 24 | schedule: 25 | interval: "monthly" 26 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/i18n/fr/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'write_words' => 'Mots à surligner', 6 | 'write_words_more' => '(séparés par une nouvelle ligne)', 7 | 'enable_in_article' => 'Activer la mise en évidence également dans l’article', 8 | 'enable_in_article_more' => '(⚠️ peut être plus lent avec beaucoup de mots)', 9 | 'enable_logs' => 'Activer les journaux', 10 | 'case_sensitive' => 'Sensible à la casse', 11 | 'separate_word_search' => 'Recherche de mots séparés', 12 | 'test_highlighting_word' => 'surligner', 13 | 'permission_problem' => 'Votre fichier de configuration n’est pas accessible en écriture, veuillez modifier les permissions du fichier %s', 14 | ), 15 | ); 16 | -------------------------------------------------------------------------------- /xExtension-TitleWrap/README.md: -------------------------------------------------------------------------------- 1 | # TitleWrap extension 2 | 3 | FreshRSS extension which changes how article titles are being displayed. Instead of truncating a title when it overflows the display area, 4 | this extension applies a line-wrap to long article titles. 5 | 6 | To use it, upload this directory in your `./extensions` directory and enable it on the extension panel in FreshRSS. If you need more control, use the *User CSS* extension instead to specify your own CSS rules. 7 | 8 | The CSS code (since 0.3 legacy) used to wrap long titles was originally [proposed](https://github.com/FreshRSS/FreshRSS/issues/2344) by ₣rans de Jonge. 9 | 10 | ## Changelog 11 | 12 | - 0.3 ready for FreshRSS 1.23.2-dev (April 2023) / upcoming 1.24.0 13 | - 0.1 initial version 14 | -------------------------------------------------------------------------------- /xExtension-showFeedID/extension.php: -------------------------------------------------------------------------------- 1 | registerTranslates(); 11 | $this->registerHook('js_vars', [$this, 'jsVars']); 12 | Minz_View::appendScript($this->getFileUrl('showfeedid.js')); 13 | } 14 | } 15 | 16 | /** 17 | * @param array $vars 18 | * @return array 19 | */ 20 | public function jsVars(array $vars): array { 21 | $vars['showfeedid_i18n'] = [ 22 | 'show' => _t('ext.showfeedid.show'), 23 | 'hide' => _t('ext.showfeedid.hide') 24 | ]; 25 | return $vars; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /xExtension-TitleWrap/static/title_wrap.css: -------------------------------------------------------------------------------- 1 | .flux .flux_header .item { 2 | vertical-align: top; 3 | } 4 | 5 | .flux .flux_header .item .title { 6 | position: relative; 7 | } 8 | 9 | .flux .flux_header .item.website .websiteName, 10 | .flux .flux_header .item .title { 11 | white-space: wrap; 12 | } 13 | 14 | .flux .flux_header .item .summary { 15 | margin-top: -0.5rem; 16 | } 17 | 18 | .flux:not(.current):hover .flux_header .item .date { 19 | opacity: inherit; 20 | } 21 | 22 | .flux:not(.current):hover .flux_header .item .title:has(~ .date) { 23 | padding-right: 155px; 24 | z-index: auto; 25 | } 26 | 27 | @media (max-width: 840px) { 28 | .flux:not(.current) .flux_header .item.titleAuthorSummaryDate .title:has(~ .date), 29 | .flux:not(.current):hover .flux_header .item.titleAuthorSummaryDate .title:has(~ .date) { 30 | padding-right: 0; 31 | padding-left: 0; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /xExtension-TitleWrap/static/title_wrap.rtl.css: -------------------------------------------------------------------------------- 1 | .flux .flux_header .item { 2 | vertical-align: top; 3 | } 4 | 5 | .flux .flux_header .item .title { 6 | position: relative; 7 | } 8 | 9 | .flux .flux_header .item.website .websiteName, 10 | .flux .flux_header .item .title { 11 | white-space: wrap; 12 | } 13 | 14 | .flux .flux_header .item .summary { 15 | margin-top: -0.5rem; 16 | } 17 | 18 | .flux:not(.current):hover .flux_header .item .date { 19 | opacity: inherit; 20 | } 21 | 22 | .flux:not(.current):hover .flux_header .item .title:has(~ .date) { 23 | padding-left: 155px; 24 | z-index: auto; 25 | } 26 | 27 | @media (max-width: 840px) { 28 | .flux:not(.current) .flux_header .item.titleAuthorSummaryDate .title:has(~ .date), 29 | .flux:not(.current):hover .flux_header .item.titleAuthorSummaryDate .title:has(~ .date) { 30 | padding-left: 0; 31 | padding-right: 0; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /xExtension-QuickCollapse/extension.php: -------------------------------------------------------------------------------- 1 | registerTranslates(); 9 | $this->registerHook('js_vars', [$this, 'jsVars']); 10 | 11 | Minz_View::appendStyle($this->getFileUrl('style.css')); 12 | Minz_View::appendScript($this->getFileUrl('script.js'), cond: false, defer: true, async: false); 13 | } 14 | 15 | /** 16 | * @param array $vars 17 | * @return array 18 | */ 19 | public function jsVars(array $vars): array { 20 | $vars['quick_collapse'] = [ 21 | 'icon_url_in' => $this->getFileUrl('in.svg'), 22 | 'icon_url_out' => $this->getFileUrl('out.svg'), 23 | 'i18n' => [ 24 | 'toggle_collapse' => _t('gen.js.toggle_collapse'), 25 | ] 26 | ]; 27 | return $vars; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/mailers/Share.php: -------------------------------------------------------------------------------- 1 | view->_path('share_mailer/article.txt.php'); 24 | 25 | $this->view->content = $content; 26 | 27 | if (\FreshRSS_Context::hasSystemConf()) { 28 | $subject_prefix = '[' . \FreshRSS_Context::systemConf()->title . ']'; 29 | } else { 30 | $subject_prefix = ''; 31 | } 32 | return $this->mail($to, $subject_prefix . ' ' . $subject); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /xExtension-Captcha/static/recaptcha-v3.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals grecaptcha */ 4 | 5 | window.addEventListener('load', function () { 6 | const submitBtn = document.querySelector('[type="submit"]'); 7 | function listener(e) { 8 | e.preventDefault(); 9 | grecaptcha.ready(function () { 10 | grecaptcha.execute(document.querySelector('#siteKey').innerHTML, { action: 'submit' }).then(function (token) { 11 | const form = document.querySelector('form'); 12 | const res = form.querySelector('input[name="g-recaptcha-response"]'); 13 | if (res) { 14 | res.remove(); 15 | } 16 | 17 | form.insertAdjacentHTML('beforeend', ``); 18 | submitBtn.removeEventListener('click', listener); 19 | submitBtn.click(); 20 | submitBtn.addEventListener('click', listener); 21 | }); 22 | }); 23 | } 24 | submitBtn.addEventListener('click', listener); 25 | }); 26 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/extension.php: -------------------------------------------------------------------------------- 1 | registerTranslates(); 13 | 14 | $this->registerController('shareByEmail'); 15 | $this->registerViews(); 16 | 17 | FreshRSS_Share::register([ 18 | 'type' => 'email', 19 | 'url' => Minz_Url::display(['c' => 'shareByEmail', 'a' => 'share']) . '&id=~ID~', 20 | 'transform' => [], 21 | 'form' => 'simple', 22 | 'method' => 'GET', 23 | ]); 24 | 25 | spl_autoload_register(array($this, 'loader')); 26 | } 27 | 28 | public function loader(string $class_name): void { 29 | if (strpos($class_name, 'ShareByEmail') === 0) { 30 | $class_name = substr($class_name, 13); 31 | $base_path = $this->getPath() . '/'; 32 | include($base_path . str_replace('\\', '/', $class_name) . '.php'); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/generate.yml: -------------------------------------------------------------------------------- 1 | name: Generate extension list 2 | 3 | on: 4 | schedule: 5 | - cron: '11 11 * * *' 6 | workflow_dispatch: ~ 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | env: 15 | FILE: extensions.json 16 | 17 | steps: 18 | - name: Checkout source code # zizmor: ignore[artipacked] Credentials are needed later 19 | uses: actions/checkout@v6 20 | 21 | - name: Build JSON file 22 | run: php ./generate.php 23 | 24 | - name: Setup git 25 | run: | 26 | git config user.name "GitHub Actions Bot" 27 | git config user.email "<>" 28 | 29 | - name: Get changes 30 | id: diff 31 | run: | 32 | DIFF=$(git diff --numstat -- $FILE | wc -l) 33 | echo "DIFF=$DIFF" >> $GITHUB_OUTPUT 34 | 35 | - name: Commit changes 36 | run: | 37 | git add $FILE 38 | git commit -m 'Update extension list' 39 | git push origin $GITHUB_REF 40 | if: steps.diff.outputs.DIFF != 0 41 | -------------------------------------------------------------------------------- /xExtension-Captcha/Controllers/authController.php: -------------------------------------------------------------------------------- 1 | _csp($csp); 18 | 19 | parent::formLoginAction(); 20 | } 21 | 22 | /** 23 | * @throws FreshRSS_Context_Exception 24 | */ 25 | #[\Override] 26 | public function registerAction(): void { 27 | // Checking for valid captcha is not needed here since this isn't a POST action 28 | $csp = CaptchaExtension::loadDependencies(); 29 | if (!empty($csp)) $this->_csp($csp); 30 | 31 | parent::registerAction(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/README.md: -------------------------------------------------------------------------------- 1 | # xExtension-ShareByEmail 2 | 3 | This FreshRSS extension allows to share articles by email, in a more powerful way than the simple `mailto` solution. 4 | 5 | ## How to install 6 | 7 | To install this extension, you must upload this directory in your `./extensions` directory and enable it on the extension panel in FreshRSS. 8 | 9 | ## How to configure 10 | 11 | After the installation the `Email` sharing service will added to the list of available sharing services. You need to add the `Email` sharing service to your individual sharing service list before it is available in the sharing menu. 12 | 13 | You will have to configure the mailing system in FreshRSS. [See the documentation](https://freshrss.github.io/FreshRSS/en/admins/05_Configuring_email_validation.html#configure-the-smtp-server) 14 | 15 | ## Changelog 16 | 17 | - 0.3.2 With the new feature, Turkish language deficiencies have been corrected. 18 | - 0.3.0 detail information about the mail system shown in the extension config 19 | - 0.2.3 Turkish language support added 20 | - 0.1 initial version 21 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/README.md: -------------------------------------------------------------------------------- 1 | # WordHighlighter extension 2 | 3 | A FreshRSS extension which give ability to highlight user-defined words. 4 | 5 | ## Usage 6 | 7 | To use it, upload this directory in your `./extensions` directory and enable it on the extension panel in FreshRSS. You can add words to be highlighted by clicking on the manage button ⚙️. 8 | See also official docs at freshrss.github.io/FreshRSS/en/admins/15_extensions.html 9 | 10 | ## Preview 11 | 12 | Light theme: 13 | 14 | ![snapshot](./snapshot.png) 15 | 16 | 17 |
18 | click to see example screenshot in dark theme 19 | 20 | ![snapshot-dark-theme](./snapshot-dark.png) 21 | 22 |
23 | 24 | 25 | ## Changelog 26 | 27 | - 0.0.3 Turkish language support added. 28 | - 0.0.2 use `json` for storing configuration, add more configuration options 29 | (enable_in_article, enable_logs, case_sensitive, separate_word_search) 30 | and refactored & simplified code. 31 | - 0.0.1 initial version (as a proper FreshRSS extension) 32 | -------------------------------------------------------------------------------- /xExtension-ColorfulList/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 oyox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /xExtension-QuickCollapse/static/in.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /xExtension-QuickCollapse/static/out.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /xExtension-ReadingTime/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Lapineige, hkcomori 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/i18n/en/shareByEmail.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'feedback' => [ 6 | 'failed' => 'The email cannot be sent, please contact your administrator.', 7 | 'fields_required' => 'All the fields are required.', 8 | 'sent' => 'The email has been sent.', 9 | ], 10 | 'form' => [ 11 | 'cancel' => 'Cancel', 12 | 'content' => 'Content', 13 | 'content_default' => "Hi,\n\nYou might find this article quite interesting!\n\n%s – %s\n\n---\n\nThis email has been sent by %s via %s ( %s )", 14 | 'send' => 'Send', 15 | 'subject' => 'Subject', 16 | 'subject_default' => 'I found this article interesting!', 17 | 'to' => 'To', 18 | ], 19 | 'intro' => 'You are about to share this article by email: “%s”', 20 | 'title' => 'Share an article by email', 21 | 'manage' => [ 22 | 'mailer' => 'Mailing system', 23 | 'mail' => 'PHP mail()', 24 | 'smtp' => 'SMTP (send from %s)', 25 | 'error' => 'Error', 26 | 'help' => 'Switch PHP mail()/SMTP connection in config.php: see documentation' 27 | ], 28 | ], 29 | ]; 30 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/i18n/tr/shareByEmail.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'feedback' => [ 6 | 'failed' => 'E-posta gönderilemiyor, lütfen yöneticinizle iletişime geçin.', 7 | 'fields_required' => 'Tüm alanların doldurulması zorunludur.', 8 | 'sent' => 'E-posta gönderildi.', 9 | ], 10 | 'form' => [ 11 | 'cancel' => 'İptal', 12 | 'content' => 'İçerik', 13 | 'content_default' => "Merhaba,\n\nBu makaleyi oldukça ilginç bulabilirsiniz!\n\n%s – %s\n\n---\n\nBu e-posta %s tarafından %s ( %s ) aracılığıyla gönderildi", 14 | 'send' => 'Gönder', 15 | 'subject' => 'Konu', 16 | 'subject_default' => 'Bu makaleyi ilginç buldum!', 17 | 'to' => 'Kime', 18 | ], 19 | 'intro' => 'Bu makaleyi e-posta yoluyla paylaşmak üzeresiniz: “%s”', 20 | 'title' => 'Bir makaleyi e-posta ile paylaşın', 21 | 'manage' => [ 22 | 'mailer' => 'Mail sistemi', 23 | 'mail' => 'PHP mail()', 24 | 'smtp' => 'SMTP (%s kaynağından gönder)', 25 | 'error' => 'Hata', 26 | 'help' => 'config.php dosyasındaki PHP mail()/SMTP bağlantısını değiştirin: belgelere bakın', 27 | ] 28 | ], 29 | ]; 30 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/i18n/de/shareByEmail.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'feedback' => [ 6 | 'failed' => 'Die Mail konnte nicht gesendet werden. Bitte kontaktiere deinen Administrator.', 7 | 'fields_required' => 'Alle Felder sind Pflichtfelder.', 8 | 'sent' => 'Die Mail wurde gesendet.', 9 | ], 10 | 'form' => [ 11 | 'cancel' => 'Abbrechen', 12 | 'content' => 'Inhalt', 13 | 'content_default' => "Hi,\n\nIch glaube dieser Artikel ist interessant für dich!\n\n%s – %s\n\n---\n\nDiese Mail wurde von %s gesendet über %s ( %s )", 14 | 'send' => 'Senden', 15 | 'subject' => 'Betreff', 16 | 'subject_default' => 'Interessanter Artikel für dich!', 17 | 'to' => 'An', 18 | ], 19 | 'intro' => 'Diesen Artikel per Mail versenden: “%s”', 20 | 'title' => 'Einen Artikel per Mail teilen.', 21 | 'manage' => [ 22 | 'mailer' => 'E-Mail-Versand', 23 | 'mail' => 'via PHP mail()', 24 | 'smtp' => 'via SMTP (versendet von %s)', 25 | 'error' => 'Fehler', 26 | 'help' => 'Versand zwischen PHP mail() und SMTP in config.php wechseln: siehe Dokumentation', 27 | ] 28 | ], 29 | ]; 30 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/i18n/fr/shareByEmail.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'feedback' => [ 6 | 'failed' => 'L’email n’a pas pu être envoyé, merci de contacter votre administrateur.', 7 | 'fields_required' => 'Tous les champs sont requis.', 8 | 'sent' => 'L’email a bien été envoyé.', 9 | ], 10 | 'form' => [ 11 | 'cancel' => 'Annuler', 12 | 'content' => 'Contenu', 13 | 'content_default' => "Salut,\n\nJe pense que cet article pourrait te plaire !\n\n%s – %s\n\n---\n\nCet email vous a été envoyé par %s via %s ( %s )", 14 | 'send' => 'Envoyer', 15 | 'subject' => 'Sujet', 16 | 'subject_default' => 'J’ai trouvé un article intéressant !', 17 | 'to' => 'Pour', 18 | ], 19 | 'intro' => 'Vous êtes sur le point de partager cet article par courriel : « %s »', 20 | 'title' => 'Partager un article par courriel', 21 | 'manage' => [ 22 | 'mailer' => 'Système de messagerie', 23 | 'mail' => 'PHP mail()', 24 | 'smtp' => 'SMTP (envoyer en tant que %s)', 25 | 'error' => 'Erreur', 26 | 'help' => 'Éditer les paramètres SMTP ou PHP mail() dans config.php : voir la documentation', 27 | ] 28 | ], 29 | ]; 30 | -------------------------------------------------------------------------------- /xExtension-Captcha/static/captchaConfig.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals slider, providerConfig, captchaReset, clearFields, init_password_observers, data_auto_leave_validation */ 4 | 5 | function initCaptchaConfig() { 6 | const captchaProvider = document.querySelector('select#captchaProvider'); 7 | if (!captchaProvider) { 8 | return; 9 | } 10 | 11 | function onChange() { 12 | const provider = captchaProvider.value; 13 | const commonTmpl = document.querySelector('.captchaTmpl#common'); 14 | const tmpl = document.querySelector(`.captchaTmpl#${provider}`); 15 | providerConfig.innerHTML = provider !== 'none' ? commonTmpl.innerHTML + tmpl.innerHTML : ''; 16 | init_password_observers(document.body); 17 | data_auto_leave_validation(document.body); 18 | } 19 | 20 | captchaProvider.onchange = onChange; 21 | captchaReset.onclick = function (e) { 22 | e.preventDefault(); 23 | captchaReset.form.reset(); 24 | onChange(); 25 | }; 26 | 27 | onChange(); 28 | 29 | clearFields.onclick = function (e) { 30 | e.preventDefault(); 31 | document.querySelectorAll('input[type="text"], input[type="password"]').forEach(el => { 32 | el.value = ''; 33 | }); 34 | }; 35 | } 36 | 37 | window.addEventListener('load', function () { 38 | if (typeof slider !== 'undefined') { 39 | slider.addEventListener('freshrss:slider-load', initCaptchaConfig); 40 | } 41 | initCaptchaConfig(); 42 | }); 43 | -------------------------------------------------------------------------------- /xExtension-ReadingTime/README.md: -------------------------------------------------------------------------------- 1 | Extension pour FreshRSS () 2 | 3 | > **v1.6** 4 | 5 | Ajoute une estimation du temps de lecture à côté de chaque article. 6 | Fonctionne sur les affichages de bureau et mobile. 7 | 8 | S'installe comme toute les extensions, soit via l'outil intégré dans l'interface (icône des paramètres -> extensions) soit manuellement en copiant ce dépôt directement dans le dossier `extensions` de votre installation de FreshRSS. 9 | Aucune module externe. Une fois activée dans les préférences, l'extension doit fonctionner après avoir recharger la page. 10 | 11 | Un indicateur du temps de lecture doit s'afficher dans le nom du flux de chaque article. 12 | Vous pouvez définir la vitesse de lecture et la méthode de mesure du temps de lecture. 13 | 14 | --- 15 | 16 | Extension for FressRSS () 17 | 18 | > **v1.6** 19 | 20 | Add a reading time estimation next to each article. 21 | Works on both desktop and mobile displays. 22 | 23 | Install it the same way as any other extension, with the integrated tool in the interface (parameters icon -> extensions) or manually by copying this repository directly in the `extensions` folder of your FreshRSS install. 24 | No external module. Once activated in the preferences, this extension should be working after reloading the page. 25 | 26 | You can set reading speed and source metrics to estimate reading time. 27 | -------------------------------------------------------------------------------- /phpstan.dist.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | phpVersion: 3 | min: 80100 # PHP 8.1 4 | max: 80599 # PHP 8.5 5 | level: 10 # https://phpstan.org/user-guide/rule-levels 6 | fileExtensions: 7 | - php 8 | - phtml 9 | paths: 10 | - ../FreshRSS 11 | - . 12 | excludePaths: 13 | analyse: 14 | - ../FreshRSS 15 | analyseAndScan: 16 | - .git/*? 17 | - node_modules/*? 18 | - symbolic/*? 19 | - third-party/*? 20 | - tmp/*? 21 | - vendor/ 22 | dynamicConstantNames: 23 | - TYPE_GIT 24 | checkBenevolentUnionTypes: true 25 | checkImplicitMixed: true 26 | checkMissingOverrideMethodAttribute: true 27 | checkTooWideReturnTypesInProtectedAndPublicMethods: true 28 | reportAnyTypeWideningInVarTag: true 29 | reportPossiblyNonexistentConstantArrayOffset: true 30 | treatPhpDocTypesAsCertain: false 31 | strictRules: 32 | disallowedEmpty: false 33 | disallowedLooseComparison: false 34 | disallowedShortTernary: false 35 | strictArrayFilter: true 36 | exceptions: 37 | check: 38 | missingCheckedExceptionInThrows: true 39 | tooWideThrowType: true 40 | implicitThrows: false 41 | checkedExceptionClasses: 42 | - 'Minz_Exception' 43 | ignoreErrors: 44 | - '#Only booleans are allowed in (a negated boolean|a ternary operator condition|an elseif condition|an if condition|&&|\|\|), (bool|false|int(<[0-9, max]+>)?|true|null|\|)+ given.*#' 45 | includes: 46 | - vendor/phpstan/phpstan-strict-rules/rules.neon 47 | -------------------------------------------------------------------------------- /xExtension-ColorfulList/static/script.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | window.onload = function () { 3 | // Initial Colorize for situation where 'no new item changes triggered later' (https://github.com/FreshRSS/Extensions/issues/183) 4 | colorize(); 5 | // Insert entry monitor for autoloading list 6 | monitorEntry(colorize); 7 | function monitorEntry(monitorCallback) { 8 | const targetNode = document.getElementById('stream'); 9 | const config = { attributes: false, childList: true, subtree: false }; 10 | const callback = function (mutationsList, observer) { 11 | for (const mutation of mutationsList) { 12 | if (mutation.type === 'childList') { 13 | monitorCallback(mutationsList); 14 | } 15 | } 16 | }; 17 | const observer = new MutationObserver(callback); 18 | if (targetNode) { 19 | observer.observe(targetNode, config); 20 | } 21 | } 22 | }; 23 | 24 | function colorize(mList) { 25 | const entry = document.querySelectorAll('.flux_header'); 26 | entry.forEach((e, i) => { 27 | const cl = stringToColour(e.querySelector('.website').textContent) + '12'; 28 | e.style.background = cl; 29 | }); 30 | } 31 | 32 | const stringToColour = (str) => { 33 | let hash = 0; 34 | str.split('').forEach(char => { 35 | hash = char.charCodeAt(0) + ((hash << 5) - hash); 36 | }); 37 | let color = '#'; 38 | for (let i = 0; i < 3; i++) { 39 | const value = (hash >> (i * 8)) & 0xff; 40 | color += value.toString(16).padStart(2, '0'); 41 | } 42 | return color; 43 | }; 44 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import js from "@eslint/js"; 3 | import neostandard, { resolveIgnoresFromGitignore } from 'neostandard'; 4 | import stylistic from '@stylistic/eslint-plugin'; 5 | 6 | export default [ 7 | { 8 | files: ["**/*.js"], 9 | languageOptions: { 10 | globals: { 11 | ...globals.browser, 12 | }, 13 | sourceType: "script", 14 | }, 15 | }, 16 | { 17 | ignores: [ 18 | ...resolveIgnoresFromGitignore(), 19 | "**/*.min.js", 20 | "extensions/", 21 | "p/scripts/vendor/", 22 | ], 23 | }, 24 | js.configs.recommended, 25 | // stylistic.configs['recommended-flat'], 26 | ...neostandard(), 27 | { 28 | plugins: { 29 | "@stylistic": stylistic, 30 | }, 31 | rules: { 32 | "camelcase": "off", 33 | "eqeqeq": "off", 34 | "no-empty": ["error", { "allowEmptyCatch": true }], 35 | "no-unused-vars": ["error", { 36 | "args": "none", 37 | "caughtErrors": "none", 38 | }], 39 | "object-shorthand": "off", 40 | "yoda": "off", 41 | "@stylistic/indent": ["warn", "tab", { "SwitchCase": 1 }], 42 | "@stylistic/linebreak-style": ["error", "unix"], 43 | "@stylistic/max-len": ["warn", 165], 44 | "@stylistic/no-tabs": "off", 45 | "@stylistic/quotes": ["off", "single", { "avoidEscape": true }], 46 | "@stylistic/quote-props": ["warn", "consistent"], 47 | "@stylistic/semi": ["warn", "always"], 48 | "@stylistic/space-before-function-paren": ["warn", { 49 | "anonymous": "always", 50 | "asyncArrow": "always", 51 | "named": "never", 52 | }], 53 | }, 54 | }, 55 | ]; 56 | -------------------------------------------------------------------------------- /xExtension-ReadingTime/configure.phtml: -------------------------------------------------------------------------------- 1 | 5 |
6 | 7 |
8 | 9 |
10 | 11 |

12 |
13 | 14 | 15 |
16 | 20 |

21 |
22 |
23 | 24 |
25 |
26 | 27 | 28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /xExtension-Captcha/i18n/en/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'protected_pages' => 'Protected pages', 6 | 'pages' => array( 7 | 'register' => 'Register', 8 | 'login' => 'Login', 9 | ), 10 | 'captcha_provider' => 'CAPTCHA Provider', 11 | 'providers' => array( 12 | 'none' => 'None', 13 | 'site_key' => array( 14 | 'label' => 'Site Key (public)', 15 | 'placeholder' => 'Enter your site key here…', 16 | ), 17 | 'secret_key' => array( 18 | 'label' => 'Secret Key (private)', 19 | 'placeholder' => 'Enter your secret key here…', 20 | ), 21 | ), 22 | 'invalid_captcha' => 'Invalid captcha, try again.', 23 | 'clear_fields' => 'Clear fields', 24 | 'ext_must_be_enabled' => 'The extension must be enabled for the configuration view to work properly.', 25 | 'help' => array( 26 | 'turnstile' => 'Turnstile website | Cloudflare privacy policy | Cloudflare ToS', 27 | 'recaptcha' => 'reCAPTCHA website | Google privacy policy | Google ToS', 28 | 'hcaptcha' => 'hCaptcha website | hCaptcha privacy policy | hCaptcha ToS', 29 | ), 30 | 'send_client_ip' => 'Send client IP address', 31 | ), 32 | ); 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "freshrss-extensions", 3 | "type": "module", 4 | "description": "Extensions for FreshRSS", 5 | "homepage": "https://freshrss.org/", 6 | "readmeFilename": "README.md", 7 | "bugs": { 8 | "url": "https://github.com/FreshRSS/Extensions/issues" 9 | }, 10 | "keywords": [ 11 | "freshrss", 12 | "extensions" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/FreshRSS/Extensions.git" 17 | }, 18 | "license": "see each extension", 19 | "engines": { 20 | "node": ">=18" 21 | }, 22 | "scripts": { 23 | "eslint": "eslint .", 24 | "eslint_fix": "eslint --fix .", 25 | "markdownlint": "markdownlint '**/*.md'", 26 | "markdownlint_fix": "markdownlint --fix '**/*.md'", 27 | "rtlcss": "npm run symbolic && rtlcss -d symbolic/ && find -L symbolic/ -type f -name '*.rtl.rtl.css' -delete", 28 | "stylelint": "stylelint '**/*.css'", 29 | "stylelint_fix": "stylelint --fix '**/*.css'", 30 | "symbolic": "rm -fr symbolic && mkdir symbolic && find . -maxdepth 1 -type d -name 'xExtension-*' -exec ln -sf \"$(pwd)/{}\" ./symbolic/ \\;", 31 | "test": "npm run eslint && npm run stylelint && npm run markdownlint", 32 | "fix": "npm run rtlcss && npm run stylelint_fix && npm run eslint_fix && npm run markdownlint_fix" 33 | }, 34 | "devDependencies": { 35 | "eslint": "^9.39.1", 36 | "@eslint/js": "^9.8.0", 37 | "globals": "^16.5.0", 38 | "markdownlint-cli": "^0.46.0", 39 | "neostandard": "^0.12.2", 40 | "rtlcss": "^4.3.0", 41 | "sass": "^1.94.2", 42 | "stylelint": "^16.26.1", 43 | "stylelint-config-recommended-scss": "^16.0.2", 44 | "stylelint-order": "^7.0.0", 45 | "@stylistic/stylelint-plugin": "^4.0.0" 46 | }, 47 | "rtlcssConfig": {} 48 | } 49 | -------------------------------------------------------------------------------- /xExtension-Captcha/i18n/pl/ext.php: -------------------------------------------------------------------------------- 1 | array( 5 | 'protected_pages' => 'Chronione strony', 6 | 'pages' => array( 7 | 'register' => 'Rejestracja', 8 | 'login' => 'Logowanie', 9 | ), 10 | 'captcha_provider' => 'Dostawca CAPTCHA', 11 | 'providers' => array( 12 | 'none' => 'Brak', 13 | 'site_key' => array( 14 | 'label' => 'Klucz witryny (publiczny)', 15 | 'placeholder' => 'Wprowadź swój klucz witryny…', 16 | ), 17 | 'secret_key' => array( 18 | 'label' => 'Tajny klucz (prywatny)', 19 | 'placeholder' => 'Wprowadź swój tajny klucz…', 20 | ), 21 | ), 22 | 'invalid_captcha' => 'Nieprawidłowa captcha. Spróbuj ponownie.', 23 | 'clear_fields' => 'Wyczyść pola', 24 | 'ext_must_be_enabled' => 'Rozszerzenie musi być włączone aby widok konfiguracji działał prawidłowo.', 25 | 'help' => array( 26 | 'turnstile' => 'Witryna Turnstile | Polityka prywatności Cloudflare | Warunki użytkowania Cloudflare', 27 | 'recaptcha' => 'Witryna reCAPTCHA | Polityka prywatności Google | Warunki użytkowania Google', 28 | 'hcaptcha' => 'Witryna hCaptcha | Polityka prywatności hCaptcha | Warunki użytkowania hCaptcha', 29 | ), 30 | 'send_client_ip' => 'Wysyłaj adres IP klienta', 31 | ), 32 | ); 33 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/views/shareByEmail/share.phtml: -------------------------------------------------------------------------------- 1 | 5 |
6 |

7 | 8 |

9 | entry) ? $this->entry->title() : '') ?> 10 |

11 | 12 | 77 |
78 | -------------------------------------------------------------------------------- /xExtension-StickyFeeds/static/script.js: -------------------------------------------------------------------------------- 1 | /* globals $ */ 2 | 'use strict'; 3 | 4 | const sticky_feeds = { 5 | aside: null, 6 | tree: null, 7 | window: null, 8 | 9 | init: function () { 10 | if (window.matchMedia('(max-width: 840px)').matches) { 11 | return; 12 | } 13 | 14 | if (!window.$) { 15 | window.setTimeout(sticky_feeds.init, 50); 16 | return; 17 | } 18 | 19 | sticky_feeds.tree = $('#aside_feed .tree'); 20 | if (sticky_feeds.tree.length > 0) { 21 | // Get the "real" window height: don't forget to remove the height 22 | // of the #nav_entries 23 | sticky_feeds.window = $(window); 24 | sticky_feeds.window.height = sticky_feeds.window.height() - $('#nav_entries').height(); 25 | 26 | // Make the aside "sticky" and save the initial position. 27 | sticky_feeds.aside = $('#aside_feed'); 28 | sticky_feeds.aside.addClass('sticky'); 29 | sticky_feeds.aside.initial_pos = sticky_feeds.aside.position(); 30 | sticky_feeds.tree.initial_pos = sticky_feeds.tree.position(); 31 | 32 | // Attach the scroller method to the window scroll. 33 | sticky_feeds.window.on('scroll', sticky_feeds.scroller); 34 | sticky_feeds.scroller(); 35 | } 36 | }, 37 | 38 | scroller: function () { 39 | const pos_top_window = sticky_feeds.window.scrollTop(); 40 | 41 | if (pos_top_window < sticky_feeds.aside.initial_pos.top + sticky_feeds.tree.initial_pos.top) { 42 | // scroll top has not reached the top of the sticky tree yet so it 43 | // stays in place but its height must adapted: 44 | // window height - sticky tree pos top + actual scroll top 45 | const real_tree_pos_top = sticky_feeds.aside.initial_pos.top + sticky_feeds.tree.initial_pos.top; 46 | sticky_feeds.tree.css('top', sticky_feeds.tree.initial_pos.top); 47 | sticky_feeds.tree.css('height', sticky_feeds.window.height - real_tree_pos_top + pos_top_window); 48 | } else { 49 | // Now we have to make the tree follow the window. It's quite easy 50 | // since its position is calculated from the parent aside. 51 | // The height is also easier to calculate since it's just the window 52 | // height. 53 | sticky_feeds.tree.css('top', pos_top_window - sticky_feeds.aside.initial_pos.top); 54 | sticky_feeds.tree.css('height', sticky_feeds.window.height); 55 | } 56 | }, 57 | }; 58 | 59 | window.onload = sticky_feeds.init; 60 | -------------------------------------------------------------------------------- /xExtension-YouTube/README.md: -------------------------------------------------------------------------------- 1 | # FreshRSS - YouTube video extension 2 | 3 | This FreshRSS extension allows you to directly watch YouTube/PeerTube videos from within subscribed channel feeds. 4 | 5 | To use it, upload the ```xExtension-YouTube``` directory to the FreshRSS `./extensions` directory on your server and enable it on the extension panel in FreshRSS. 6 | 7 | ## Features 8 | 9 | - Embeds Youtube videos directly in FreshRSS, instead of linking to the Youtube page 10 | - Simplifies the subscription to channel URLs by automatically detecting the channels feed URL 11 | 12 | You can simply add Youtube video subscriptions by pasting URLs like: 13 | - `https://www.youtube.com/channel/UCwbjxO5qQTMkSZVueqKwxuw` 14 | - `https://www.youtube.com/user/AndrewTrials` 15 | 16 | ## Screenshots 17 | 18 | With FreshRSS and an original Youtube Channel feed: 19 | ![screenshot before](https://github.com/kevinpapst/freshrss-youtube/blob/screenshot-readme/before.png?raw=true "Without this extension the video is not shown") 20 | 21 | With activated Youtube extension: 22 | ![screenshot after](https://github.com/kevinpapst/freshrss-youtube/blob/screenshot-readme/after.png?raw=true "After activating the extension you can enjoy your video directly in the FreshRSS stream") 23 | 24 | ## Changelog 25 | 26 | 0.12: 27 | - Turkish language support added 28 | 29 | 0.11: 30 | - Modernized codebase for latest FreshRSS release 1.23.1 31 | - Moved from [custom repo](https://github.com/kevinpapst/freshrss-youtube) to FreshRSS official extension repo 32 | 33 | 0.10: 34 | - Enhance feed content formatting when included 35 | - Enhance YouTube URL matching 36 | 37 | 0.9: 38 | - Set the extension level at "user" (**users must re-enable the extension**) 39 | - Fix calls to unset configuration variables 40 | - Register translations when extension is disabled 41 | 42 | 0.8: 43 | - Automatically convert channel and username URLs to feed URLs 44 | 45 | 0.7: 46 | - Support for PeerTube feed 47 | 48 | 0.6: 49 | - Support cookie-less domain [youtube-nocookie.com](https://www.youtube-nocookie.com) for embedding 50 | 51 | 0.5: 52 | - Opened "API" for external usage 53 | 54 | 0.4: 55 | - Added option to display original feed content (currently Youtube inserts a download icon link to the video file) 56 | - Fixed config loading 57 | 58 | 0.3: 59 | - Added installation hints 60 | 61 | 0.2: 62 | - Fixed "Use of undefined constant FreshRSS_Context" 63 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-recommended-scss", 3 | "plugins": [ 4 | "stylelint-order", 5 | "stylelint-scss", 6 | "@stylistic/stylelint-plugin" 7 | ], 8 | "rules": { 9 | "at-rule-empty-line-before": [ 10 | "always", { 11 | "ignoreAtRules": [ "after-comment", "else" ] 12 | } 13 | ], 14 | "@stylistic/at-rule-name-space-after": [ 15 | "always", { 16 | "ignoreAtRules": [ "after-comment" ] 17 | } 18 | ], 19 | "@stylistic/block-closing-brace-newline-after": [ 20 | "always", { 21 | "ignoreAtRules": [ "if", "else" ] 22 | } 23 | ], 24 | "@stylistic/block-closing-brace-newline-before": "always-multi-line", 25 | "@stylistic/block-opening-brace-newline-after": "always-multi-line", 26 | "@stylistic/block-opening-brace-space-before": "always", 27 | "@stylistic/color-hex-case": "lower", 28 | "color-hex-length": "short", 29 | "color-no-invalid-hex": true, 30 | "@stylistic/declaration-colon-space-after": "always", 31 | "@stylistic/declaration-colon-space-before": "never", 32 | "@stylistic/indentation": "tab", 33 | "no-descending-specificity": null, 34 | "@stylistic/no-eol-whitespace": true, 35 | "property-no-vendor-prefix": true, 36 | "rule-empty-line-before": [ 37 | "always", { 38 | "except": ["after-single-line-comment","first-nested"] 39 | } 40 | ], 41 | "order/properties-order": [ 42 | "margin", 43 | "padding", 44 | "background", 45 | "display", 46 | "float", 47 | "max-width", 48 | "width", 49 | "max-height", 50 | "height", 51 | "color", 52 | "font", 53 | "font-family", 54 | "font-size", 55 | "border", 56 | "border-top", 57 | "border-top-color", 58 | "border-right", 59 | "border-right-color", 60 | "border-bottom", 61 | "border-bottom-color", 62 | "border-left", 63 | "border-left-color", 64 | "border-radius", 65 | "box-shadow" 66 | ], 67 | "scss/at-else-closing-brace-newline-after": "always-last-in-chain", 68 | "scss/at-else-closing-brace-space-after": "always-intermediate", 69 | "scss/at-else-empty-line-before": "never", 70 | "scss/at-if-closing-brace-newline-after": "always-last-in-chain", 71 | "scss/at-if-closing-brace-space-after": "always-intermediate" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /xExtension-showFeedID/static/showfeedid.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | window.addEventListener("load", function () { 4 | // eslint-disable-next-line no-undef 5 | const i18n = context.extensions.showfeedid_i18n; 6 | 7 | let div = document.querySelector('h1 ~ div'); 8 | if (div.classList.contains('drop-section')) { 9 | div = document.createElement('div'); 10 | document.querySelector('h1').after(div); 11 | } 12 | 13 | const button = document.createElement('a'); 14 | 15 | button.classList.add('btn'); 16 | button.classList.add('btn-icon-text'); 17 | button.id = 'showFeedId'; 18 | button.innerHTML = ' ' + i18n.show + ''; 19 | 20 | div.appendChild(button); 21 | 22 | const parent = button.parentElement; 23 | parent.style.display = 'inline-flex'; 24 | parent.style.flexWrap = 'wrap'; 25 | parent.style.gap = '0.5rem'; 26 | 27 | // Check if only feeds with errors are being shown 28 | if (document.querySelector('main.post > p.alert.alert-warn')) { 29 | parent.style.flexDirection = 'column'; 30 | button.style.marginTop = '0.5rem'; 31 | } 32 | 33 | const buttonText = button.querySelector('span'); 34 | 35 | button.addEventListener('click', function () { 36 | if (document.querySelector('.feed-id, .cat-id')) { 37 | buttonText.innerText = i18n.show; 38 | } else { 39 | buttonText.innerText = i18n.hide; 40 | } 41 | 42 | const feeds = document.querySelectorAll('li.item.feed'); 43 | 44 | feeds.forEach(function (feed) { 45 | const feedId = feed.dataset.feedId; 46 | const feedname_elem = feed.getElementsByClassName('item-title')[0]; 47 | if (feedname_elem) { 48 | if (!feedname_elem.querySelector('.feed-id')) { 49 | feedname_elem.insertAdjacentHTML('beforeend', ' (ID: ' + feedId + ')'); 50 | return; 51 | } 52 | feedname_elem.querySelector('.feed-id').remove(); 53 | } 54 | }); 55 | 56 | const cats = document.querySelectorAll('div.box > ul.box-content'); 57 | 58 | cats.forEach(function (cat) { 59 | const catId = cat.dataset.catId; 60 | const catname_elem = cat.parentElement.querySelectorAll('div.box-title > h2')[0]; 61 | if (catname_elem) { 62 | if (!catname_elem.querySelector('.cat-id')) { 63 | catname_elem.insertAdjacentHTML('beforeend', ' (ID: ' + catId + ')'); 64 | return; 65 | } 66 | catname_elem.querySelector('.cat-id').remove(); 67 | } 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "freshrss.org/freshrss-extensions", 3 | "description": "Extensions for FreshRSS", 4 | "type": "project", 5 | "homepage": "https://freshrss.org/", 6 | "license": "AGPL-3.0", 7 | "support": { 8 | "docs": "https://freshrss.github.io/FreshRSS/", 9 | "issues": "https://github.com/FreshRSS/Extensions/issues", 10 | "source": "https://github.com/FreshRSS/Extensions/" 11 | }, 12 | "keywords": [ 13 | "news", 14 | "aggregator", 15 | "RSS", 16 | "Atom", 17 | "WebSub" 18 | ], 19 | "require": { 20 | "php": ">=8.1", 21 | "ext-ctype": "*", 22 | "ext-curl": "*", 23 | "ext-dom": "*", 24 | "ext-fileinfo": "*", 25 | "ext-gmp": "*", 26 | "ext-intl": "*", 27 | "ext-json": "*", 28 | "ext-libxml": "*", 29 | "ext-mbstring": "*", 30 | "ext-openssl": "*", 31 | "ext-pcre": "*", 32 | "ext-pdo": "*", 33 | "ext-pdo_sqlite": "*", 34 | "ext-session": "*", 35 | "ext-simplexml": "*", 36 | "ext-xml": "*", 37 | "ext-xmlreader": "*", 38 | "ext-zend-opcache": "*", 39 | "ext-zip": "*", 40 | "ext-zlib": "*" 41 | }, 42 | "suggest": { 43 | "ext-iconv": "*", 44 | "ext-pdo_mysql": "*", 45 | "ext-pdo_pgsql": "*" 46 | }, 47 | "require-dev": { 48 | "php": ">=8.1", 49 | "ext-phar": "*", 50 | "ext-tokenizer": "*", 51 | "ext-xmlwriter": "*", 52 | "phpstan/phpstan": "^2", 53 | "phpstan/phpstan-strict-rules": "^2", 54 | "squizlabs/php_codesniffer": "^4" 55 | }, 56 | "scripts": { 57 | "php-lint": "find . -type d -name 'vendor' -prune -o -name '*.php' -print0 | xargs -0 -n1 -P4 php -l 1>/dev/null", 58 | "phtml-lint": "find . -type d -name 'vendor' -prune -o -name '*.phtml' -print0 | xargs -0 -n1 -P4 php -l 1>/dev/null", 59 | "phpcs": "phpcs . -s", 60 | "phpcbf": "phpcbf . -p -s", 61 | "phpstan": "phpstan analyse .", 62 | "phpstan-third-party": "phpstan analyse -c phpstan-third-party.neon .", 63 | "test": [ 64 | "@php-lint", 65 | "@phtml-lint", 66 | "@phpcs", 67 | "@phpstan" 68 | ], 69 | "fix": [ 70 | "@phpcbf" 71 | ] 72 | }, 73 | "config": { 74 | "allow-plugins": { 75 | "phpstan/extension-installer": false 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /xExtension-Captcha/README.md: -------------------------------------------------------------------------------- 1 | # Form Captcha extension 2 | 3 | Protect register/login forms with captcha 4 | 5 | Currently the following CAPTCHA providers are supported: 6 | * [Cloudflare Turnstile](https://www.cloudflare.com/application-services/products/turnstile/) 7 | * [reCAPTCHA v2/v3](https://developers.google.com/recaptcha) 8 | * [hCaptcha](https://www.hcaptcha.com/) 9 | 10 | The extension is especially useful if you're running a public instance and want to protect it from bots. 11 | To see failed captcha solve attempts, look at the logs in: `data/users/_/log.txt` (admin log) 12 | 13 | --- 14 | 15 | Warning: if you're protecting the login page and you have unsafe autologin enabled, it can allow anyone to bypass the captcha - it's recommended to disable this option 16 | 17 | --- 18 | 19 | Available configuration settings: 20 | * Protected pages 21 | * CAPTCHA provider 22 | * Site Key 23 | * Secret Key 24 | * Send client IP address? 25 | 26 |
27 | Show configuration screenshot 28 | 29 | ![configuration](./screenshot.png) 30 | 31 |
32 | 33 | ## Trouble with login 34 | 35 | If you are having trouble with logging in after configuring the extension, you can manually disable it in `FreshRSS/data/config.php`, login and reconfigure the extension. 36 | 37 | ## Changelog 38 | 39 | * 1.0.2 [2025-12-06] 40 | * Remove warning about unsafe autologin, since it's been removed in FreshRSS 1.28.0 41 | * 1.0.1 [2025-09-20] 42 | * Improvements 43 | * The user is now notified that the extension must be enabled for the configuration view to work properly. (due to JS) 44 | * Security 45 | * Captcha configuration now requires reauthenticating in FreshRSS to protect the secret key 46 | * Register form wasn't correctly protected because the extension wasn't protecting the POST action, only displaying the captcha widget 47 | * Fixed potential captcha bypass due to checking for `POST_TO_GET` parameter in the session 48 | * Use slightly stronger CSP on login and register pages 49 | * Bug fixes 50 | * Fixed wrong quote in CSP `"` instead of `'` 51 | * Client IP is now taken from `X-Real-IP` instead of `X-Forwarded-For`, since the latter could contain multiple comma-separated IPs 52 | * Refactor 53 | * `data-auto-leave-validation` is now being used in the configure view instead of `data-leave-validation` 54 | * `data-toggle` attributes were removed from the configure view, since they aren't needed anymore as of v1.27.1 55 | * Other minor changes 56 | * 1.0.0 [2025-07-30] 57 | * Initial release 58 | -------------------------------------------------------------------------------- /xExtension-YouTube/configure.phtml: -------------------------------------------------------------------------------- 1 | 5 |
6 | 7 |
8 | 9 | 10 |
11 | 12 |
13 | 14 | 15 |
16 | 17 |
18 | 19 |
20 | 24 |
25 | 26 |
27 | 31 |
32 | 33 |
34 | 38 |
39 | 40 |
41 | 42 | 43 |
44 |
45 | 46 |
47 |
48 | 49 | 50 |
51 |
52 |
53 | -------------------------------------------------------------------------------- /xExtension-UnsafeAutologin/Controllers/authController.php: -------------------------------------------------------------------------------- 1 | 'index', 'a' => 'index'], true); 13 | return; 14 | } 15 | Minz_Request::forward(['c' => 'auth', 'a' => 'formLogin']); 16 | } 17 | 18 | /** 19 | * @throws FreshRSS_Context_Exception 20 | * @throws Minz_ConfigurationNamespaceException 21 | * @throws Minz_ConfigurationException 22 | * @throws Minz_PermissionDeniedException 23 | */ 24 | #[\Override] 25 | public function loginAction(): void { 26 | if (FreshRSS_Context::systemConf()->auth_type !== 'form') { 27 | parent::loginAction(); 28 | return; 29 | } 30 | 31 | $username = Minz_Request::paramString('u'); 32 | $password = Minz_Request::paramString('p', plaintext: true); 33 | 34 | if ($username === '' || $password === '') { 35 | self::redirectFormLogin(); 36 | return; 37 | } 38 | 39 | if (!FreshRSS_user_Controller::checkUsername($username) || !FreshRSS_user_Controller::userExists($username)) { 40 | Minz_Request::bad( 41 | _t('feedback.auth.login.invalid'), 42 | ['c' => 'index', 'a' => 'index'] 43 | ); 44 | return; 45 | } 46 | 47 | $config = FreshRSS_UserConfiguration::getForUser($username); 48 | 49 | $s = $config->passwordHash ?? ''; 50 | $ok = password_verify($password, $s); 51 | 52 | if ($ok) { 53 | FreshRSS_Context::initUser($username); 54 | FreshRSS_FormAuth::deleteCookie(); 55 | Minz_Session::regenerateID('FreshRSS'); 56 | Minz_Session::_params([ 57 | Minz_User::CURRENT_USER => $username, 58 | 'passwordHash' => $s, 59 | 'lastReauth' => false, 60 | 'csrf' => false, 61 | ]); 62 | FreshRSS_Auth::giveAccess(); 63 | 64 | Minz_Translate::init(FreshRSS_Context::userConf()->language); 65 | 66 | FreshRSS_UserDAO::touch(); 67 | 68 | Minz_Request::good(_t('feedback.auth.login.success'), ['c' => 'index', 'a' => 'index']); 69 | return; 70 | } 71 | 72 | Minz_Log::warning('Unsafe password mismatch for user ' . $username, USERS_PATH . '/' . $username . '/log.txt'); 73 | Minz_Request::bad( 74 | _t('feedback.auth.login.invalid'), 75 | ['c' => 'index', 'a' => 'index'] 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /xExtension-QuickCollapse/static/script.js: -------------------------------------------------------------------------------- 1 | /* globals context */ 2 | 3 | (function () { 4 | function toggleCollapse() { 5 | const streamElem = document.getElementById('stream'); 6 | const toggleElem = document.getElementById('toggle-collapse'); 7 | const wasCollapsed = streamElem.classList.contains('hide_posts'); 8 | 9 | if (wasCollapsed) { 10 | streamElem.classList.remove('hide_posts'); 11 | toggleElem.classList.remove('collapsed'); 12 | } else { 13 | streamElem.classList.add('hide_posts'); 14 | toggleElem.classList.add('collapsed'); 15 | } 16 | 17 | if (context.does_lazyload && wasCollapsed) { 18 | const lazyloadedElements = streamElem.querySelectorAll( 19 | 'img[data-original], iframe[data-original]' 20 | ); 21 | lazyloadedElements.forEach(function (el) { 22 | el.src = el.getAttribute('data-original'); 23 | el.removeAttribute('data-original'); 24 | }); 25 | } 26 | } 27 | 28 | function syncWithContext() { 29 | if (!window.context) { 30 | // The variables might not be available yet, so we need to wait for them. 31 | return setTimeout(syncWithContext, 10); 32 | } 33 | 34 | const toggleElem = document.getElementById('toggle-collapse'); 35 | toggleElem.title = context.extensions.quick_collapse.i18n.toggle_collapse; 36 | toggleElem.innerHTML = `↕`; 37 | toggleElem.innerHTML += `✖`; 38 | 39 | if (context.hide_posts) { 40 | toggleElem.classList.add('collapsed'); 41 | } 42 | } 43 | 44 | const streamElem = document.getElementById('stream'); 45 | if (!streamElem || !streamElem.classList.contains('normal')) { 46 | // The button should be enabled only on "normal" view 47 | return; 48 | } 49 | 50 | // create the new button 51 | const toggleElem = document.createElement('button'); 52 | toggleElem.id = 'toggle-collapse'; 53 | toggleElem.classList.add('btn'); 54 | toggleElem.addEventListener('click', toggleCollapse); 55 | 56 | // replace the "order" button by a stick containing the order and the 57 | // collapse buttons 58 | const orderElem = document.getElementById('toggle-order'); 59 | 60 | const stickElem = document.createElement('div'); 61 | stickElem.classList.add('stick'); 62 | 63 | orderElem.parentNode.insertBefore(stickElem, orderElem); 64 | stickElem.appendChild(orderElem); 65 | stickElem.appendChild(toggleElem); 66 | 67 | // synchronizes the collapse button with dynamic vars passed via the 68 | // backend (async mode). 69 | syncWithContext(); 70 | }()); 71 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/static/word-highlighter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* WordHighlighter v0.0.2 (FreshRSS Extension) */ 4 | 5 | function wordHighlighter(c /* console */, Mark, context, OPTIONS) { 6 | const markConf = (done, counter) => ({ 7 | caseSensitive: OPTIONS.case_sensitive || false, 8 | separateWordSearch: OPTIONS.separate_word_search || false, 9 | ignoreJoiners: OPTIONS.ignore_joiners || false, 10 | exclude: [ 11 | 'mark', 12 | ...(OPTIONS.enable_in_article ? [] : ['article *']), 13 | ], 14 | done: (n) => (counter.value += n) && done(), 15 | noMatch: done, 16 | }); 17 | 18 | const m = new Mark(context); 19 | const changePageListener = debounce(200, (x) => { 20 | OPTIONS.enable_logs && c.group('WordHighlighter: page change'); 21 | stopObserving(); 22 | highlightWords(m, startObserving); 23 | }); 24 | 25 | const mo = new MutationObserver(changePageListener); 26 | mo.observe(context, { subtree: true, childList: true }); 27 | 28 | function startObserving() { 29 | mo.observe(context, { subtree: true, childList: true }); 30 | } 31 | 32 | function stopObserving() { 33 | mo.disconnect(); 34 | } 35 | 36 | function highlightWords(m, done) { 37 | const start = performance.now(); 38 | const hCounter = { value: 0 }; 39 | 40 | new Promise((resolve) => 41 | m.mark(OPTIONS.words || [], { ...markConf(resolve, hCounter) }) 42 | ) 43 | .finally(() => { 44 | if (OPTIONS.enable_logs) { 45 | c.log(`WordHighlighter: ${hCounter.value} new highlights added in ${performance.now() - start}ms.`); 46 | c.groupEnd(); 47 | } 48 | typeof done === 'function' && done(); 49 | }); 50 | } 51 | 52 | highlightWords(m); 53 | } 54 | 55 | // MAIN: 56 | 57 | (function main() { 58 | try { 59 | const confName = 'WordHighlighterConf'; 60 | const OPTIONS = window[confName] || { }; 61 | const onMainPage = !(new URL(window.location)).searchParams.get('c'); 62 | if (onMainPage) { 63 | console.log('WordHighlighter: script load...'); 64 | const context = document.querySelector('#stream'); 65 | wordHighlighter(console, window.Mark || (Error('mark.js library is not loaded ❗️')), context, OPTIONS); 66 | console.log('WordHighlighter: script loaded.✅'); 67 | } else { 68 | OPTIONS.enable_logs && console.log('WordHighlighter: ❗️ paused outside of feed page'); 69 | } 70 | return Promise.resolve(); 71 | } catch (error) { 72 | console.error('WordHighlighter: ❌', error); 73 | return Promise.reject(error); 74 | } 75 | })(); 76 | 77 | // Util functions: 78 | 79 | function debounce(duration, func) { 80 | let timeout; 81 | return function (...args) { 82 | const effect = () => { 83 | timeout = null; 84 | return func.apply(this, args); 85 | }; 86 | clearTimeout(timeout); 87 | timeout = setTimeout(effect, duration); 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /xExtension-YouTube/static/fetchIcons.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals context, slider */ 4 | 5 | function initFetchBtn() { 6 | const i18n = context.extensions.yt_i18n; 7 | 8 | const fetchIcons = document.querySelector('button[value="iconFetchFinish"]'); 9 | if (!fetchIcons) { 10 | return; 11 | } 12 | 13 | document.querySelectorAll('#yt_action_btn').forEach(el => { el.style.marginBottom = '1rem'; }); 14 | 15 | fetchIcons.form.querySelectorAll('button').forEach(btn => btn.removeAttribute('disabled')); 16 | fetchIcons.removeAttribute('title'); 17 | 18 | fetchIcons.onclick = function (e) { 19 | e.preventDefault(); 20 | 21 | const closeSlider = document.querySelector('#close-slider'); 22 | if (closeSlider) { 23 | closeSlider.onclick = (e) => e.preventDefault(); 24 | closeSlider.style.cursor = 'not-allowed'; 25 | closeSlider.querySelector('img.icon').remove(); 26 | } 27 | 28 | fetchIcons.form.onsubmit = window.onbeforeunload = (e) => e.preventDefault(); 29 | fetchIcons.onclick = null; 30 | fetchIcons.disabled = true; 31 | fetchIcons.parentElement.insertAdjacentHTML('afterend', ` 32 |

33 |
34 | ${i18n.fetching_icons}: 35 |


36 | `); 37 | 38 | const iconFetchCount = document.querySelector('b#iconFetchCount'); 39 | const iconFetchChannel = document.querySelector('b#iconFetchChannel'); 40 | 41 | const configureUrl = fetchIcons.form.action; 42 | 43 | function ajaxBody(action, args) { 44 | return JSON.stringify({ 45 | '_csrf': context.csrf, 46 | 'yt_action_btn': 'ajax' + action, 47 | ...args 48 | }); 49 | } 50 | 51 | fetch(configureUrl, { 52 | method: 'POST', 53 | body: ajaxBody('GetYtFeeds'), 54 | headers: { 55 | 'Content-Type': 'application/json; charset=UTF-8' 56 | } 57 | }).then(resp => { 58 | if (!resp.ok) { 59 | return; 60 | } 61 | return resp.json(); 62 | }).then(json => { 63 | let completed = 0; 64 | json.forEach(async (feed) => { 65 | await fetch(configureUrl, { 66 | method: 'POST', 67 | body: ajaxBody('FetchIcon', { 'id': feed.id }), 68 | headers: { 69 | 'Content-Type': 'application/json; charset=UTF-8' 70 | } 71 | }).then(async () => { 72 | iconFetchChannel.innerText = feed.title; 73 | iconFetchCount.innerText = `${++completed}/${json.length}`; 74 | if (completed === json.length) { 75 | fetchIcons.disabled = false; 76 | fetchIcons.form.onsubmit = window.onbeforeunload = null; 77 | fetchIcons.click(); 78 | } 79 | }); 80 | }); 81 | }); 82 | }; 83 | } 84 | 85 | window.addEventListener('load', function () { 86 | if (typeof slider !== 'undefined') { 87 | slider.addEventListener('freshrss:slider-load', initFetchBtn); 88 | } 89 | initFetchBtn(); 90 | }); 91 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | ifdef NO_DOCKER 4 | PHP = $(shell which php) 5 | else 6 | PHP = docker run \ 7 | --interactive \ 8 | --tty \ 9 | --rm \ 10 | --volume $(shell pwd):/usr/src/app:z \ 11 | --workdir /usr/src/app \ 12 | --name freshrss-extension-php-cli \ 13 | freshrss-extension-php-cli \ 14 | php 15 | endif 16 | 17 | ############ 18 | ## Docker ## 19 | ############ 20 | .PHONY: build 21 | build: ## Build a Docker image 22 | docker build \ 23 | --pull \ 24 | --tag freshrss-extension-php-cli \ 25 | --file Docker/Dockerfile . 26 | 27 | ########### 28 | ## TOOLS ## 29 | ########### 30 | .PHONY: generate 31 | generate: ## Generate the extensions.json file 32 | @$(PHP) ./generate.php 33 | 34 | ########## 35 | ## HELP ## 36 | ########## 37 | .PHONY: help 38 | help: 39 | @grep --extended-regexp '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 40 | 41 | ###################### 42 | ## Tests and linter ## 43 | ###################### 44 | .PHONY: lint 45 | lint: vendor/bin/phpcs ## Run the linter on the PHP files 46 | $(PHP) vendor/bin/phpcs . -p -s 47 | 48 | .PHONY: lint-fix 49 | lint-fix: vendor/bin/phpcbf ## Fix the errors detected by the linter 50 | $(PHP) vendor/bin/phpcbf . -p -s 51 | 52 | bin/composer: 53 | mkdir -p bin/ 54 | wget 'https://raw.githubusercontent.com/composer/getcomposer.org/9e43d8a9b16fffa4dc9b090b9104dab7d815424a/web/installer' -O - -q | php -- --quiet --install-dir='./bin/' --filename='composer' 55 | 56 | vendor/bin/phpcs: bin/composer 57 | bin/composer install --prefer-dist --no-progress 58 | ln -s ../vendor/bin/phpcs bin/phpcs 59 | 60 | vendor/bin/phpcbf: bin/composer 61 | bin/composer install --prefer-dist --no-progress 62 | ln -s ../vendor/bin/phpcbf bin/phpcbf 63 | 64 | bin/typos: 65 | mkdir -p bin/ 66 | cd bin ; \ 67 | wget -q 'https://github.com/crate-ci/typos/releases/download/v1.16.21/typos-v1.16.21-x86_64-unknown-linux-musl.tar.gz' && \ 68 | tar -xvf *.tar.gz './typos' && \ 69 | chmod +x typos && \ 70 | rm *.tar.gz ; \ 71 | cd .. 72 | 73 | node_modules/.bin/eslint: 74 | npm install 75 | 76 | node_modules/.bin/rtlcss: 77 | npm install 78 | 79 | vendor/bin/phpstan: bin/composer 80 | bin/composer install --prefer-dist --no-progress 81 | 82 | .PHONY: composer-test 83 | composer-test: vendor/bin/phpstan 84 | bin/composer run-script test 85 | 86 | .PHONY: composer-fix 87 | composer-fix: 88 | bin/composer run-script fix 89 | 90 | .PHONY: npm-test 91 | npm-test: node_modules/.bin/eslint 92 | npm test 93 | 94 | .PHONY: npm-fix 95 | npm-fix: node_modules/.bin/eslint 96 | npm run fix 97 | 98 | .PHONY: typos-test 99 | typos-test: bin/typos 100 | bin/typos 101 | 102 | # TODO: Add shellcheck, shfmt, hadolint 103 | .PHONY: test-all 104 | test-all: composer-test npm-test typos-test 105 | 106 | .PHONY: fix-all 107 | fix-all: composer-fix npm-fix 108 | -------------------------------------------------------------------------------- /xExtension-ReadingTime/extension.php: -------------------------------------------------------------------------------- 1 | registerTranslates(); 15 | if (!FreshRSS_Context::hasUserConf()) { 16 | return; 17 | } 18 | // Defaults 19 | $speed = FreshRSS_Context::userConf()->attributeInt('reading_time_speed'); 20 | if ($speed === null) { 21 | FreshRSS_Context::userConf()->_attribute('reading_time_speed', $this->speed); 22 | } else { 23 | $this->speed = $speed; 24 | } 25 | $metrics = FreshRSS_Context::userConf()->attributeString('reading_time_metrics'); 26 | if ($metrics === null) { 27 | FreshRSS_Context::userConf()->_attribute('reading_time_metrics', $this->metrics); 28 | } else { 29 | $this->metrics = $metrics; 30 | } 31 | if (in_array(null, [$speed, $metrics], true)) { 32 | FreshRSS_Context::userConf()->save(); 33 | } 34 | $this->registerHook('js_vars', [$this, 'getParams']); 35 | Minz_View::appendScript($this->getFileUrl('readingtime.js')); 36 | } 37 | 38 | public function getSpeed(): int { 39 | return $this->speed; 40 | } 41 | 42 | public function getMetrics(): string { 43 | return $this->metrics; 44 | } 45 | 46 | /** 47 | * Called from js_vars hook 48 | * 49 | * Pass dynamic parameters to readingtime.js via `window.context.extensions`. 50 | * Chain with other js_vars hooks via $vars. 51 | * 52 | * @param array $vars is the result of hooks chained in the previous step. 53 | * @return array is passed to the hook chained to the next step. 54 | */ 55 | public function getParams(array $vars): array { 56 | $vars['reading_time_speed'] = $this->speed; 57 | $vars['reading_time_metrics'] = $this->metrics; 58 | return $vars; 59 | } 60 | 61 | /** 62 | * @throws FreshRSS_Context_Exception 63 | * @throws Minz_ConfigurationParamException 64 | */ 65 | #[\Override] 66 | public function handleConfigureAction(): void { 67 | $this->registerTranslates(); 68 | 69 | if (Minz_Request::isPost()) { 70 | $speed = $this->validateSpeed(Minz_Request::paramInt('reading_time_speed')); 71 | FreshRSS_Context::userConf()->_attribute('reading_time_speed', $speed); 72 | $metrics = $this->validateMetrics(Minz_Request::paramString('reading_time_metrics')); 73 | FreshRSS_Context::userConf()->_attribute('reading_time_metrics', $metrics); 74 | FreshRSS_Context::userConf()->save(); 75 | } 76 | } 77 | 78 | /** @throws Minz_ConfigurationParamException */ 79 | private function validateSpeed(int $speed): int { 80 | if ($speed <= 0) { 81 | throw new Minz_ConfigurationParamException('Reading speed must be greater than 0'); 82 | } 83 | return $speed; 84 | } 85 | 86 | /** @throws Minz_ConfigurationParamException */ 87 | private function validateMetrics(string $metrics): string { 88 | switch ($metrics) { 89 | case 'words': 90 | case 'letters': 91 | return $metrics; 92 | default: 93 | throw new Minz_ConfigurationParamException('Unsupported source metrics'); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Automated tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | 14 | tests: 15 | # https://github.com/actions/virtual-environments 16 | runs-on: ubuntu-24.04 17 | defaults: 18 | run: 19 | working-directory: ./Extensions 20 | 21 | steps: 22 | - name: Git checkout source code 23 | uses: actions/checkout@v6 24 | with: 25 | path: Extensions 26 | persist-credentials: false 27 | 28 | # Composer tests 29 | 30 | - name: Check PHP syntax 31 | run: composer run-script php-lint 32 | 33 | - name: Check PHTML syntax 34 | run: composer run-script phtml-lint 35 | 36 | - name: Use Composer cache 37 | id: composer-cache 38 | uses: actions/cache@v4 39 | with: 40 | path: Extensions/vendor 41 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 42 | restore-keys: | 43 | ${{ runner.os }}-php- 44 | 45 | - name: Run Composer install 46 | run: composer install --prefer-dist --no-progress 47 | if: steps.composer-cache.outputs.cache-hit != 'true' 48 | 49 | - name: PHP_CodeSniffer 50 | run: composer run-script phpcs 51 | 52 | - name: Git checkout FreshRSS source code 53 | uses: actions/checkout@v6 54 | with: 55 | repository: FreshRSS/FreshRSS 56 | path: FreshRSS 57 | persist-credentials: false 58 | 59 | - name: PHPStan 60 | run: composer run-script phpstan 61 | 62 | # NPM tests 63 | 64 | - name: Uses Node.js 65 | uses: actions/setup-node@v6 66 | with: 67 | # https://nodejs.org/en/about/releases/ 68 | node-version: '22' 69 | cache: 'npm' 70 | cache-dependency-path: 'Extensions/package-lock.json' 71 | 72 | - run: npm ci 73 | 74 | - name: Check JavaScript syntax 75 | run: npm run --silent eslint 76 | 77 | - name: Check Markdown syntax 78 | run: npm run --silent markdownlint 79 | 80 | - name: Check CSS syntax 81 | run: npm run --silent stylelint 82 | 83 | - name: Check Right-to-left CSS 84 | run: npm run --silent rtlcss && git diff --exit-code 85 | 86 | # Shell tests 87 | 88 | - name: Use shell cache 89 | id: shell-cache 90 | uses: actions/cache@v4 91 | with: 92 | path: Extensions/bin 93 | key: ${{ runner.os }}-typos@v1.16.21 94 | 95 | - name: Add ./bin/ to $PATH 96 | run: mkdir -p bin/ && echo "${PWD}/bin" >> $GITHUB_PATH 97 | 98 | - name: Install typos 99 | if: steps.shell-cache.outputs.cache-hit != 'true' 100 | run: | 101 | cd bin ; 102 | wget -q 'https://github.com/crate-ci/typos/releases/download/v1.16.21/typos-v1.16.21-x86_64-unknown-linux-musl.tar.gz' && 103 | [[ "$(sha256sum *.tar.gz | cut -d " " -f 1)" == "a9bc4f49617409ed4ae0f253dd319f5871c6d7a16c6f35bb5fe687572e0f071f" ]] && 104 | tar -xvf *.tar.gz './typos' && 105 | chmod +x typos && 106 | rm *.tar.gz ; 107 | cd .. 108 | 109 | - name: Check spelling 110 | run: bin/typos 111 | -------------------------------------------------------------------------------- /xExtension-ShareByEmail/Controllers/shareByEmailController.php: -------------------------------------------------------------------------------- 1 | extension = Minz_ExtensionManager::findExtension('Share By Email'); 21 | } 22 | 23 | /** 24 | * @throws FreshRSS_Context_Exception 25 | * @throws Minz_ConfigurationException 26 | * @throws Minz_ConfigurationNamespaceException 27 | * @throws Minz_PDOConnectionException 28 | */ 29 | public function shareAction(): void { 30 | if (!FreshRSS_Auth::hasAccess()) { 31 | Minz_Error::error(403); 32 | } 33 | 34 | $id = Minz_Request::paramString('id'); 35 | if ($id === '') { 36 | Minz_Error::error(404); 37 | } 38 | 39 | $entryDAO = FreshRSS_Factory::createEntryDao(); 40 | $entry = $entryDAO->searchById($id); 41 | if ($entry === null) { 42 | Minz_Error::error(404); 43 | return; 44 | } 45 | $this->view->entry = $entry; 46 | 47 | if (!FreshRSS_Context::hasSystemConf()) { 48 | throw new FreshRSS_Context_Exception('System configuration not initialised!'); 49 | } 50 | 51 | $username = Minz_Session::paramString('currentUser') ?: '_'; 52 | $service_name = FreshRSS_Context::systemConf()->title; 53 | $service_url = FreshRSS_Context::systemConf()->base_url; 54 | 55 | Minz_View::prependTitle(_t('shareByEmail.share.title') . ' · '); 56 | if ($this->extension !== null) { 57 | Minz_View::appendStyle($this->extension->getFileUrl('shareByEmail.css')); 58 | } 59 | $this->view->_layout('simple'); 60 | $this->view->to = ''; 61 | $this->view->subject = _t('shareByEmail.share.form.subject_default'); 62 | $this->view->content = _t( 63 | 'shareByEmail.share.form.content_default', 64 | $entry->title(), 65 | $entry->link(), 66 | $username, 67 | $service_name, 68 | $service_url 69 | ); 70 | 71 | if (Minz_Request::isPost()) { 72 | $this->view->to = $to = Minz_Request::paramString('to'); 73 | $this->view->subject = $subject = Minz_Request::paramString('subject'); 74 | $this->view->content = $content = Minz_Request::paramString('content'); 75 | 76 | if ($to == "" || $subject == "" || $content == "") { 77 | Minz_Request::bad(_t('shareByEmail.share.feedback.fields_required'), [ 78 | 'c' => 'shareByEmail', 79 | 'a' => 'share', 80 | 'params' => [ 81 | 'id' => $id, 82 | ], 83 | ]); 84 | } 85 | 86 | $mailer = new \ShareByEmail\mailers\Share(); 87 | $sent = $mailer->send_article($to, $subject, $content); 88 | 89 | if ($sent) { 90 | Minz_Request::good(_t('shareByEmail.share.feedback.sent'), [ 91 | 'c' => 'index', 92 | 'a' => 'index', 93 | ]); 94 | } else { 95 | Minz_Request::bad(_t('shareByEmail.share.feedback.failed'), [ 96 | 'c' => 'shareByEmail', 97 | 'a' => 'share', 98 | 'params' => [ 99 | 'id' => $id, 100 | ], 101 | ]); 102 | } 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /xExtension-ImageProxy/configure.phtml: -------------------------------------------------------------------------------- 1 | 5 |
6 | 7 |
8 | 9 |
10 | 12 |
13 |
14 |
15 | 16 |
17 | attributeBool('image_proxy_scheme_http') ? 'checked' : '' ?>> 19 |
20 |
21 |
22 | 23 |
24 | attributeBool('image_proxy_scheme_https') ? 'checked' : '' ?>> 26 |
27 |
28 |
29 | 30 |
31 | 39 |
40 |
41 |
42 | 43 |
44 | attributeBool('image_proxy_scheme_include') ? 'checked' : '' ?>> 46 |
47 |
48 |
49 | 50 |
51 | attributeBool('image_proxy_url_encode') ? 'checked' : '' ?>> 53 |
54 |
55 | 56 |
57 |
58 | 59 | 60 |
61 |
62 |
63 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/configure.phtml: -------------------------------------------------------------------------------- 1 | 5 |
6 | 7 | 8 |
9 | 13 |
14 | 16 |
17 |
18 | 19 |
20 | 23 |
24 | enable_in_article == '') { ?> 25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 | 33 |
34 | Click to see more advanced options 35 | 36 |
37 | 40 |
41 | case_sensitive == '') { ?> 42 | 43 | 44 | 45 | 46 |
47 |
48 | 49 |
50 | 53 |
54 | separate_word_search == '') { ?> 55 | 56 | 57 | 58 | 59 |
60 |
61 | 62 |
63 | 66 |
67 | enable_logs == '') { ?> 68 | 69 | 70 | 71 | 72 |
73 | 74 |
75 |
76 | 77 |
78 | permission_problem !== '') { ?> 79 |

permission_problem) ?>

80 | 81 |
82 | 83 | 84 |
85 | 86 |
87 | 88 |
89 | -------------------------------------------------------------------------------- /repositories.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "url": "https://github.com/FreshRSS/Extensions", 3 | "type": "git" 4 | }, { 5 | "url": "https://github.com/oyox/FreshRSS-extensions", 6 | "type": "git" 7 | }, { 8 | "url": "https://github.com/Eisa01/FreshRSS---Auto-Refresh-Extension", 9 | "type": "git" 10 | }, { 11 | "url": "https://github.com/aledeg/xExtension-DateFormat", 12 | "type": "git" 13 | }, { 14 | "url": "https://github.com/aledeg/xExtension-LatexSupport", 15 | "type": "git" 16 | }, { 17 | "url": "https://github.com/aledeg/xExtension-Paywall", 18 | "type": "git" 19 | }, { 20 | "url": "https://github.com/aledeg/xExtension-RedditImage", 21 | "type": "git" 22 | }, { 23 | "url": "https://github.com/aledeg/xExtension-WhiteList", 24 | "type": "git" 25 | }, { 26 | "url": "https://framagit.org/nicofrand/xextension-threepanesview", 27 | "type": "git" 28 | }, { 29 | "url": "https://framagit.org/nicofrand/xextension-togglablemenu", 30 | "type": "git" 31 | }, { 32 | "url": "https://github.com/Korbak/freshrss-invidious", 33 | "type": "git" 34 | }, { 35 | "url": "https://github.com/cn-tools/cntools_FreshRssExtensions", 36 | "type": "git" 37 | }, { 38 | "url": "https://github.com/ravenscroftj/freshrss-flaresolverr-extension", 39 | "type": "git" 40 | }, { 41 | "url": "https://github.com/jacob2826/FreshRSS-TranslateTitlesCN", 42 | "type": "git" 43 | }, { 44 | "url": "https://github.com/babico/xExtension-TwitchChannel2RssFeed", 45 | "type": "git" 46 | }, { 47 | "url": "https://github.com/tunbridgep/freshrss-invidious", 48 | "type": "git" 49 | }, { 50 | "url": "https://github.com/javerous/freshrss-greader-redate", 51 | "type": "git" 52 | }, { 53 | "url": "https://github.com/kapdap/freshrss-extensions", 54 | "type": "git" 55 | }, { 56 | "url": "https://github.com/christian-putzke/freshrss-pocket-button", 57 | "type": "git" 58 | }, { 59 | "url": "https://github.com/huffstler/xExtension-StarToPocket", 60 | "type": "git" 61 | }, { 62 | "url": "https://github.com/Joedmin/xExtension-readeck-button", 63 | "type": "git" 64 | }, { 65 | "url": "https://github.com/Joedmin/xExtension-wallabag-button", 66 | "type": "git" 67 | },{ 68 | "url": "https://github.com/printfuck/xExtension-Readable", 69 | "type": "git" 70 | }, { 71 | "url": "https://github.com/Victrid/freshrss-image-cache-plugin", 72 | "type": "git" 73 | }, { 74 | "url": "https://github.com/aidistan/freshrss-extensions", 75 | "type": "git" 76 | }, { 77 | "url": "https://github.com/mgnsk/FreshRSS-AutoTTL", 78 | "type": "git" 79 | }, { 80 | "url": "https://github.com/DevonHess/FreshRSS-Extensions", 81 | "type": "git" 82 | }, { 83 | "url": "https://github.com/reply2future/xExtension-NewsAssistant", 84 | "type": "git" 85 | }, { 86 | "url": "https://github.com/giventofly/freshrss-comicsinfeed", 87 | "type": "git" 88 | }, { 89 | "url": "https://code.sitosis.com/rudism/freshrss-kagi-summarizer", 90 | "type": "git" 91 | }, { 92 | "url": "https://github.com/kalvn/freshrss-mark-previous-as-read", 93 | "type": "git" 94 | }, { 95 | "url": "https://github.com/LiangWei88/xExtension-ArticleSummary", 96 | "type": "git" 97 | }, { 98 | "url": "https://github.com/Niehztog/freshrss-af-readability", 99 | "type": "git" 100 | }, { 101 | "url": "https://github.com/tryallthethings/freshvibes", 102 | "type": "git" 103 | }, { 104 | "url": "https://github.com/pe1uca/xExtension-RateLimiter", 105 | "type": "git" 106 | }, { 107 | "url": "https://github.com/daften/xExtension-ShareToLinkwarden", 108 | "type": "git" 109 | }, { 110 | "url": "https://github.com/fengchang/xExtension-FeedDigest", 111 | "type": "git" 112 | }, { 113 | "url": "https://github.com/veverkap/xExtension-karakeep-button", 114 | "type": "git" 115 | }] 116 | -------------------------------------------------------------------------------- /xExtension-ReadingTime/static/readingtime.js: -------------------------------------------------------------------------------- 1 | (function reading_time() { 2 | 'use strict'; 3 | 4 | const reading_time = { 5 | flux_list: null, 6 | flux: null, 7 | textContent: null, 8 | count: null, 9 | read_time: null, 10 | reading_time: null, 11 | 12 | init: function () { 13 | const flux_list = document.querySelectorAll('[id^="flux_"]'); 14 | const speed = window.context.extensions.reading_time_speed; 15 | const metrics = window.context.extensions.reading_time_metrics; 16 | const language = window.context.i18n.language; 17 | 18 | for (let i = 0; i < flux_list.length; i++) { 19 | if ('readingTime' in flux_list[i].dataset) { 20 | continue; 21 | } 22 | 23 | reading_time.flux = flux_list[i]; 24 | 25 | if (metrics == 'letters') { 26 | reading_time.count = reading_time.flux_letters_count(flux_list[i], language); 27 | } else { // words 28 | reading_time.count = reading_time.flux_words_count(flux_list[i]); 29 | } 30 | reading_time.reading_time = reading_time.calc_read_time(reading_time.count, speed); 31 | 32 | flux_list[i].dataset.readingTime = reading_time.reading_time; 33 | 34 | const li = document.createElement('li'); 35 | li.setAttribute('class', 'item date'); 36 | li.style.width = '40px'; 37 | li.style.overflow = 'hidden'; 38 | li.style.textAlign = 'right'; 39 | li.style.display = 'table-cell'; 40 | li.textContent = reading_time.reading_time + '\u2009m'; 41 | 42 | const ul = document.querySelector('#' + reading_time.flux.id + ' ul.horizontal-list'); 43 | ul.insertBefore(li, ul.children[ul.children.length - 1]); 44 | } 45 | }, 46 | 47 | flux_words_count: function flux_words_count(flux) { 48 | // get textContent, from the article itself (not the header, not the bottom line): 49 | reading_time.textContent = flux.querySelector('.flux_content .content').textContent; 50 | 51 | // split the text to count the words correctly (source: http://www.mediacollege.com/internet/javascript/text/count-words.html) 52 | reading_time.textContent = reading_time.textContent.replace(/(^\s*)|(\s*$)/gi, ''); // exclude start and end white-space 53 | reading_time.textContent = reading_time.textContent.replace(/[ ]{2,}/gi, ' '); // 2 or more space to 1 54 | reading_time.textContent = reading_time.textContent.replace(/\n /, '\n'); // exclude newline with a start spacing 55 | 56 | return reading_time.textContent.split(' ').length; 57 | }, 58 | 59 | flux_letters_count: function flux_letters_count(flux, language) { 60 | const segmenter = new Intl.Segmenter(language, { granularity: 'grapheme' }); 61 | 62 | // get textContent, from the article itself (not the header, not the bottom line): 63 | reading_time.textContent = flux.querySelector('.flux_content .content').textContent; 64 | 65 | // clean the text by removing excessive whitespace 66 | reading_time.textContent = reading_time.textContent.replace(/\s/gi, ''); // exclude white-space 67 | 68 | return [...segmenter.segment(reading_time.textContent)].length; 69 | }, 70 | 71 | calc_read_time: function calc_read_time(count, speed) { 72 | reading_time.read_time = Math.round(count / speed); 73 | 74 | if (reading_time.read_time === 0) { 75 | reading_time.read_time = '<1'; 76 | } 77 | 78 | return reading_time.read_time; 79 | }, 80 | }; 81 | 82 | function add_load_more_listener() { 83 | reading_time.init(); 84 | document.body.addEventListener('freshrss:load-more', function (e) { 85 | reading_time.init(); 86 | }); 87 | 88 | if (window.console) { 89 | console.log('ReadingTime init done.'); 90 | } 91 | } 92 | 93 | if (typeof window.context !== 'undefined' && typeof window.context.extensions !== 'undefined') { 94 | add_load_more_listener(); 95 | } else { 96 | document.addEventListener('freshrss:globalContextLoaded', add_load_more_listener, false); 97 | } 98 | }()); 99 | -------------------------------------------------------------------------------- /generate.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | $gitRepository) { 44 | echo 'Processing ', $gitRepository, ' repository', PHP_EOL; 45 | exec("GIT_TERMINAL_PROMPT=0 git clone --quiet --single-branch --depth 1 --no-tags {$gitRepository} {$tempFolder}/{$key}"); 46 | 47 | unset($metadataFiles); 48 | exec("find {$tempFolder}/{$key} -iname metadata.json", $metadataFiles); 49 | foreach ($metadataFiles as $metadataFile) { 50 | try { 51 | $metadata = json_decode(file_get_contents($metadataFile) ?: '', true, 512, JSON_THROW_ON_ERROR); 52 | if (!is_array($metadata)) { 53 | throw new ParseError('Not an array!'); 54 | } 55 | $directory = basename(dirname($metadataFile)); 56 | $metadata['url'] = $gitRepository; 57 | $metadata['version'] = is_scalar($metadata['version'] ?? null) ? strval($metadata['version']) : ''; 58 | $metadata['method'] = TYPE_GIT; 59 | $metadata['directory'] = ($directory === sha1($gitRepository)) ? '.' : $directory; 60 | 61 | $required_keys = [ 62 | 'name', 63 | 'author', 64 | 'description', 65 | 'version', 66 | 'entrypoint', 67 | 'type', 68 | 'url', 69 | 'method', 70 | 'directory', 71 | ]; 72 | 73 | // Sanitize extension values to prevent HTML injection (when rendered by FreshRSS) 74 | // Also clean unnecessary keys 75 | foreach ($metadata as $k => $v) { 76 | if ($k === 'description') { 77 | continue; 78 | } 79 | if (!in_array($k, $required_keys, true)) { 80 | unset($metadata[$k]); 81 | continue; 82 | } 83 | $metadata[$k] = htmlspecialchars(is_string($metadata[$k]) ? $metadata[$k] : '', ENT_COMPAT, 'UTF-8'); 84 | } 85 | $metadata['description'] = strip_tags(is_string($metadata['description'] ?? null) ? $metadata['description'] : '', allowed_tags: ['a']); 86 | 87 | $extensions[] = $metadata; 88 | } catch (Exception $exception) { 89 | continue; 90 | } 91 | } 92 | } 93 | 94 | // --------------- // 95 | // Generate output // 96 | // --------------- // 97 | usort($extensions, function ($a, $b) { 98 | return $a['name'] <=> $b['name']; 99 | }); 100 | $output = [ 101 | 'version' => VERSION, 102 | 'extensions' => $extensions, 103 | ]; 104 | try { 105 | file_put_contents('extensions.json', json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL); 106 | 107 | echo PHP_EOL; 108 | echo \count($extensions), ' extensions found.', PHP_EOL; 109 | } catch (Exception $exception) { 110 | echo 'The extensions.json file can not be generated.', PHP_EOL; 111 | exit(1); 112 | } 113 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/extension.php: -------------------------------------------------------------------------------- 1 | registerTranslates(); 19 | 20 | // register CSS for WordHighlighter: 21 | Minz_View::appendStyle($this->getFileUrl('style.css')); 22 | 23 | Minz_View::appendScript($this->getFileUrl('mark.min.js'), cond: false, defer: false, async: false); 24 | 25 | $current_user = Minz_Session::paramString('currentUser'); 26 | 27 | $staticPath = join_path($this->getPath(), 'static'); 28 | $configFileJs = join_path($staticPath, 'config.' . $current_user . '.js'); 29 | 30 | if (file_exists($configFileJs)) { 31 | Minz_View::appendScript($this->getFileUrl('config.' . $current_user . '.js')); 32 | } 33 | 34 | Minz_View::appendScript($this->getFileUrl('word-highlighter.js')); 35 | } 36 | 37 | #[\Override] 38 | public function handleConfigureAction(): void { 39 | $this->registerTranslates(); 40 | 41 | $current_user = Minz_Session::paramString('currentUser'); 42 | $staticPath = join_path($this->getPath(), 'static'); 43 | 44 | $configFileJson = join_path($staticPath, ('config.' . $current_user . '.json')); 45 | 46 | if (!file_exists($configFileJson) && !is_writable($staticPath)) { 47 | $tmpPath = explode(EXTENSIONS_PATH . '/', $staticPath); 48 | $this->permission_problem = $tmpPath[1] . '/'; 49 | } elseif (file_exists($configFileJson) && !is_writable($configFileJson)) { 50 | $tmpPath = explode(EXTENSIONS_PATH . '/', $configFileJson); 51 | $this->permission_problem = $tmpPath[1]; 52 | } elseif (Minz_Request::isPost()) { 53 | $configWordList = html_entity_decode(Minz_Request::paramString('words_list')); 54 | 55 | $this->word_highlighter_conf = $configWordList; 56 | $this->enable_in_article = (bool) Minz_Request::paramString('enable-in-article'); 57 | $this->enable_logs = (bool) Minz_Request::paramString('enable_logs'); 58 | $this->case_sensitive = (bool) Minz_Request::paramString('case_sensitive'); 59 | $this->separate_word_search = (bool) Minz_Request::paramString('separate_word_search'); 60 | 61 | $configObj = [ 62 | 'enable_in_article' => $this->enable_in_article, 63 | 'enable_logs' => $this->enable_logs, 64 | 'case_sensitive' => $this->case_sensitive, 65 | 'separate_word_search' => $this->separate_word_search, 66 | 'words' => preg_split("/\r\n|\n|\r/", $configWordList), 67 | ]; 68 | $configJson = json_encode($configObj, WordHighlighterExtension::JSON_ENCODE_CONF); 69 | file_put_contents(join_path($staticPath, ('config.' . $current_user . '.json')), $configJson . PHP_EOL); 70 | file_put_contents(join_path($staticPath, ('config.' . $current_user . '.js')), $this->jsonToJs($configJson) . PHP_EOL); 71 | } 72 | 73 | if (file_exists($configFileJson)) { 74 | try { 75 | $confJson = json_decode(file_get_contents($configFileJson) ?: '', true, 8, JSON_THROW_ON_ERROR); 76 | if (json_last_error() !== JSON_ERROR_NONE || !is_array($confJson)) { 77 | return; 78 | } 79 | $this->enable_in_article = (bool) ($confJson['enable_in_article'] ?? false); 80 | $this->enable_logs = (bool) ($confJson['enable_logs'] ?? false); 81 | $this->case_sensitive = (bool) ($confJson['case_sensitive'] ?? false); 82 | $this->separate_word_search = (bool) ($confJson['separate_word_search'] ?? false); 83 | $this->word_highlighter_conf = implode("\n", (array) ($confJson['words'] ?? [])); 84 | } catch (Exception $exception) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch 85 | // probably nothing to do needed 86 | } 87 | } 88 | } 89 | 90 | private function jsonToJs(string $jsonStr): string { 91 | $js = "window.WordHighlighterConf = " . 92 | $jsonStr . ";\n" . 93 | "window.WordHighlighterConf.enable_logs && console.log('WordHighlighter: loaded user config:', window.WordHighlighterConf);"; 94 | return $js; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /xExtension-Captcha/configure.phtml: -------------------------------------------------------------------------------- 1 | getName())) { 6 | echo '
' . _t('ext.form_captcha.ext_must_be_enabled') . ''; 7 | return; 8 | } 9 | 10 | $config = CaptchaExtension::getConfig(); 11 | ?> 12 |
18 | 19 | 20 | 21 |
22 | 23 |
24 |
25 | 26 | /> 27 | 28 |
29 |
30 |
31 | /> 32 | 33 |
34 |
35 |
36 | 37 |
38 | 39 |
40 | 47 |
48 |
49 | 50 | 75 | 76 | 83 | 90 | 97 | 104 | 105 |
106 | 109 | 110 |
111 |
112 | 113 | 114 | 115 |
116 |
117 |
118 | -------------------------------------------------------------------------------- /xExtension-ImageProxy/README.md: -------------------------------------------------------------------------------- 1 | # Image Proxy extension 2 | 3 | This FreshRSS extension allows you to get rid of insecure content warnings or disappearing images when you use an encrypted connection to FreshRSS. An encrypted connection can be [very easily enabled](http://fransdejonge.com/2016/05/lets-encrypt-on-debianjessie/) thanks to the [Let's Encrypt](https://letsencrypt.org/) initiative. 4 | 5 | To use it, upload this entire directory to the FreshRSS `./extensions` directory on your server and enable it on the extension panel in FreshRSS. 6 | 7 | ## Changelog 8 | 9 | * 1.0 Breaking changes due to significant code upgrade: settings must be saved again 10 | * 0.7.3 Turkish language support added 11 | 12 | ## Configuration settings 13 | 14 | * `proxy_url` (default: `https://images.example.com/?url=`): the URL that is prependended to the original image URL 15 | 16 | * `scheme_http` (default: `1`): whether to proxy HTTP resources 17 | 18 | * `scheme_https` (default: `0`): whether to proxy HTTPS resources 19 | 20 | * `scheme_default` (default: `auto`): which scheme to use for resources that do not include one; if set to `-`, those will not be proxied; 21 | if set along `scheme_include`, the scheme included in the URL will either be `auto`-matically derived from your current connection or the one explicitly specified 22 | 23 | * `scheme_include` (default: `0`): whether to include the scheme - `http*://` - in the proxied URL 24 | 25 | * `url_encode` (default: `1`): whether to URL-encode (RFC 3986) the proxied URL 26 | 27 | ## Proxy Settings 28 | 29 | By default this extension will use the [wsrv.nl](https://wsrv.nl) image caching and resizing proxy, but instead you can supply your own proxy URL in the settings. An example URL would look like ``https://images.example.com/?url=``. 30 | 31 | By ticking the `scheme_https` checkbox, you can also force the use of the proxy, even for images coming through an encrypted channel. This makes the server that hosts your FreshRSS instance the only point of entry for images, preventing your client from connecting directly to the RSS sources to recover them (which could be a privacy concern in extreme cases). 32 | 33 | The source code for the wsrv.nl proxy can be found at [github.com/weserv/images](https://github.com/weserv/images), but of course other methods are available. For example, in Apache you could [use `mod_rewrite` to set up a simple proxy](#apache-configuration) and similar methods are available in nginx and lighttpd. Alternatively you could use a simple PHP script, [along these lines](https://github.com/Alexxz/Simple-php-proxy-script). Keep in mind that too simple a proxy could introduce security risks, which is why the default proxy processes the images. 34 | 35 | ### Apache configuration 36 | 37 | In order to use Apache [mod_rewrite](https://httpd.apache.org/docs/current/mod/mod_rewrite.html), you will need to set the following settings: 38 | 39 | * `proxy_url` = **** 40 | 41 | * `scheme_include` = **1** 42 | 43 | * `url_encode` = **0** 44 | 45 | Along the following Apache configuration for the `www.example.org` virtual host: 46 | 47 | ```apache 48 | # WARNING: Multiple '/' in %{REQUEST_URI} are internally trimmed to a single one! 49 | RewriteCond %{REQUEST_URI} ^/proxy/https:/+(.*)$ 50 | RewriteRule ^ https://%1 [QSA,P,L] 51 | RewriteCond %{REQUEST_URI} ^/proxy/http:/+(.*)$ 52 | RewriteRule ^ http://%1 [QSA,P,L] 53 | # CRITICAL: Do NOT leave your proxy open to everyone!!! 54 | 55 | # Local network 56 | Require ip 192.168.0.0/16 172.16.0.0/12 10.0.0.0/8 57 | # Users 58 | AuthType Basic 59 | AuthName "Proxy - Authorized Users ONLY" 60 | AuthBasicProvider file 61 | AuthUserFile /etc/apache2/htpasswd/users 62 | Require valid-user 63 | # Local network OR authenticated users 64 | Satisfy any 65 | 66 | # CRITICAL: Do NOT allow access to local resources!!! 67 | # - (any) IPv4 68 | # - (any) IPv6 69 | # - localhost 70 | # - local.domain (e.g. example.org) 71 | 72 | Require all denied 73 | 74 | ``` 75 | 76 | ### nginx configuration 77 | 78 | In order to use nginx's [proxy 79 | module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html), you will 80 | need to set the following settings: 81 | 82 | * `proxy_url` = **** 83 | * `scheme_include` = **1** 84 | * `url_encode` = **0** 85 | 86 | Add this to your nginx config: 87 | 88 | ``` nginx 89 | # Use 1 GiB cache with a 1 MiB memory zone (enough for ~8,000 keys). 90 | # Delete data that has not been accessed for 12 hours. 91 | proxy_cache_path /var/cache/nginx/freshrss levels=1:2 keys_zone=freshrss:1m 92 | max_size=1g inactive=12h use_temp_path=off; 93 | 94 | server { 95 | 96 | … 97 | 98 | location /proxy { 99 | if ($arg_key = "changeme") { 100 | proxy_pass $arg_url; 101 | } 102 | # Handle redirects coming from the target server. 103 | proxy_redirect ~^(.*)$ https://www.example.org/proxy?key=$arg_key&url=$1; 104 | proxy_ssl_server_name on; 105 | proxy_cache freshrss; 106 | # Cache positive answers for up to 2 days. 107 | proxy_cache_valid 200 301 302 307 308 2d; 108 | } 109 | 110 | … 111 | 112 | } 113 | ``` 114 | 115 | If you do not need caching, omit all lines starting with `proxy_cache`. If you 116 | would like to limit access based on IP addresses instead, take a look at 117 | [ngx_http_access_module](http://nginx.org/en/docs/http/ngx_http_access_module.html). 118 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | /\.git/* 6 | /data/config.php 7 | /data/update.php 8 | /data/users/*/config.php 9 | /(?-i:extensions)/* 10 | /lib/http-conditional.php 11 | /lib/marienfressinaud/ 12 | /lib/phpgt/* 13 | /lib/phpmailer/* 14 | /lib/simplepie/* 15 | /node_modules/* 16 | /p/scripts/vendor/* 17 | /vendor/* 18 | 19 | /symbolic/* 20 | /third-party/* 21 | /tmp/* 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | /i18n/*\.php$ 69 | *\.phtml$ 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | *\.phtml$ 81 | /app/install.php 82 | 83 | 84 | *\.phtml$ 85 | /app/install.php 86 | 87 | 88 | *\.phtml$ 89 | 90 | 91 | 92 | 93 | *\.phtml$ 94 | 95 | 96 | 97 | 98 | 99 | *\.php$ 100 | 101 | 102 | 103 | *\.phtml$ 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | *\.phtml$ 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /xExtension-ImageProxy/extension.php: -------------------------------------------------------------------------------- 1 | registerHook('entry_before_display', [self::class, 'setImageProxyHook']); 23 | // Defaults 24 | $save = false; 25 | if (FreshRSS_Context::userConf()->attributeString('image_proxy_url') == null) { 26 | FreshRSS_Context::userConf()->_attribute('image_proxy_url', self::PROXY_URL); 27 | $save = true; 28 | } 29 | if (FreshRSS_Context::userConf()->attributeBool('image_proxy_scheme_http') === null) { 30 | FreshRSS_Context::userConf()->_attribute('image_proxy_scheme_http', self::SCHEME_HTTP); 31 | $save = true; 32 | } 33 | if (FreshRSS_Context::userConf()->attributeBool('image_proxy_scheme_https') === null) { 34 | FreshRSS_Context::userConf()->_attribute('image_proxy_scheme_https', self::SCHEME_HTTPS); 35 | $save = true; 36 | } 37 | if (FreshRSS_Context::userConf()->attributeString('image_proxy_scheme_default') === null) { 38 | FreshRSS_Context::userConf()->_attribute('image_proxy_scheme_default', self::SCHEME_DEFAULT); 39 | $save = true; 40 | } 41 | if (FreshRSS_Context::userConf()->attributeBool('image_proxy_scheme_include') === null) { 42 | FreshRSS_Context::userConf()->_attribute('image_proxy_scheme_include', self::SCHEME_INCLUDE); 43 | $save = true; 44 | } 45 | if (FreshRSS_Context::userConf()->attributeBool('image_proxy_url_encode') === null) { 46 | FreshRSS_Context::userConf()->_attribute('image_proxy_url_encode', self::URL_ENCODE); 47 | $save = true; 48 | } 49 | if ($save) { 50 | FreshRSS_Context::userConf()->save(); 51 | } 52 | } 53 | 54 | /** 55 | * @throws FreshRSS_Context_Exception 56 | */ 57 | #[\Override] 58 | public function handleConfigureAction(): void { 59 | $this->registerTranslates(); 60 | 61 | if (Minz_Request::isPost()) { 62 | FreshRSS_Context::userConf()->_attribute('image_proxy_url', Minz_Request::paramString('image_proxy_url', plaintext: true) ?: self::PROXY_URL); 63 | FreshRSS_Context::userConf()->_attribute('image_proxy_scheme_http', Minz_Request::paramBoolean('image_proxy_scheme_http')); 64 | FreshRSS_Context::userConf()->_attribute('image_proxy_scheme_https', Minz_Request::paramBoolean('image_proxy_scheme_https')); 65 | FreshRSS_Context::userConf()->_attribute('image_proxy_scheme_default', 66 | Minz_Request::paramString('image_proxy_scheme_default', plaintext: true) ?: self::SCHEME_DEFAULT); 67 | FreshRSS_Context::userConf()->_attribute('image_proxy_scheme_include', Minz_Request::paramBoolean('image_proxy_scheme_include')); 68 | FreshRSS_Context::userConf()->_attribute('image_proxy_url_encode', Minz_Request::paramBoolean('image_proxy_url_encode')); 69 | FreshRSS_Context::userConf()->save(); 70 | } 71 | } 72 | 73 | /** 74 | * @throws FreshRSS_Context_Exception 75 | */ 76 | public static function getProxyImageUri(string $url): string { 77 | $parsed_url = parse_url($url); 78 | $scheme = $parsed_url['scheme'] ?? ''; 79 | if ($scheme === 'http') { 80 | if (!FreshRSS_Context::userConf()->attributeBool('image_proxy_scheme_http')) { 81 | return $url; 82 | } 83 | if (!FreshRSS_Context::userConf()->attributeBool('image_proxy_scheme_include')) { 84 | $url = substr($url, 7); // http:// 85 | } 86 | } elseif ($scheme === 'https') { 87 | if (!FreshRSS_Context::userConf()->attributeBool('image_proxy_scheme_https')) { 88 | return $url; 89 | } 90 | if (!FreshRSS_Context::userConf()->attributeBool('image_proxy_scheme_include')) { 91 | $url = substr($url, 8); // https:// 92 | } 93 | } elseif ($scheme === '') { 94 | if (FreshRSS_Context::userConf()->attributeString('image_proxy_scheme_default') === 'auto') { 95 | if (FreshRSS_Context::userConf()->attributeBool('image_proxy_scheme_include')) { 96 | $url = ((is_string($_SERVER['HTTPS'] ?? null) && strtolower($_SERVER['HTTPS']) !== 'off') ? 'https:' : 'http:') . $url; 97 | } 98 | } elseif (str_starts_with(FreshRSS_Context::userConf()->attributeString('image_proxy_scheme_default') ?? '', 'http')) { 99 | if (FreshRSS_Context::userConf()->attributeBool('image_proxy_scheme_include')) { 100 | $url = FreshRSS_Context::userConf()->attributeString('image_proxy_scheme_default') . ':' . $url; 101 | } 102 | } else { // do not proxy unschemed ("//path/...") URLs 103 | return $url; 104 | } 105 | } else { // unknown/unsupported (non-http) scheme 106 | return $url; 107 | } 108 | if (FreshRSS_Context::userConf()->attributeBool('image_proxy_url_encode')) { 109 | $url = rawurlencode($url); 110 | } 111 | return FreshRSS_Context::userConf()->attributeString('image_proxy_url') . $url; 112 | } 113 | 114 | /** 115 | * @param array $matches 116 | * @throws FreshRSS_Context_Exception 117 | */ 118 | public static function getSrcSetUris(array $matches): string { 119 | return str_replace($matches[1], self::getProxyImageUri($matches[1]), $matches[0]); 120 | } 121 | 122 | /** 123 | * @throws FreshRSS_Context_Exception 124 | */ 125 | public static function swapUris(string $content): string { 126 | if ($content === '') { 127 | return $content; 128 | } 129 | 130 | $doc = new DOMDocument(); 131 | libxml_use_internal_errors(true); // prevent tag soup errors from showing 132 | $content = mb_convert_encoding($content, 'HTML-ENTITIES', 'UTF-8'); 133 | if (!is_string($content)) { 134 | return ''; 135 | } 136 | $doc->loadHTML($content); 137 | $imgs = $doc->getElementsByTagName('img'); 138 | foreach ($imgs as $img) { 139 | if (!($img instanceof DOMElement)) { 140 | continue; 141 | } 142 | if ($img->hasAttribute('src')) { 143 | $src = $img->getAttribute('src'); 144 | $newSrc = self::getProxyImageUri($src); 145 | /* 146 | Due to the URL change, FreshRSS is not aware of already rendered enclosures. 147 | Adding data-xextension-imageproxy-original-src / srcset ensures that 148 | original URLs are present in the content for the renderer check FreshRSS_Entry->containsLink. 149 | */ 150 | $img->setAttribute('data-xextension-imageproxy-original-src', $src); 151 | $img->setAttribute('src', $newSrc); 152 | } 153 | if ($img->hasAttribute('srcset')) { 154 | $srcSet = $img->getAttribute('srcset'); 155 | $newSrcSet = preg_replace_callback('/(?:([^\s,]+)(\s*(?:\s+\d+[wx])(?:,\s*)?))/', fn (array $matches) => self::getSrcSetUris($matches), $srcSet); 156 | if ($newSrcSet != null) { 157 | $img->setAttribute('data-xextension-imageproxy-original-srcset', $srcSet); 158 | $img->setAttribute('srcset', $newSrcSet); 159 | } 160 | } 161 | } 162 | 163 | $body = $doc->getElementsByTagName('body')->item(0); 164 | 165 | $output = $doc->saveHTML($body); 166 | if ($output === false) { 167 | return ''; 168 | } 169 | 170 | $output = preg_replace('/^|<\/body>$/', '', $output) ?? ''; 171 | 172 | return $output; 173 | } 174 | 175 | /** 176 | * @throws FreshRSS_Context_Exception 177 | */ 178 | public static function setImageProxyHook(FreshRSS_Entry $entry): FreshRSS_Entry { 179 | $entry->_content( 180 | self::swapUris($entry->content()) 181 | ); 182 | 183 | return $entry; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /xExtension-Captcha/extension.php: -------------------------------------------------------------------------------- 1 | ,captchaProvider:string,provider:array,sendClientIp:bool} $default_config */ 6 | public static array $default_config = [ 7 | 'protectedPages' => [], 8 | 'captchaProvider' => 'none', 9 | 'provider' => [], 10 | 'sendClientIp' => true, 11 | ]; 12 | public static string $recaptcha_v3_js; 13 | 14 | #[\Override] 15 | public function init(): void { 16 | $this->registerTranslates(); 17 | $this->registerHook('before_login_btn', [$this, 'captchaWidget']); 18 | $this->registerController('auth'); 19 | $this->registerController('user'); 20 | 21 | self::$recaptcha_v3_js = $this->getFileUrl('recaptcha-v3.js'); 22 | 23 | if (Minz_Request::controllerName() === 'extension') { 24 | Minz_View::appendScript($this->getFileUrl('captchaConfig.js')); 25 | } 26 | } 27 | 28 | /** 29 | * @throws FreshRSS_Context_Exception 30 | */ 31 | public static function isProtectedPage(): bool { 32 | $config = self::getConfig(); 33 | $page = Minz_Request::controllerName() . '_' . Minz_Request::actionName(); 34 | return in_array($page, $config['protectedPages'], true); 35 | } 36 | 37 | public static function getClientIp(): string { 38 | $ip = checkTrustedIP() ? ($_SERVER['HTTP_X_REAL_IP'] ?? connectionRemoteAddress()) : connectionRemoteAddress(); 39 | return is_string($ip) ? $ip : ''; 40 | } 41 | 42 | /** 43 | * @throws FreshRSS_Context_Exception 44 | */ 45 | public function captchaWidget(): string { 46 | $config = self::getConfig(); 47 | if (!self::isProtectedPage()) { 48 | return ''; 49 | } 50 | $siteKey = $config['provider']['siteKey'] ?? ''; 51 | return match ($config['captchaProvider']) { 52 | 'turnstile' => '
', 53 | 'recaptcha-v2' => '
', 54 | 'recaptcha-v3' => '', 55 | 'hcaptcha' => '
', 56 | default => '', 57 | }; 58 | } 59 | 60 | /** 61 | * @throws Minz_PermissionDeniedException 62 | */ 63 | public static function warnLog(string $msg): void { 64 | Minz_Log::warning('[Form Captcha] ' . $msg, ADMIN_LOG); 65 | } 66 | 67 | /** 68 | * @throws FreshRSS_Context_Exception 69 | * @return array{protectedPages:string[],captchaProvider:string,provider:array,sendClientIp:bool} 70 | */ 71 | public static function getConfig(): array { 72 | /** @var array{protectedPages:array,captchaProvider:string,provider:array,sendClientIp:bool} $cfg */ 73 | $cfg = FreshRSS_Context::systemConf()->attributeArray('form_captcha_config') ?? self::$default_config; 74 | if (in_array('auth_register', $cfg['protectedPages'], true)) { 75 | // Protect POST action for registration form 76 | $cfg['protectedPages'][] = 'user_create'; 77 | } 78 | return $cfg; 79 | } 80 | 81 | /** 82 | * @throws FreshRSS_Context_Exception 83 | * @throws Minz_PermissionDeniedException 84 | */ 85 | public static function initCaptcha(): bool { 86 | $username = Minz_Request::paramString('username'); 87 | 88 | $config = CaptchaExtension::getConfig(); 89 | $provider = $config['captchaProvider']; 90 | 91 | if ($provider === 'none') { 92 | return true; 93 | } 94 | 95 | if (Minz_Request::isPost() && CaptchaExtension::isProtectedPage()) { 96 | $ch = curl_init(); 97 | if ($ch === false) { 98 | Minz_Error::error(500); 99 | return false; 100 | } 101 | 102 | /* 103 | See: 104 | https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ 105 | https://developers.google.com/recaptcha/docs/verify?hl=en 106 | https://docs.hcaptcha.com/#verify-the-user-response-server-side 107 | */ 108 | 109 | $siteverify_url = match ($provider) { 110 | 'turnstile' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify', 111 | 'recaptcha-v2' => 'https://www.google.com/recaptcha/api/siteverify', 112 | 'recaptcha-v3' => 'https://www.google.com/recaptcha/api/siteverify', 113 | 'hcaptcha' => 'https://hcaptcha.com/siteverify', 114 | default => '', 115 | }; 116 | if ($siteverify_url === '') { 117 | Minz_Error::error(500); 118 | return false; 119 | } 120 | $response_param = match ($provider) { 121 | 'turnstile' => 'cf-turnstile-response', 122 | 'recaptcha-v2' => 'g-recaptcha-response', 123 | 'recaptcha-v3' => 'g-recaptcha-response', 124 | 'hcaptcha' => 'h-captcha-response', 125 | default => '', 126 | }; 127 | $response_val = Minz_Request::paramString($response_param); 128 | 129 | $fields = [ 130 | 'secret' => $config['provider']['secretKey'] ?? '', 131 | 'response' => $response_val, 132 | ]; 133 | if ($config['sendClientIp']) { 134 | $fields['remoteip'] = CaptchaExtension::getClientIp(); 135 | } 136 | curl_setopt_array($ch, [ 137 | CURLOPT_URL => $siteverify_url, 138 | CURLOPT_POST => true, 139 | CURLOPT_POSTFIELDS => http_build_query($fields), 140 | CURLOPT_USERAGENT => FRESHRSS_USERAGENT, 141 | CURLOPT_RETURNTRANSFER => true, 142 | ]); 143 | curl_setopt_array($ch, FreshRSS_Context::systemConf()->curl_options); 144 | 145 | $body = curl_exec($ch); 146 | if (!is_string($body)) { 147 | Minz_Error::error(500); 148 | return false; 149 | } 150 | /** @var array{success:bool,error-codes:string[]} $json */ 151 | $json = json_decode($body, true); 152 | if (!is_array($json)) { 153 | Minz_Error::error(500); 154 | return false; 155 | } 156 | if ($json['success'] !== true) { 157 | $actionName = Minz_Request::actionName(); 158 | CaptchaExtension::warnLog("($actionName) Failed to verify '$provider' challenge for user \"$username\": " . implode(',', $json['error-codes'])); 159 | Minz_Error::error(400, ['error' => [_t('ext.form_captcha.invalid_captcha')]]); 160 | return false; 161 | } 162 | } 163 | return true; 164 | } 165 | 166 | /** 167 | * @throws FreshRSS_Context_Exception 168 | * @return array 169 | */ 170 | public static function loadDependencies(): array { 171 | $cfg = self::getConfig(); 172 | $provider = self::isProtectedPage() ? $cfg['captchaProvider'] : ''; 173 | $js_url = match ($provider) { 174 | 'turnstile' => 'https://challenges.cloudflare.com/turnstile/v0/api.js', 175 | 'recaptcha-v2' => 'https://www.google.com/recaptcha/api.js', 176 | 'recaptcha-v3' => 'https://www.google.com/recaptcha/api.js?render=' . $cfg['provider']['siteKey'], 177 | 'hcaptcha' => 'https://js.hcaptcha.com/1/api.js', 178 | default => '', 179 | }; 180 | if ($js_url === '') { 181 | return []; 182 | } 183 | $csp_hosts = parse_url($js_url); 184 | if (!is_array($csp_hosts)) { 185 | Minz_Error::error(500); 186 | return []; 187 | } 188 | $csp_hosts = 'https://' . ($csp_hosts['host'] ?? ''); 189 | if ($csp_hosts === 'https://www.google.com') { 190 | // Original js_url injects script from www.gstatic.com therefore this is needed 191 | $csp_hosts .= "/recaptcha/api.js https://www.gstatic.com/recaptcha/"; 192 | } elseif ($csp_hosts === 'https://js.hcaptcha.com') { 193 | $csp_hosts = 'https://hcaptcha.com https://*.hcaptcha.com'; 194 | } 195 | $csp = [ 196 | 'default-src' => "'self'", 197 | 'frame-ancestors' => "'none'", 198 | 'script-src' => "'self' $csp_hosts", 199 | 'frame-src' => $csp_hosts, 200 | 'connect-src' => "'self' $csp_hosts", 201 | ]; 202 | if ($provider === 'hcaptcha') { 203 | $csp['style-src'] = "'self' " . $csp_hosts; 204 | } 205 | Minz_View::appendScript($js_url); 206 | if ($provider === 'recaptcha-v3') { 207 | Minz_View::appendScript(self::$recaptcha_v3_js); 208 | } 209 | return $csp; 210 | } 211 | 212 | /** 213 | * @throws FreshRSS_Context_Exception 214 | */ 215 | #[\Override] 216 | public function handleConfigureAction(): void { 217 | $this->registerTranslates(); 218 | 219 | if (FreshRSS_Auth::requestReauth()) { 220 | return; 221 | } 222 | 223 | if (Minz_Request::isPost()) { 224 | $form_captcha_config = [ 225 | 'protectedPages' => Minz_Request::paramArray('protectedPages'), 226 | 'captchaProvider' => Minz_Request::paramStringNull('captchaProvider') ?? 'none', 227 | 'provider' => Minz_Request::paramArray('provider'), 228 | 'sendClientIp' => Minz_Request::paramBoolean('sendClientIp'), 229 | ]; 230 | 231 | FreshRSS_Context::systemConf()->_attribute('form_captcha_config', $form_captcha_config); 232 | FreshRSS_Context::systemConf()->save(); 233 | 234 | Minz_Request::setGoodNotification(_t('feedback.conf.updated')); 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "8a6dcc1afea2037b32149736705f192e", 8 | "packages": [], 9 | "packages-dev": [ 10 | { 11 | "name": "phpstan/phpstan", 12 | "version": "2.1.32", 13 | "dist": { 14 | "type": "zip", 15 | "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227", 16 | "reference": "e126cad1e30a99b137b8ed75a85a676450ebb227", 17 | "shasum": "" 18 | }, 19 | "require": { 20 | "php": "^7.4|^8.0" 21 | }, 22 | "conflict": { 23 | "phpstan/phpstan-shim": "*" 24 | }, 25 | "bin": [ 26 | "phpstan", 27 | "phpstan.phar" 28 | ], 29 | "type": "library", 30 | "autoload": { 31 | "files": [ 32 | "bootstrap.php" 33 | ] 34 | }, 35 | "notification-url": "https://packagist.org/downloads/", 36 | "license": [ 37 | "MIT" 38 | ], 39 | "description": "PHPStan - PHP Static Analysis Tool", 40 | "keywords": [ 41 | "dev", 42 | "static analysis" 43 | ], 44 | "support": { 45 | "docs": "https://phpstan.org/user-guide/getting-started", 46 | "forum": "https://github.com/phpstan/phpstan/discussions", 47 | "issues": "https://github.com/phpstan/phpstan/issues", 48 | "security": "https://github.com/phpstan/phpstan/security/policy", 49 | "source": "https://github.com/phpstan/phpstan-src" 50 | }, 51 | "funding": [ 52 | { 53 | "url": "https://github.com/ondrejmirtes", 54 | "type": "github" 55 | }, 56 | { 57 | "url": "https://github.com/phpstan", 58 | "type": "github" 59 | } 60 | ], 61 | "time": "2025-11-11T15:18:17+00:00" 62 | }, 63 | { 64 | "name": "phpstan/phpstan-strict-rules", 65 | "version": "2.0.7", 66 | "source": { 67 | "type": "git", 68 | "url": "https://github.com/phpstan/phpstan-strict-rules.git", 69 | "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538" 70 | }, 71 | "dist": { 72 | "type": "zip", 73 | "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/d6211c46213d4181054b3d77b10a5c5cb0d59538", 74 | "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538", 75 | "shasum": "" 76 | }, 77 | "require": { 78 | "php": "^7.4 || ^8.0", 79 | "phpstan/phpstan": "^2.1.29" 80 | }, 81 | "require-dev": { 82 | "php-parallel-lint/php-parallel-lint": "^1.2", 83 | "phpstan/phpstan-deprecation-rules": "^2.0", 84 | "phpstan/phpstan-phpunit": "^2.0", 85 | "phpunit/phpunit": "^9.6" 86 | }, 87 | "type": "phpstan-extension", 88 | "extra": { 89 | "phpstan": { 90 | "includes": [ 91 | "rules.neon" 92 | ] 93 | } 94 | }, 95 | "autoload": { 96 | "psr-4": { 97 | "PHPStan\\": "src/" 98 | } 99 | }, 100 | "notification-url": "https://packagist.org/downloads/", 101 | "license": [ 102 | "MIT" 103 | ], 104 | "description": "Extra strict and opinionated rules for PHPStan", 105 | "support": { 106 | "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", 107 | "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.7" 108 | }, 109 | "time": "2025-09-26T11:19:08+00:00" 110 | }, 111 | { 112 | "name": "squizlabs/php_codesniffer", 113 | "version": "4.0.1", 114 | "source": { 115 | "type": "git", 116 | "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", 117 | "reference": "0525c73950de35ded110cffafb9892946d7771b5" 118 | }, 119 | "dist": { 120 | "type": "zip", 121 | "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", 122 | "reference": "0525c73950de35ded110cffafb9892946d7771b5", 123 | "shasum": "" 124 | }, 125 | "require": { 126 | "ext-simplexml": "*", 127 | "ext-tokenizer": "*", 128 | "ext-xmlwriter": "*", 129 | "php": ">=7.2.0" 130 | }, 131 | "require-dev": { 132 | "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" 133 | }, 134 | "bin": [ 135 | "bin/phpcbf", 136 | "bin/phpcs" 137 | ], 138 | "type": "library", 139 | "notification-url": "https://packagist.org/downloads/", 140 | "license": [ 141 | "BSD-3-Clause" 142 | ], 143 | "authors": [ 144 | { 145 | "name": "Greg Sherwood", 146 | "role": "Former lead" 147 | }, 148 | { 149 | "name": "Juliette Reinders Folmer", 150 | "role": "Current lead" 151 | }, 152 | { 153 | "name": "Contributors", 154 | "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" 155 | } 156 | ], 157 | "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", 158 | "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", 159 | "keywords": [ 160 | "phpcs", 161 | "standards", 162 | "static analysis" 163 | ], 164 | "support": { 165 | "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", 166 | "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", 167 | "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", 168 | "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" 169 | }, 170 | "funding": [ 171 | { 172 | "url": "https://github.com/PHPCSStandards", 173 | "type": "github" 174 | }, 175 | { 176 | "url": "https://github.com/jrfnl", 177 | "type": "github" 178 | }, 179 | { 180 | "url": "https://opencollective.com/php_codesniffer", 181 | "type": "open_collective" 182 | }, 183 | { 184 | "url": "https://thanks.dev/u/gh/phpcsstandards", 185 | "type": "thanks_dev" 186 | } 187 | ], 188 | "time": "2025-11-10T16:43:36+00:00" 189 | } 190 | ], 191 | "aliases": [], 192 | "minimum-stability": "stable", 193 | "stability-flags": {}, 194 | "prefer-stable": false, 195 | "prefer-lowest": false, 196 | "platform": { 197 | "php": ">=8.1", 198 | "ext-ctype": "*", 199 | "ext-curl": "*", 200 | "ext-dom": "*", 201 | "ext-fileinfo": "*", 202 | "ext-gmp": "*", 203 | "ext-intl": "*", 204 | "ext-json": "*", 205 | "ext-libxml": "*", 206 | "ext-mbstring": "*", 207 | "ext-openssl": "*", 208 | "ext-pcre": "*", 209 | "ext-pdo": "*", 210 | "ext-pdo_sqlite": "*", 211 | "ext-session": "*", 212 | "ext-simplexml": "*", 213 | "ext-xml": "*", 214 | "ext-xmlreader": "*", 215 | "ext-zend-opcache": "*", 216 | "ext-zip": "*", 217 | "ext-zlib": "*" 218 | }, 219 | "platform-dev": { 220 | "php": ">=8.1", 221 | "ext-phar": "*", 222 | "ext-tokenizer": "*", 223 | "ext-xmlwriter": "*" 224 | }, 225 | "plugin-api-version": "2.6.0" 226 | } 227 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FreshRSS extensions 2 | 3 | This repository contains all the official [FreshRSS](https://github.com/FreshRSS/FreshRSS) extensions. 4 | 5 | To install an extension, git clone or download [the extension archive](https://github.com/FreshRSS/Extensions/archive/refs/heads/main.zip) and extract it on your computer. 6 | Then, upload the specific extension(s) you want on your server. 7 | Extensions must be in the `./extensions` directory of your FreshRSS installation. 8 | 9 | ## Commands for developers 10 | 11 | ```sh 12 | # Test this repository and its extensions 13 | make test-all 14 | 15 | # Test compatibility between `../FreshRSS/` core and all known extensions from `./repositories.json` 16 | ./generate.php 17 | composer run-script phpstan-third-party 18 | ``` 19 | 20 | ## Core extensions 21 | 22 | *Custom CSS* and *Custom JS* are now a part of [core extensions shipped with FreshRSS](https://github.com/FreshRSS/FreshRSS/tree/edge/lib/core-extensions). 23 | 24 | ## Third-party extensions 25 | 26 | There are some FreshRSS extensions out there, developed by community members: 27 | 28 | ### By [@kevinpapst](https://github.com/kevinpapst), [Web](https://www.kevinpapst.de/) 29 | 30 | * [Youtube](xExtension-YouTube) shows YouTube videos inline in the feed 31 | 32 | ### By [@oYoX](https://github.com/oyox), [Web](https://oyox.de/) 33 | 34 | * [Keep Folder State](https://github.com/oyox/FreshRSS-extensions/tree/master/xExtension-KeepFolderState): Stores the state of the folders locally and expand them automatically if necessary. 35 | * [Fixed Nav Menu](https://github.com/oyox/FreshRSS-extensions/tree/master/xExtension-FixedNavMenu): (desktop) Sets the position of the navigation menu to fixed when scrolling down. 36 | * [Mobile Scroll Menu](https://github.com/oyox/FreshRSS-extensions/tree/master/xExtension-MobileScrollMenu): (mobile) Automatically hides the header menu when scrolling down and shows it when scrolling up. 37 | * [Touch Control](https://github.com/oyox/FreshRSS-extensions/tree/master/xExtension-TouchControl): (mobile) Add touch gestures to FreshRSS. 38 | 39 | 40 | ### By [@Eisa01](https://github.com/Eisa01) 41 | 42 | * [FreshRSS Auto Refresh](https://github.com/Eisa01/FreshRSS---Auto-Refresh-Extension): Automatically refreshes FreshRSS page once in a minute. 43 | 44 | 45 | ### By [@aledeg](https://github.com/aledeg) 46 | 47 | * [Date Format](https://github.com/aledeg/xExtension-DateFormat): Change how dates are displayed in the interface 48 | * [Latex Support](https://github.com/aledeg/xExtension-LatexSupport): Add support for LaTeX notation rendering 49 | * [Paywall](https://github.com/aledeg/xExtension-Paywall): Add title prefix on articles behind a paywall 50 | * [Reddit Image](https://github.com/aledeg/xExtension-RedditImage): Replace link to Reddit topic with resource link 51 | 52 | 53 | ### By [Nicolas Frandeboeuf](https://framagit.org/nicofrand) 54 | 55 | * [ThreePanesView](https://framagit.org/nicofrand/xextension-threepanesview): [Adds a third vertical pane along the articles list, to display the articles content](https://nicofrand.eu/freshrss-extension-threepanesview/). 56 | * [TogglableMenu](https://framagit.org/nicofrand/xextension-togglablemenu): Makes the menu always togglable, even on larger screens. 57 | 58 | 59 | ### By [@Lapineige](https://github.com/lapineige), [@hkcomori](https://github.com/hkcomori) 60 | 61 | * [Reading Time](https://github.com/FreshRSS/Extensions/tree/main/xExtension-ReadingTime): Add a reading time estimation next to each article. 62 | 63 | 64 | ### By [@Korbak](https://github.com/Korbak) 65 | 66 | * [Invidious](https://github.com/Korbak/freshrss-invidious): Displays videos from YouTube feeds inline and replaces every source by the Invidious instance of your choice for an enhanced privacy (no tracking or limitation) 67 | 68 | ### By [@CN-Tools](https://github.com/cn-tools) 69 | 70 | * [Black List](https://github.com/cn-tools/cntools_FreshRssExtensions/tree/master/xExtension-BlackList): Blacklist to block feeds for users 71 | * [Copy 2 Clipboard](https://github.com/cn-tools/cntools_FreshRssExtensions/tree/master/xExtension-Copy2Clipboard): Add a button in the navigation bar to copy the destination links of all visible entries into clipboard 72 | * [Feed Title Builder](https://github.com/cn-tools/cntools_FreshRssExtensions/tree/master/xExtension-FeedTitleBuilder): Build your own feed title based on url, the original feed title and the date the feed was added 73 | * [FilterTitle](https://github.com/cn-tools/cntools_FreshRssExtensions/tree/master/xExtension-FilterTitle): Filter out feed entries by keywords parsed by the feed entry title 74 | * [RemoveEmojis](https://github.com/cn-tools/cntools_FreshRssExtensions/tree/master/xExtension-RemoveEmojis): Remove emojis in the title of newly added feed entries 75 | * [SendToMyJD2](https://github.com/cn-tools/cntools_FreshRssExtensions/tree/master/xExtension-SendToMyJD2): Send links to a jDownloader2 instance with the myJDownloader2 API 76 | * [YouTube Channel 2 RSSFeed](https://github.com/cn-tools/cntools_FreshRssExtensions/tree/master/xExtension-YouTubeChannel2RssFeed): You can add a YouTube Channel URL and will get it as RSSFeed. Additional you can detect YouTube shorts. 77 | 78 | ### By [@DevonHess](https://github.com/DevonHess) 79 | 80 | * [RSS-Bridge](https://github.com/DevonHess/FreshRSS-Extensions/tree/main/xExtension-RssBridge): Run URLs through [RSS-Bridge](https://github.com/rss-bridge/rss-bridge) detection 81 | 82 | ### By [@Kapdap](https://github.com/Kapdap) 83 | 84 | * [Clickable Links](https://github.com/kapdap/freshrss-extensions/tree/master/xExtension-ClickableLinks): Replaces non-clickable plain text URLs found in articles with clickable HTML links 85 | 86 | ### By [@dohseven](https://framagit.org/dohseven) 87 | 88 | * [Explosm](https://framagit.org/dohseven/freshrss-explosm): Directly displays the Explosm comic in FreshRSS 89 | 90 | ### By [@ImAReplicant](https://framagit.org/ImAReplicant) 91 | 92 | * [Youtube/Peertube](https://framagit.org/ImAReplicant/freshrss-youtube): Display videos from YouTube/PeerTube feeds inline 93 | 94 | ### By [@christian-putzke](https://github.com/christian-putzke/) 95 | 96 | * [Pocket Button](https://github.com/christian-putzke/freshrss-pocket-button): Add articles to Pocket with one simple button click or a keyboard shortcut. 97 | 98 | ### By [@huffstler](https://github.com/huffstler) 99 | 100 | * [Star To Pocket](https://github.com/huffstler/xExtension-StarToPocket): Like the extension above, but sends articles to Pocket when they are starred. Also works with FreshRSS client applications! 101 | 102 | ### By [@Joedmin](https://github.com/Joedmin/) 103 | 104 | * [Readeck Button](https://github.com/Joedmin/xExtension-readeck-button): Add articles to a selected Readeck instance with one simple button click or a keyboard shortcut. 105 | * [Wallabag Button](https://github.com/Joedmin/xExtension-wallabag-button): Add articles to a selected Wallabag instance with one simple button click or a keyboard shortcut. 106 | 107 | ### By [@printfuck](https://github.com/printfuck/) 108 | 109 | * [Readable](https://github.com/printfuck/xExtension-Readable): Fetch article content for selected feeds with [Readability](https://github.com/mozilla/readability) or [Mercury](https://github.com/postlight/mercury-parser) 110 | 111 | ### By [@Victrid](https://github.com/Victrid/) 112 | 113 | * [Image Cache](https://github.com/Victrid/freshrss-image-cache-plugin): Cache feed images on your own facility or Cloudflare cache. 114 | 115 | ### By [@aidistan](https://github.com/aidistan) 116 | 117 | * [FeedPriorityShortcut](https://github.com/aidistan/freshrss-extensions#feed-priority-shortcut): Quick setter for your feed priorities. 118 | * [ThemeModeSynchronizer](https://github.com/aidistan/freshrss-extensions#theme-mode-synchronizer): Synchronize the theme with your system light/dark mode. 119 | 120 | ### By [@balthisar](https://github.com/balthisar) 121 | 122 | * [RedditSub](https://github.com/balthisar/xExtension-RedditSub): A FreshRSS Extension to Show a Reddit Subreddit as Part of the Article Title. 123 | 124 | ### By [@mgnsk](https://github.com/mgnsk) 125 | 126 | * [AutoTTL](https://github.com/mgnsk/FreshRSS-AutoTTL): A FreshRSS extension for automatic feed refresh TTL based on the average frequency of entries. 127 | 128 | ### By [@giventofly](https://github.com/giventofly) 129 | 130 | * [Comics In Feed](https://github.com/giventofly/freshrss-comicsinfeed): Display comicss directly in FreshRSS (currently for The awkward yeti and Butter Safe). 131 | 132 | ### By [@rudism](https://code.sitosis.com/rudism) 133 | 134 | * [Kagi Summarizer](https://code.sitosis.com/rudism/freshrss-kagi-summarizer): Adds a "Summarize" button to the top of all entries that will fetch the summary of the entry using the [Kagi Universal Summarizer](https://kagi.com/summarizer/index.html). 135 | 136 | ### By [@shinemoon](https://github.com/shinemoon) 137 | 138 | * [Colorful List](https://github.com/shinemoon/FreshRSS-Dev/tree/master/extensions/xExtension-ColorfulList): Generate light different background color for article list rows (relying on the feed name) 139 | 140 | ### By [@babico](https://github.com/babico) 141 | 142 | * [Twitch Channel 2 Rss Feed](https://github.com/babico/xExtension-TwitchChannel2RssFeed): You can add a Twitch Channel URL and will get it as RSSFeed 143 | 144 | ### By [@ravenscroftj](https://github.com/ravenscroftj) 145 | 146 | * [FreshRss FlareSolverr](https://github.com/ravenscroftj/freshrss-flaresolverr-extension): Use a Flaresolverr instance to bypass cloudflare security checks 147 | 148 | ### By [@tunbridgep](https://github.com/tunbridgep) 149 | 150 | * [Invidious Video Feed](https://github.com/tunbridgep/freshrss-invidious/tree/master/xExtension-Invidious): Embed YouTube feeds inside article content, but with Invidious. 151 | 152 | ### By [@jacob2826](https://github.com/jacob2826) 153 | 154 | * [TranslateTitlesCN](https://github.com/jacob2826/FreshRSS-TranslateTitlesCN): Translate article titles of the specified feed into Chinese, using [DeepLX](https://github.com/OwO-Network/DeepLX) or Google Translate. 155 | 156 | ### By [@kalvn](https://github.com/kalvn) 157 | 158 | * [Mark Previous as Read](https://github.com/kalvn/freshrss-mark-previous-as-read): Adds a button in the footer of each entry. Clicking this button will mark all previous entries belonging to the current feed, as read. 159 | 160 | ### By [@lukasMega](https://github.com/lukasMega) 161 | 162 | * [Word Highlighter](https://github.com/lukasMega/Extensions-FreshRSS-): Gives you ability to highlight user-defined words (using [mark.js](https://github.com/julkue/mark.js)) 163 | 164 | ### By [@LiangWei88](https://github.com/LiangWei88) 165 | 166 | * [ArticleSummary](https://github.com/LiangWei88/xExtension-ArticleSummary): A powerful article summarization plugin for FreshRSS that allows users to generate summaries using a language model API conforming to the OpenAI API specification. 167 | 168 | ### By [@Niehztog](https://github.com/Niehztog) 169 | 170 | * [Article Full Text](https://github.com/Niehztog/freshrss-af-readability): Fetches full article contents and adds them to feed items by using [Fivefilters Readability.php library](https://github.com/fivefilters/readability.php) (no docker containers required). 171 | 172 | ### By [@tryallthethings](https://github.com/tryallthethings) 173 | 174 | * [FreshVibes](https://github.com/tryallthethings/freshvibes): A fully customizable iGoogle / Netvibes-like dashboard view 175 | 176 | ### By [@pe1uca](https://github.com/pe1uca) 177 | 178 | * [Rate limiter](https://github.com/pe1uca/xExtension-RateLimiter/): Prevents FreshRSS from making too many requests to the same site in a defined amount of time. 179 | 180 | ### By [@daften](https://github.com/daften) 181 | 182 | * [Share To Linkwarden](https://github.com/daften/xExtension-ShareToLinkwarden/): Allows sharing items directly to a self-hosted Linkwarden instance. 183 | 184 | ### By [@fengchang](https://github.com/fengchang) 185 | 186 | * [Feed Digest](https://github.com/fengchang/xExtension-FeedDigest): Automatically summarize RSS articles using OpenAI-compatible LLM APIs. 187 | 188 | ### By [@veverkap](https://github.com/veverkap/) 189 | 190 | * [Karakeep Button](https://github.com/veverkap/xExtension-karakeep-button): Add articles to a selected Karakeep instance with one simple button click or a keyboard shortcut. 191 | -------------------------------------------------------------------------------- /xExtension-WordHighlighter/static/mark.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * mark.js (Library for highlighting words) 3 | * https://github.com/julkue/mark.js/blob/master/dist/mark.es6.min.js 4 | */ 5 | /* eslint-disable */ 6 | 7 | /*!*************************************************** 8 | * mark.js v9.0.0 9 | * https://markjs.io/ 10 | * Copyright (c) 2014–2018, Julian Kühnel 11 | * Released under the MIT license https://git.io/vwTVl 12 | *****************************************************/ 13 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.Mark=t()}(this,function(){"use strict";class e{constructor(e,t=!0,s=[],r=5e3){this.ctx=e,this.iframes=t,this.exclude=s,this.iframesTimeout=r}static matches(e,t){const s="string"==typeof t?[t]:t,r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.oMatchesSelector||e.webkitMatchesSelector;if(r){let t=!1;return s.every(s=>!r.call(e,s)||(t=!0,!1)),t}return!1}getContexts(){let e,t=[];return(e=void 0!==this.ctx&&this.ctx?NodeList.prototype.isPrototypeOf(this.ctx)?Array.prototype.slice.call(this.ctx):Array.isArray(this.ctx)?this.ctx:"string"==typeof this.ctx?Array.prototype.slice.call(document.querySelectorAll(this.ctx)):[this.ctx]:[]).forEach(e=>{const s=t.filter(t=>t.contains(e)).length>0;-1!==t.indexOf(e)||s||t.push(e)}),t}getIframeContents(e,t,s=(()=>{})){let r;try{const t=e.contentWindow;if(r=t.document,!t||!r)throw new Error("iframe inaccessible")}catch(e){s()}r&&t(r)}isIframeBlank(e){const t="about:blank",s=e.getAttribute("src").trim();return e.contentWindow.location.href===t&&s!==t&&s}observeIframeLoad(e,t,s){let r=!1,i=null;const n=()=>{if(!r){r=!0,clearTimeout(i);try{this.isIframeBlank(e)||(e.removeEventListener("load",n),this.getIframeContents(e,t,s))}catch(e){s()}}};e.addEventListener("load",n),i=setTimeout(n,this.iframesTimeout)}onIframeReady(e,t,s){try{"complete"===e.contentWindow.document.readyState?this.isIframeBlank(e)?this.observeIframeLoad(e,t,s):this.getIframeContents(e,t,s):this.observeIframeLoad(e,t,s)}catch(e){s()}}waitForIframes(e,t){let s=0;this.forEachIframe(e,()=>!0,e=>{s++,this.waitForIframes(e.querySelector("html"),()=>{--s||t()})},e=>{e||t()})}forEachIframe(t,s,r,i=(()=>{})){let n=t.querySelectorAll("iframe"),o=n.length,a=0;n=Array.prototype.slice.call(n);const h=()=>{--o<=0&&i(a)};o||h(),n.forEach(t=>{e.matches(t,this.exclude)?h():this.onIframeReady(t,e=>{s(t)&&(a++,r(e)),h()},h)})}createIterator(e,t,s){return document.createNodeIterator(e,t,s,!1)}createInstanceOnIframe(t){return new e(t.querySelector("html"),this.iframes)}compareNodeIframe(e,t,s){if(e.compareDocumentPosition(s)&Node.DOCUMENT_POSITION_PRECEDING){if(null===t)return!0;if(t.compareDocumentPosition(s)&Node.DOCUMENT_POSITION_FOLLOWING)return!0}return!1}getIteratorNode(e){const t=e.previousNode();let s;return{prevNode:t,node:s=null===t?e.nextNode():e.nextNode()&&e.nextNode()}}checkIframeFilter(e,t,s,r){let i=!1,n=!1;return r.forEach((e,t)=>{e.val===s&&(i=t,n=e.handled)}),this.compareNodeIframe(e,t,s)?(!1!==i||n?!1===i||n||(r[i].handled=!0):r.push({val:s,handled:!0}),!0):(!1===i&&r.push({val:s,handled:!1}),!1)}handleOpenIframes(e,t,s,r){e.forEach(e=>{e.handled||this.getIframeContents(e.val,e=>{this.createInstanceOnIframe(e).forEachNode(t,s,r)})})}iterateThroughNodes(e,t,s,r,i){const n=this.createIterator(t,e,r);let o,a,h=[],c=[],l=()=>(({prevNode:a,node:o}=this.getIteratorNode(n)),o);for(;l();)this.iframes&&this.forEachIframe(t,e=>this.checkIframeFilter(o,a,e,h),t=>{this.createInstanceOnIframe(t).forEachNode(e,e=>c.push(e),r)}),c.push(o);c.forEach(e=>{s(e)}),this.iframes&&this.handleOpenIframes(h,e,s,r),i()}forEachNode(e,t,s,r=(()=>{})){const i=this.getContexts();let n=i.length;n||r(),i.forEach(i=>{const o=()=>{this.iterateThroughNodes(e,i,t,s,()=>{--n<=0&&r()})};this.iframes?this.waitForIframes(i,o):o()})}}class t{constructor(e){this.opt=Object.assign({},{diacritics:!0,synonyms:{},accuracy:"partially",caseSensitive:!1,ignoreJoiners:!1,ignorePunctuation:[],wildcards:"disabled"},e)}create(e){return"disabled"!==this.opt.wildcards&&(e=this.setupWildcardsRegExp(e)),e=this.escapeStr(e),Object.keys(this.opt.synonyms).length&&(e=this.createSynonymsRegExp(e)),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),this.opt.diacritics&&(e=this.createDiacriticsRegExp(e)),e=this.createMergedBlanksRegExp(e),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.createJoinersRegExp(e)),"disabled"!==this.opt.wildcards&&(e=this.createWildcardsRegExp(e)),e=this.createAccuracyRegExp(e),new RegExp(e,`gm${this.opt.caseSensitive?"":"i"}`)}sortByLength(e){return e.sort((e,t)=>e.length===t.length?e>t?1:-1:t.length-e.length)}escapeStr(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}createSynonymsRegExp(e){const t=this.opt.synonyms,s=this.opt.caseSensitive?"":"i",r=this.opt.ignoreJoiners||this.opt.ignorePunctuation.length?"\0":"";for(let i in t)if(t.hasOwnProperty(i)){let n=Array.isArray(t[i])?t[i]:[t[i]];n.unshift(i),(n=this.sortByLength(n).map(e=>("disabled"!==this.opt.wildcards&&(e=this.setupWildcardsRegExp(e)),e=this.escapeStr(e))).filter(e=>""!==e)).length>1&&(e=e.replace(new RegExp(`(${n.map(e=>this.escapeStr(e)).join("|")})`,`gm${s}`),r+`(${n.map(e=>this.processSynonyms(e)).join("|")})`+r))}return e}processSynonyms(e){return(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),e}setupWildcardsRegExp(e){return(e=e.replace(/(?:\\)*\?/g,e=>"\\"===e.charAt(0)?"?":"")).replace(/(?:\\)*\*/g,e=>"\\"===e.charAt(0)?"*":"")}createWildcardsRegExp(e){let t="withSpaces"===this.opt.wildcards;return e.replace(/\u0001/g,t?"[\\S\\s]?":"\\S?").replace(/\u0002/g,t?"[\\S\\s]*?":"\\S*")}setupIgnoreJoinersRegExp(e){return e.replace(/[^(|)\\]/g,(e,t,s)=>{let r=s.charAt(t+1);return/[(|)\\]/.test(r)||""===r?e:e+"\0"})}createJoinersRegExp(e){let t=[];const s=this.opt.ignorePunctuation;return Array.isArray(s)&&s.length&&t.push(this.escapeStr(s.join(""))),this.opt.ignoreJoiners&&t.push("\\u00ad\\u200b\\u200c\\u200d"),t.length?e.split(/\u0000+/).join(`[${t.join("")}]*`):e}createDiacriticsRegExp(e){const t=this.opt.caseSensitive?"":"i",s=this.opt.caseSensitive?["aàáảãạăằắẳẵặâầấẩẫậäåāą","AÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćč","CÇĆČ","dđď","DĐĎ","eèéẻẽẹêềếểễệëěēę","EÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïī","IÌÍỈĨỊÎÏĪ","lł","LŁ","nñňń","NÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøō","OÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rř","RŘ","sšśșş","SŠŚȘŞ","tťțţ","TŤȚŢ","uùúủũụưừứửữựûüůū","UÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿ","YÝỲỶỸỴŸ","zžżź","ZŽŻŹ"]:["aàáảãạăằắẳẵặâầấẩẫậäåāąAÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćčCÇĆČ","dđďDĐĎ","eèéẻẽẹêềếểễệëěēęEÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïīIÌÍỈĨỊÎÏĪ","lłLŁ","nñňńNÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøōOÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rřRŘ","sšśșşSŠŚȘŞ","tťțţTŤȚŢ","uùúủũụưừứửữựûüůūUÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿYÝỲỶỸỴŸ","zžżźZŽŻŹ"];let r=[];return e.split("").forEach(i=>{s.every(s=>{if(-1!==s.indexOf(i)){if(r.indexOf(s)>-1)return!1;e=e.replace(new RegExp(`[${s}]`,`gm${t}`),`[${s}]`),r.push(s)}return!0})}),e}createMergedBlanksRegExp(e){return e.replace(/[\s]+/gim,"[\\s]+")}createAccuracyRegExp(e){let t=this.opt.accuracy,s="string"==typeof t?t:t.value,r="string"==typeof t?[]:t.limiters,i="";switch(r.forEach(e=>{i+=`|${this.escapeStr(e)}`}),s){case"partially":default:return`()(${e})`;case"complementary":return`()([^${i="\\s"+(i||this.escapeStr("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~¡¿"))}]*${e}[^${i}]*)`;case"exactly":return`(^|\\s${i})(${e})(?=$|\\s${i})`}}}class s{constructor(e){this.ctx=e,this.ie=!1;const t=window.navigator.userAgent;(t.indexOf("MSIE")>-1||t.indexOf("Trident")>-1)&&(this.ie=!0)}set opt(e){this._opt=Object.assign({},{element:"",className:"",exclude:[],iframes:!1,iframesTimeout:5e3,separateWordSearch:!0,acrossElements:!1,ignoreGroups:0,each:()=>{},noMatch:()=>{},filter:()=>!0,done:()=>{},debug:!1,log:window.console},e)}get opt(){return this._opt}get iterator(){return new e(this.ctx,this.opt.iframes,this.opt.exclude,this.opt.iframesTimeout)}log(e,t="debug"){const s=this.opt.log;this.opt.debug&&"object"==typeof s&&"function"==typeof s[t]&&s[t](`mark.js: ${e}`)}getSeparatedKeywords(e){let t=[];return e.forEach(e=>{this.opt.separateWordSearch?e.split(" ").forEach(e=>{e.trim()&&-1===t.indexOf(e)&&t.push(e)}):e.trim()&&-1===t.indexOf(e)&&t.push(e)}),{keywords:t.sort((e,t)=>t.length-e.length),length:t.length}}isNumeric(e){return Number(parseFloat(e))==e}checkRanges(e){if(!Array.isArray(e)||"[object Object]"!==Object.prototype.toString.call(e[0]))return this.log("markRanges() will only accept an array of objects"),this.opt.noMatch(e),[];const t=[];let s=0;return e.sort((e,t)=>e.start-t.start).forEach(e=>{let{start:r,end:i,valid:n}=this.callNoMatchOnInvalidRanges(e,s);n&&(e.start=r,e.length=i-r,t.push(e),s=i)}),t}callNoMatchOnInvalidRanges(e,t){let s,r,i=!1;return e&&void 0!==e.start?(r=(s=parseInt(e.start,10))+parseInt(e.length,10),this.isNumeric(e.start)&&this.isNumeric(e.length)&&r-t>0&&r-s>0?i=!0:(this.log("Ignoring invalid or overlapping range: "+`${JSON.stringify(e)}`),this.opt.noMatch(e))):(this.log(`Ignoring invalid range: ${JSON.stringify(e)}`),this.opt.noMatch(e)),{start:s,end:r,valid:i}}checkWhitespaceRanges(e,t,s){let r,i=!0,n=s.length,o=t-n,a=parseInt(e.start,10)-o;return(r=(a=a>n?n:a)+parseInt(e.length,10))>n&&(r=n,this.log(`End range automatically set to the max value of ${n}`)),a<0||r-a<0||a>n||r>n?(i=!1,this.log(`Invalid range: ${JSON.stringify(e)}`),this.opt.noMatch(e)):""===s.substring(a,r).replace(/\s+/g,"")&&(i=!1,this.log("Skipping whitespace only range: "+JSON.stringify(e)),this.opt.noMatch(e)),{start:a,end:r,valid:i}}getTextNodes(e){let t="",s=[];this.iterator.forEachNode(NodeFilter.SHOW_TEXT,e=>{s.push({start:t.length,end:(t+=e.textContent).length,node:e})},e=>this.matchesExclude(e.parentNode)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT,()=>{e({value:t,nodes:s})})}matchesExclude(t){return e.matches(t,this.opt.exclude.concat(["script","style","title","head","html"]))}wrapRangeInTextNode(e,t,s){const r=this.opt.element?this.opt.element:"mark",i=e.splitText(t),n=i.splitText(s-t);let o=document.createElement(r);return o.setAttribute("data-markjs","true"),this.opt.className&&o.setAttribute("class",this.opt.className),o.textContent=i.textContent,i.parentNode.replaceChild(o,i),n}wrapRangeInMappedTextNode(e,t,s,r,i){e.nodes.every((n,o)=>{const a=e.nodes[o+1];if(void 0===a||a.start>t){if(!r(n.node))return!1;const a=t-n.start,h=(s>n.end?n.end:s)-n.start,c=e.value.substr(0,n.start),l=e.value.substr(h+n.start);if(n.node=this.wrapRangeInTextNode(n.node,a,h),e.value=c+l,e.nodes.forEach((t,s)=>{s>=o&&(e.nodes[s].start>0&&s!==o&&(e.nodes[s].start-=h),e.nodes[s].end-=h)}),s-=h,i(n.node.previousSibling,n.start),!(s>n.end))return!1;t=n.end}return!0})}wrapGroups(e,t,s,r){return r((e=this.wrapRangeInTextNode(e,t,t+s)).previousSibling),e}separateGroups(e,t,s,r,i){let n=t.length;for(let s=1;s-1&&r(t[s],e)&&(e=this.wrapGroups(e,n,t[s].length,i))}return e}wrapMatches(e,t,s,r,i){const n=0===t?0:t+1;this.getTextNodes(t=>{t.nodes.forEach(t=>{let i;for(t=t.node;null!==(i=e.exec(t.textContent))&&""!==i[n];){if(this.opt.separateGroups)t=this.separateGroups(t,i,n,s,r);else{if(!s(i[n],t))continue;let e=i.index;if(0!==n)for(let t=1;t{let o;for(;null!==(o=e.exec(t.value))&&""!==o[n];){let i=o.index;if(0!==n)for(let e=1;es(o[n],e),(t,s)=>{e.lastIndex=s,r(t)})}i()})}wrapRangeFromIndex(e,t,s,r){this.getTextNodes(i=>{const n=i.value.length;e.forEach((e,r)=>{let{start:o,end:a,valid:h}=this.checkWhitespaceRanges(e,n,i.value);h&&this.wrapRangeInMappedTextNode(i,o,a,s=>t(s,e,i.value.substring(o,a),r),t=>{s(t,e)})}),r()})}unwrapMatches(e){const t=e.parentNode;let s=document.createDocumentFragment();for(;e.firstChild;)s.appendChild(e.removeChild(e.firstChild));t.replaceChild(s,e),this.ie?this.normalizeTextNode(t):t.normalize()}normalizeTextNode(e){if(e){if(3===e.nodeType)for(;e.nextSibling&&3===e.nextSibling.nodeType;)e.nodeValue+=e.nextSibling.nodeValue,e.parentNode.removeChild(e.nextSibling);else this.normalizeTextNode(e.firstChild);this.normalizeTextNode(e.nextSibling)}}markRegExp(e,t){this.opt=t,this.log(`Searching with expression "${e}"`);let s=0,r="wrapMatches";this.opt.acrossElements&&(r="wrapMatchesAcrossElements"),this[r](e,this.opt.ignoreGroups,(e,t)=>this.opt.filter(t,e,s),e=>{s++,this.opt.each(e)},()=>{0===s&&this.opt.noMatch(e),this.opt.done(s)})}mark(e,s){this.opt=s;let r=0,i="wrapMatches";const{keywords:n,length:o}=this.getSeparatedKeywords("string"==typeof e?[e]:e),a=e=>{const s=new t(this.opt).create(e);let h=0;this.log(`Searching with expression "${s}"`),this[i](s,1,(t,s)=>this.opt.filter(s,e,r,h),e=>{h++,r++,this.opt.each(e)},()=>{0===h&&this.opt.noMatch(e),n[o-1]===e?this.opt.done(r):a(n[n.indexOf(e)+1])})};this.opt.acrossElements&&(i="wrapMatchesAcrossElements"),0===o?this.opt.done(r):a(n[0])}markRanges(e,t){this.opt=t;let s=0,r=this.checkRanges(e);r&&r.length?(this.log("Starting to mark with the following ranges: "+JSON.stringify(r)),this.wrapRangeFromIndex(r,(e,t,s,r)=>this.opt.filter(e,t,s,r),(e,t)=>{s++,this.opt.each(e,t)},()=>{this.opt.done(s)})):this.opt.done(s)}unmark(t){this.opt=t;let s=this.opt.element?this.opt.element:"*";s+="[data-markjs]",this.opt.className&&(s+=`.${this.opt.className}`),this.log(`Removal selector "${s}"`),this.iterator.forEachNode(NodeFilter.SHOW_ELEMENT,e=>{this.unwrapMatches(e)},t=>{const r=e.matches(t,s),i=this.matchesExclude(t);return!r||i?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT},this.opt.done)}}return function(e){const t=new s(e);return this.mark=((e,s)=>(t.mark(e,s),this)),this.markRegExp=((e,s)=>(t.markRegExp(e,s),this)),this.markRanges=((e,s)=>(t.markRanges(e,s),this)),this.unmark=(e=>(t.unmark(e),this)),this}}); 14 | -------------------------------------------------------------------------------- /xExtension-YouTube/extension.php: -------------------------------------------------------------------------------- 1 | registerHook('entry_before_display', [$this, 'embedYouTubeVideo']); 39 | $this->registerHook('check_url_before_add', [self::class, 'convertYoutubeFeedUrl']); 40 | $this->registerHook('custom_favicon_hash', [$this, 'iconHashParams']); 41 | $this->registerHook('custom_favicon_btn_url', [$this, 'iconBtnUrl']); 42 | $this->registerHook('feed_before_insert', [$this, 'feedBeforeInsert']); 43 | if (Minz_Request::controllerName() === 'extension') { 44 | $this->registerHook('js_vars', [self::class, 'jsVars']); 45 | Minz_View::appendScript($this->getFileUrl('fetchIcons.js')); 46 | } 47 | $this->registerTranslates(); 48 | } 49 | 50 | /** 51 | * @param array $vars 52 | * @return array 53 | */ 54 | public static function jsVars(array $vars): array { 55 | $vars['yt_i18n'] = [ 56 | 'fetching_icons' => _t('ext.yt_videos.fetching_icons'), 57 | ]; 58 | return $vars; 59 | } 60 | 61 | public function isYtFeed(string $website): bool { 62 | return str_starts_with($website, 'https://www.youtube.com/'); 63 | } 64 | 65 | public function isShort(string $website): bool { 66 | return str_starts_with($website, 'https://www.youtube.com/shorts'); 67 | } 68 | public function convertShortToWatch(string $shortUrl): string { 69 | $prefix = 'https://www.youtube.com/shorts/'; 70 | 71 | if (str_starts_with($shortUrl, $prefix)) { 72 | $videoId = str_replace($prefix, '', $shortUrl); 73 | return 'https://www.youtube.com/watch?v=' . $videoId; 74 | } 75 | 76 | return $shortUrl; 77 | } 78 | 79 | public function iconBtnUrl(FreshRSS_Feed $feed): ?string { 80 | if (!$this->isYtFeed($feed->website()) || $feed->attributeString('customFaviconExt') === $this->getName()) { 81 | return null; 82 | } 83 | return _url('extension', 'configure', 'e', urlencode($this->getName())); 84 | } 85 | 86 | public function iconHashParams(FreshRSS_Feed $feed): ?string { 87 | if ($feed->customFaviconExt() !== $this->getName()) { 88 | return null; 89 | } 90 | return 'yt' . $feed->website() . $feed->proxyParam(); 91 | } 92 | 93 | /** 94 | * @throws Minz_PDOConnectionException 95 | * @throws Minz_ConfigurationNamespaceException 96 | */ 97 | public function ajaxGetYtFeeds(): void { 98 | $feedDAO = FreshRSS_Factory::createFeedDao(); 99 | $ids = $feedDAO->listFeedsIds(); 100 | 101 | $feeds = []; 102 | 103 | foreach ($ids as $feedId) { 104 | $feed = $feedDAO->searchById($feedId); 105 | if ($feed === null) { 106 | continue; 107 | } 108 | if ($this->isYtFeed($feed->website())) { 109 | $feeds[] = [ 110 | 'id' => $feed->id(), 111 | 'title' => $feed->name(true), 112 | ]; 113 | } 114 | } 115 | 116 | header('Content-Type: application/json; charset=UTF-8'); 117 | exit(json_encode($feeds)); 118 | } 119 | 120 | /** 121 | * @throws Minz_PDOConnectionException 122 | * @throws Minz_ConfigurationNamespaceException 123 | * @throws Minz_PermissionDeniedException 124 | * @throws FreshRSS_UnsupportedImageFormat_Exception 125 | * @throws FreshRSS_Context_Exception 126 | */ 127 | public function ajaxFetchIcon(): void { 128 | $feedDAO = FreshRSS_Factory::createFeedDao(); 129 | 130 | $feed = $feedDAO->searchById(Minz_Request::paramInt('id')); 131 | if ($feed === null) { 132 | Minz_Error::error(404); 133 | return; 134 | } 135 | $this->setIconForFeed($feed, setValues: true); 136 | 137 | exit('OK'); 138 | } 139 | 140 | /** 141 | * @throws Minz_PDOConnectionException 142 | * @throws Minz_ConfigurationNamespaceException 143 | * @throws Minz_PermissionDeniedException 144 | */ 145 | public function resetAllIcons(): void { 146 | $feedDAO = FreshRSS_Factory::createFeedDao(); 147 | $ids = $feedDAO->listFeedsIds(); 148 | 149 | foreach ($ids as $feedId) { 150 | $feed = $feedDAO->searchById($feedId); 151 | if ($feed === null) { 152 | continue; 153 | } 154 | if ($feed->customFaviconExt() === $this->getName()) { 155 | $v = []; 156 | try { 157 | $feed->resetCustomFavicon(values: $v); 158 | } catch (FreshRSS_Feed_Exception $_) { 159 | $this->warnLog('Failed to reset favicon for feed “' . $feed->name(true) . '”: feed error!'); 160 | } 161 | } 162 | } 163 | } 164 | 165 | /** 166 | * @throws Minz_PermissionDeniedException 167 | */ 168 | public function warnLog(string $s): void { 169 | Minz_Log::warning('[' . $this->getName() . '] ' . $s); 170 | } 171 | /** 172 | * @throws Minz_PermissionDeniedException 173 | */ 174 | public function debugLog(string $s): void { 175 | Minz_Log::debug('[' . $this->getName() . '] ' . $s); 176 | } 177 | 178 | /** 179 | * @throws FreshRSS_Context_Exception 180 | * @throws Minz_PermissionDeniedException 181 | * @throws FreshRSS_UnsupportedImageFormat_Exception 182 | */ 183 | public function feedBeforeInsert(FreshRSS_Feed $feed): FreshRSS_Feed { 184 | $this->loadConfigValues(); 185 | 186 | if ($this->downloadIcons) { 187 | return $this->setIconForFeed($feed); 188 | } 189 | 190 | return $feed; 191 | } 192 | 193 | /** 194 | * @throws Minz_PermissionDeniedException 195 | * @throws FreshRSS_UnsupportedImageFormat_Exception 196 | * @throws FreshRSS_Context_Exception 197 | */ 198 | public function setIconForFeed(FreshRSS_Feed $feed, bool $setValues = false): FreshRSS_Feed { 199 | if (!$this->isYtFeed($feed->website())) { 200 | return $feed; 201 | } 202 | 203 | // Return early if the icon had already been downloaded before 204 | $v = $setValues ? [] : null; 205 | $oldAttributes = $feed->attributes(); 206 | try { 207 | $path = $feed->setCustomFavicon(extName: $this->getName(), disallowDelete: true, values: $v); 208 | if ($path === null) { 209 | $feed->_attributes($oldAttributes); 210 | return $feed; 211 | } elseif (file_exists($path)) { 212 | $this->debugLog('Icon had already been downloaded before for feed “' . $feed->name(true) . '”: returning early!'); 213 | return $feed; 214 | } 215 | } catch (FreshRSS_Feed_Exception $_) { 216 | $this->warnLog('Failed to set custom favicon for feed “' . $feed->name(true) . '”: feed error!'); 217 | $feed->_attributes($oldAttributes); 218 | return $feed; 219 | } 220 | 221 | $feed->_attributes($oldAttributes); 222 | $this->debugLog('downloading icon for feed “' . $feed->name(true) . '"'); 223 | 224 | $url = $feed->website(); 225 | /** @var array */ 226 | $curlOptions = $feed->attributeArray('curl_params') ?? []; 227 | 228 | $ch = curl_init(); 229 | if ($ch === false || $url === '') { 230 | return $feed; 231 | } 232 | 233 | curl_setopt_array($ch, [ 234 | CURLOPT_URL => $url, 235 | CURLOPT_USERAGENT => FRESHRSS_USERAGENT, 236 | CURLOPT_RETURNTRANSFER => true, 237 | CURLOPT_FOLLOWLOCATION => true, 238 | ]); 239 | curl_setopt_array($ch, FreshRSS_Context::systemConf()->curl_options); 240 | curl_setopt_array($ch, $curlOptions); 241 | 242 | $html = curl_exec($ch); 243 | 244 | $dom = new DOMDocument(); 245 | 246 | if (!is_string($html) || !@$dom->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) { 247 | $this->warnLog('Fail while downloading icon for feed “' . $feed->name(true) . '”: failed to load HTML!'); 248 | return $feed; 249 | } 250 | 251 | $xpath = new DOMXPath($dom); 252 | $metaElem = $xpath->query('//meta[@name="twitter:image"]'); 253 | 254 | if ($metaElem === false) { 255 | $this->warnLog('Fail while downloading icon for feed “' . $feed->name(true) . '”: icon URL couldn’t be found!'); 256 | return $feed; 257 | } 258 | $iconElem = $metaElem->item(0); 259 | 260 | if (!($iconElem instanceof DOMElement)) { 261 | $this->warnLog('Fail while downloading icon for feed “' . $feed->name(true) . '”: icon URL couldn’t be found!'); 262 | return $feed; 263 | } 264 | 265 | $iconUrl = $iconElem->getAttribute('content'); 266 | if ($iconUrl == '') { 267 | $this->warnLog('Fail while downloading icon for feed “' . $feed->name(true) . '”: icon URL is empty!'); 268 | return $feed; 269 | } 270 | 271 | curl_setopt($ch, CURLOPT_URL, $iconUrl); 272 | $contents = curl_exec($ch); 273 | if (!is_string($contents)) { 274 | $this->warnLog('Fail while downloading icon for feed “' . $feed->name(true) . '”: empty contents!'); 275 | return $feed; 276 | } 277 | 278 | try { 279 | $feed->setCustomFavicon($contents, extName: $this->getName(), disallowDelete: true, values: $v, overrideCustomIcon: true); 280 | } catch (FreshRSS_UnsupportedImageFormat_Exception $_) { 281 | $this->warnLog('Failed to set custom favicon for feed “' . $feed->name(true) . '”: unsupported image format!'); 282 | return $feed; 283 | } catch (FreshRSS_Feed_Exception $_) { 284 | $this->warnLog('Failed to set custom favicon for feed “' . $feed->name(true) . '”: feed error!'); 285 | return $feed; 286 | } 287 | 288 | return $feed; 289 | } 290 | 291 | public static function convertYoutubeFeedUrl(string $url): string { 292 | $matches = []; 293 | 294 | if (preg_match('#^https?://www\.youtube\.com/channel/([0-9a-zA-Z_-]{6,36})#', $url, $matches) === 1) { 295 | return 'https://www.youtube.com/feeds/videos.xml?channel_id=' . $matches[1]; 296 | } 297 | 298 | if (preg_match('#^https?://www\.youtube\.com/user/([0-9a-zA-Z_-]{6,36})#', $url, $matches) === 1) { 299 | return 'https://www.youtube.com/feeds/videos.xml?user=' . $matches[1]; 300 | } 301 | 302 | return $url; 303 | } 304 | 305 | /** 306 | * Initializes the extension configuration, if the user context is available. 307 | * Do not call that in your extensions init() method, it can't be used there. 308 | * @throws FreshRSS_Context_Exception 309 | */ 310 | public function loadConfigValues(): void { 311 | if (!class_exists('FreshRSS_Context', false) || !FreshRSS_Context::hasUserConf()) { 312 | return; 313 | } 314 | 315 | $width = FreshRSS_Context::userConf()->attributeInt('yt_player_width'); 316 | if ($width !== null) { 317 | $this->width = $width; 318 | } 319 | 320 | $height = FreshRSS_Context::userConf()->attributeInt('yt_player_height'); 321 | if ($height !== null) { 322 | $this->height = $height; 323 | } 324 | 325 | $showContent = FreshRSS_Context::userConf()->attributeBool('yt_show_content'); 326 | if ($showContent !== null) { 327 | $this->showContent = $showContent; 328 | } 329 | 330 | $downloadIcons = FreshRSS_Context::userConf()->attributeBool('yt_download_channel_icons'); 331 | if ($downloadIcons !== null) { 332 | $this->downloadIcons = $downloadIcons; 333 | } 334 | 335 | $noCookie = FreshRSS_Context::userConf()->attributeBool('yt_nocookie'); 336 | if ($noCookie !== null) { 337 | $this->useNoCookie = $noCookie; 338 | } 339 | } 340 | 341 | /** 342 | * Returns the width in pixel for the YouTube player iframe. 343 | * You have to call loadConfigValues() before this one, otherwise you get default values. 344 | */ 345 | public function getWidth(): int { 346 | return $this->width; 347 | } 348 | 349 | /** 350 | * Returns the height in pixel for the YouTube player iframe. 351 | * You have to call loadConfigValues() before this one, otherwise you get default values. 352 | */ 353 | public function getHeight(): int { 354 | return $this->height; 355 | } 356 | 357 | /** 358 | * Returns whether this extension displays the content of the YouTube feed. 359 | * You have to call loadConfigValues() before this one, otherwise you get default values. 360 | */ 361 | public function isShowContent(): bool { 362 | return $this->showContent; 363 | } 364 | 365 | /** 366 | * Returns whether the automatic icon download option is enabled. 367 | * You have to call loadConfigValues() before this one, otherwise you get default values. 368 | */ 369 | public function isDownloadIcons(): bool { 370 | return $this->downloadIcons; 371 | } 372 | 373 | /** 374 | * Returns if this extension should use youtube-nocookie.com instead of youtube.com. 375 | * You have to call loadConfigValues() before this one, otherwise you get default values. 376 | */ 377 | public function isUseNoCookieDomain(): bool { 378 | return $this->useNoCookie; 379 | } 380 | 381 | /** 382 | * Inserts the YouTube video iframe into the content of an entry, if the entries link points to a YouTube watch URL. 383 | * @throws FreshRSS_Context_Exception 384 | */ 385 | public function embedYouTubeVideo(FreshRSS_Entry $entry): FreshRSS_Entry { 386 | $link = $entry->link(); 387 | 388 | if ($this->isShort($link)) { 389 | $link = $this->convertShortToWatch($link); 390 | } 391 | 392 | if (preg_match('#^https?://www\.youtube\.com/watch\?v=|/videos/watch/[0-9a-f-]{36}$#', $link) !== 1) { 393 | return $entry; 394 | } 395 | 396 | $this->loadConfigValues(); 397 | 398 | if (stripos($entry->content(), ''; 449 | 450 | if ($this->showContent) { 451 | $doc = new DOMDocument(); 452 | $doc->encoding = 'UTF-8'; 453 | $doc->recover = true; 454 | $doc->strictErrorChecking = false; 455 | 456 | if ($doc->loadHTML('' . $entry->content())) { 457 | $xpath = new DOMXPath($doc); 458 | 459 | /** @var DOMNodeList $titles */ 460 | $titles = $xpath->evaluate("//*[@class='enclosure-title']"); 461 | /** @var DOMNodeList $thumbnails */ 462 | $thumbnails = $xpath->evaluate("//*[@class='enclosure-thumbnail']/@src"); 463 | /** @var DOMNodeList $descriptions */ 464 | $descriptions = $xpath->evaluate("//*[@class='enclosure-description']"); 465 | 466 | $content = '
'; 467 | 468 | // We hide the title so it doesn't appear in the final article, which would be redundant with the RSS article title, 469 | // but we keep it in the content anyway, so RSS clients can extract it if needed. 470 | if ($titles->length > 0 && $titles[0] instanceof DOMNode) { 471 | $content .= ''; 472 | } 473 | 474 | // We hide the thumbnail so it doesn't appear in the final article, which would be redundant with the YouTube player preview, 475 | // but we keep it in the content anyway, so RSS clients can extract it to display a preview where it wants (in article listing, 476 | // by example, like with Reeder). 477 | if ($thumbnails->length > 0 && $thumbnails[0] instanceof DOMNode) { 478 | $content .= ''; 479 | } 480 | 481 | $content .= $iframe; 482 | 483 | if ($descriptions->length > 0 && $descriptions[0] instanceof DOMNode) { 484 | $content .= '

' . 485 | nl2br(htmlspecialchars($descriptions[0]->nodeValue ?? '', ENT_COMPAT, 'UTF-8'), use_xhtml: true) . '

'; 486 | } 487 | 488 | $content .= "
\n"; 489 | } else { 490 | $content = $iframe . $entry->content(); 491 | } 492 | } else { 493 | $content = $iframe; 494 | } 495 | 496 | return $content; 497 | } 498 | 499 | /** 500 | * This function is called by FreshRSS when the configuration page is loaded, and when configuration is saved. 501 | * - We save configuration in case of a post. 502 | * - We (re)load configuration in all case, so they are in-sync after a save and before a page load. 503 | * @throws FreshRSS_Context_Exception 504 | * @throws Minz_PDOConnectionException 505 | * @throws Minz_ConfigurationNamespaceException 506 | * @throws FreshRSS_UnsupportedImageFormat_Exception 507 | * @throws Minz_PermissionDeniedException 508 | */ 509 | #[\Override] 510 | public function handleConfigureAction(): void { 511 | $this->registerTranslates(); 512 | 513 | if (Minz_Request::isPost()) { 514 | // for handling requests from `custom_favicon_btn_url` hook 515 | $extAction = Minz_Request::paramStringNull('extAction'); 516 | if ($extAction !== null) { 517 | $feedDAO = FreshRSS_Factory::createFeedDao(); 518 | $feed = $feedDAO->searchById(Minz_Request::paramInt('id')); 519 | if ($feed === null || !$this->isYtFeed($feed->website())) { 520 | Minz_Error::error(404); 521 | return; 522 | } 523 | 524 | $this->setIconForFeed($feed, setValues: $extAction === 'update_icon'); 525 | if ($extAction === 'query_icon_info') { 526 | header('Content-Type: application/json; charset=UTF-8'); 527 | exit(json_encode([ 528 | 'extName' => $this->getName(), 529 | 'iconUrl' => $feed->favicon(), 530 | ])); 531 | } 532 | 533 | exit('OK'); 534 | } 535 | 536 | // for handling configure page 537 | switch (Minz_Request::paramString('yt_action_btn')) { 538 | case 'ajaxGetYtFeeds': 539 | $this->ajaxGetYtFeeds(); 540 | return; 541 | case 'ajaxFetchIcon': 542 | $this->ajaxFetchIcon(); 543 | return; 544 | // non-ajax actions 545 | case 'iconFetchFinish': // called after final ajaxFetchIcon call 546 | Minz_Request::good(_t('ext.yt_videos.finished_fetching_icons'), ['c' => 'extension']); 547 | break; 548 | case 'resetIcons': 549 | $this->resetAllIcons(); 550 | break; 551 | } 552 | FreshRSS_Context::userConf()->_attribute('yt_player_height', Minz_Request::paramInt('yt_height')); 553 | FreshRSS_Context::userConf()->_attribute('yt_player_width', Minz_Request::paramInt('yt_width')); 554 | FreshRSS_Context::userConf()->_attribute('yt_show_content', Minz_Request::paramBoolean('yt_show_content')); 555 | FreshRSS_Context::userConf()->_attribute('yt_download_channel_icons', Minz_Request::paramBoolean('yt_download_channel_icons')); 556 | FreshRSS_Context::userConf()->_attribute('yt_nocookie', Minz_Request::paramBoolean('yt_nocookie')); 557 | FreshRSS_Context::userConf()->save(); 558 | } 559 | 560 | $this->loadConfigValues(); 561 | } 562 | } 563 | --------------------------------------------------------------------------------