Compare commits
368 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d7d554df8 | |||
| 66b269e6cd | |||
| 54abd0a736 | |||
| 5eab4753e8 | |||
| bf466425db | |||
| a62b97bddd | |||
| 1de9cf4241 | |||
| 7912b9b8fb | |||
| 2d24f5d04e | |||
| 20825a92c5 | |||
| 1d1162327e | |||
| 202de7c787 | |||
| b470b186b3 | |||
| 5877cf340a | |||
| d417b0ff12 | |||
| 703f113fbd | |||
| f02213a168 | |||
| 410b43a787 | |||
| d9de9d1852 | |||
| 33d55cc4e9 | |||
| 1825320369 | |||
| d80fb242db | |||
| 4336a0bd85 | |||
| dc81725788 | |||
| 86e2ce6d0b | |||
| 4679691c94 | |||
| 0d5f4a710f | |||
| 92d2ac4f58 | |||
| 1cfee27a50 | |||
| 0e9da3a090 | |||
| 57bd384951 | |||
| fdbd5b08a1 | |||
| f455c18c66 | |||
| 9dea26c923 | |||
| 46adb0ffad | |||
| 61583d53d5 | |||
| 7c42e777ec | |||
| 335dffbb75 | |||
| f0cdfb33dd | |||
| 13ef89f785 | |||
| 32fbb4e882 | |||
| 135ce1d546 | |||
| abed7b11ce | |||
| 2e26f5ca75 | |||
| d6d18a2d61 | |||
| 63891501e5 | |||
| 7107ff985f | |||
| 50d5cb96c8 | |||
| f860daef7f | |||
| e7b98afdbe | |||
| 2cfeefc8d2 | |||
| b48e6472f5 | |||
| 24043ece07 | |||
| a09129d220 | |||
| f864a2ed70 | |||
| 052e8dd0aa | |||
| 7a394b0bf8 | |||
| dcfe0a7d94 | |||
| 33c648825f | |||
| 78c7f66df7 | |||
| f2b805850c | |||
| 915b7b3cf7 | |||
| 8e86004936 | |||
| a8b4e88742 | |||
| 15e4c3a374 | |||
| 69a74c4abf | |||
| 766d4ab834 | |||
| cb617ff6e0 | |||
| 8c3f280f32 | |||
| 8a98926674 | |||
| 435a950d64 | |||
| 89c32d518d | |||
| 2f7b2e7375 | |||
| 6eeccae7cd | |||
| 99c5bcdb01 | |||
| aed99e65c1 | |||
| d1a3f98df9 | |||
| 112494bb66 | |||
| 9f7ecdb75a | |||
| 4e1f836266 | |||
| a68de4ee6a | |||
| c064891314 | |||
| 5129f53d58 | |||
| e60f0fd142 | |||
| 2b26a345cd | |||
| 3de31a1a4d | |||
| 560be66147 | |||
| fcf86e33b9 | |||
| 113f6b8982 | |||
| cbdcf1a56c | |||
| 95eb6c1230 | |||
| 643b89ec89 | |||
| 84ebf1a033 | |||
| 875618d786 | |||
| 1503a5c946 | |||
| 8641f5f2a3 | |||
| 93b17af78b | |||
| 92876a0c61 | |||
| d62df4e02a | |||
| 8fa5041c37 | |||
| dc05965895 | |||
| 109e668ac7 | |||
| 6d58052504 | |||
| 491d51c95f | |||
| db49e41acf | |||
| e6b814199b | |||
| b0a10f02fd | |||
| 9c05c3c493 | |||
| 2a9d91c783 | |||
| cb59944d6b | |||
| c12476c1a9 | |||
| bc6ab44ff2 | |||
| 6282ac1f38 | |||
| 96c0ef4efd | |||
| b139ac4a2c | |||
| c818d5bbb8 | |||
| e366710529 | |||
| 5cff4d7117 | |||
| f31a784eaa | |||
| 7edfcc3cf7 | |||
| fe4b00b9f8 | |||
| 46b159ac58 | |||
| 86c58e11f6 | |||
| 9a1d9593b3 | |||
| 43546976d2 | |||
| 3ab9ca9e4d | |||
| 1af6df7cb9 | |||
| 50dff576b0 | |||
| 6af4d69c39 | |||
| 27015a5e34 | |||
| cdb57b3843 | |||
| 152ef578d2 | |||
| 72486b9bd1 | |||
| 81df0b2a16 | |||
| b296f21e98 | |||
| af15032145 | |||
| 6a6a88d06d | |||
| 8660f5e3c7 | |||
| da4ab4263c | |||
| 237672a62c | |||
| e9d4a130fd | |||
| d291d6a74d | |||
| e0f7e6f2a8 | |||
| ef3dbd3707 | |||
| 32f08053aa | |||
| 117c031f1c | |||
| b95c9023ee | |||
| 3538c4271b | |||
| ac44507af2 | |||
| 44c48d109f | |||
| 40727704c2 | |||
| 8a014c6abc | |||
| 60ad19c427 | |||
| f40c1e7f63 | |||
| a4d16cc5c1 | |||
| 7c857bdc72 | |||
| 0caadf82f2 | |||
| 0086e0b356 | |||
| 70b513b8db | |||
| cecc18420d | |||
| d53fd17e10 | |||
| 21d22d7f0b | |||
| d59990f1dd | |||
| 49085daefe | |||
| 5872710d22 | |||
| d2212dee12 | |||
| 8db637cb39 | |||
| 699deea72c | |||
| c41d189a7a | |||
| adfc38d237 | |||
| a8bb7a48d7 | |||
| f9dce3d10f | |||
| 567e8cfc89 | |||
| a8e73d6875 | |||
| fa7f2b18a0 | |||
| 43d302e768 | |||
| 4e181330d0 | |||
| 0369f03940 | |||
| 4597d9b289 | |||
| 7bfd22aab7 | |||
| 325c505b88 | |||
| bfd8860398 | |||
| ebd65da3b6 | |||
| 83191b0c1d | |||
| 8142268799 | |||
| 5920e02562 | |||
| 09fb05aaaf | |||
| 52b184394f | |||
| 7c8c7c2711 | |||
| 9768eb9fb9 | |||
| b65373db7e | |||
| 596d22c02c | |||
| 4b86570b7c | |||
| e99864a456 | |||
| 225463817c | |||
| 3db6e822cb | |||
| 1c19151925 | |||
| 553c578f2e | |||
| 81ec32a8b6 | |||
| 3818a8a4fb | |||
| 036704b3e4 | |||
| 327d027d38 | |||
| 5ae2cbd943 | |||
| f15d29deb3 | |||
| 828a4334db | |||
| d1dc369bb2 | |||
| a8076e1891 | |||
| 3448d6267c | |||
| 159261f2f8 | |||
| ff2dfe977b | |||
| a5e3719773 | |||
| cdadb87203 | |||
| 5284d61fe3 | |||
| 3de9629a49 | |||
| 8d821dfc3b | |||
| cb775bc79e | |||
| 6cc8d8abf1 | |||
| 50395f13ca | |||
| e8c3435bb9 | |||
| 9a8a8bdca3 | |||
| 63f0a17388 | |||
| 81c7669945 | |||
| 2b000d1022 | |||
| 27253c8a97 | |||
| f68046cce0 | |||
| d33e305af9 | |||
| 764212f37c | |||
| e02b65d4bc | |||
| fe7ec25a09 | |||
| 2959a4d2bf | |||
| 6b70a7dc81 | |||
| 495c6aacd9 | |||
| bc228d0afe | |||
| d139d8a6ce | |||
| 28d0185e79 | |||
| c87c93d85f | |||
| 0ef21e85c2 | |||
| 7438d67061 | |||
| e4399b4f9a | |||
| b4f891705a | |||
| 3d72404ace | |||
| c4e92e6111 | |||
| ef22e95f8b | |||
| 7b344de846 | |||
| c45b51d1f8 | |||
| 0af1a6e121 | |||
| 535fd050b7 | |||
| d20e8a4e2c | |||
| 7514e8a0c1 | |||
| ca3ede3183 | |||
| 038d33600a | |||
| 1a3a174688 | |||
| 6848c759b5 | |||
| 10578f512f | |||
| f99dff5238 | |||
| 1936ced1ed | |||
| cb695e653a | |||
| f57949c9a2 | |||
| 94fb3d6159 | |||
| 51560f191f | |||
| c0fcdf3b6e | |||
| 969efd2af7 | |||
| 87fccfee3a | |||
| e643effefa | |||
| c531be8780 | |||
| 6e3cecc57e | |||
| b3d385861f | |||
| cc5a8fa4b3 | |||
| 78ff727064 | |||
| 54dad707a4 | |||
| df8bc742fb | |||
| e342a4f143 | |||
| febb7b1748 | |||
| 537867a64b | |||
| 82a6fe64ae | |||
| 984a66b590 | |||
| bd79ad2a7d | |||
| b583de88f3 | |||
| baccb9e719 | |||
| 0fd11693f3 | |||
| 05f7a34d43 | |||
| 315e72c412 | |||
| f916373f55 | |||
| 5353211206 | |||
| 999ab0df2b | |||
| f8700c84b8 | |||
| ddd916161e | |||
| c3cd5e3109 | |||
| 051441d875 | |||
| 0cf5051cf8 | |||
| 12ca7aa085 | |||
| d53dc4cf28 | |||
| e7298b255a | |||
| ad02f21d04 | |||
| 46323b3fe7 | |||
| 86b3593fec | |||
| d7c504f48e | |||
| 6ad2001c7d | |||
| c5d4c762d8 | |||
| 8b66c2be1d | |||
| cfb22d7ec1 | |||
| f01ff067a5 | |||
| 55a3f9fcc9 | |||
| 996f6f68d2 | |||
| 3b8ac8b16a | |||
| 381f03b56a | |||
| 117d711d7d | |||
| 2f71804ff4 | |||
| 600f19cc87 | |||
| 4a77e937af | |||
| f7e30822c3 | |||
| 1cffb70362 | |||
| 462ba8d7f7 | |||
| 991c54150d | |||
| 6eedf4111f | |||
| af1f966250 | |||
| 7f54b27079 | |||
| a3ce03cc9d | |||
| 502db64b28 | |||
| 033806df4b | |||
| 50bd6eb5d9 | |||
| 3387201634 | |||
| 76acd81fa4 | |||
| 28b235595e | |||
| 9398f7abd7 | |||
| 0f55c81e2b | |||
| f2f60a8f73 | |||
| e777f12490 | |||
| 7bdf133b9d | |||
| 7eb1d15315 | |||
| a74186c555 | |||
| fb2fdf17b7 | |||
| 6edb3da977 | |||
| c0309b6ad1 | |||
| 7bf1dd6e7a | |||
| 6a008eee14 | |||
| b9b2d6822a | |||
| 459284ab96 | |||
| b193bc212a | |||
| 3ebeb38ade | |||
| c951ac2876 | |||
| 7275bc808a | |||
| e40446ad3c | |||
| fba23cf464 | |||
| bae872e79b | |||
| d0c9ef5acf | |||
| 0c74497ef7 | |||
| 369054b02d | |||
| c3c42b0c37 | |||
| 4938a94968 | |||
| ffe1be59ea | |||
| 2318e9011d | |||
| 67df305ac2 | |||
| 2e57e3351b | |||
| a412cde3b3 | |||
| abfd9306a4 | |||
| 8889e44b5d | |||
| 9657bf1f5b | |||
| 8c5f88ac62 | |||
| 736f8b4dac | |||
| 60e1d9e361 | |||
| 7967ce4df2 | |||
| 71c7845c42 | |||
| aa56d23551 | |||
| 3cd448099d | |||
| 47ccefba4e | |||
| 1faccc7eca | |||
| e74d875d95 |
@@ -1,10 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report
|
||||
title: ''
|
||||
labels: bug, triage needed
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
name: "Bug Report"
|
||||
description: "Report a bug or unexpected behavior"
|
||||
title: "[Bug]: "
|
||||
type: "Bug"
|
||||
labels: ["triage needed"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting a bug! Please provide detailed information to help us reproduce and fix the issue.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: "Bug Summary"
|
||||
description: "Briefly describe the bug."
|
||||
placeholder: "e.g., Error when saving a new entry"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Description"
|
||||
description: "A clear and concise description of the bug."
|
||||
placeholder: "e.g., When I click 'Save', I get a 500 error."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps_to_reproduce
|
||||
attributes:
|
||||
label: "Steps to Reproduce"
|
||||
description: "Steps to reproduce the behavior."
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected_behavior
|
||||
attributes:
|
||||
label: "Expected Behavior"
|
||||
description: "What should happen instead?"
|
||||
placeholder: "e.g., The form should be saved successfully."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual_behavior
|
||||
attributes:
|
||||
label: "Actual Behavior"
|
||||
description: "What actually happens?"
|
||||
placeholder: "e.g., A 500 error is returned with no useful error message."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: "Version"
|
||||
description: "Which version of Miniflux are you using?"
|
||||
placeholder: "e.g., 2.2.6"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: "Browser"
|
||||
description: "If applicable, which browser are you using? Please provide the version."
|
||||
placeholder: "e.g., Chrome, Firefox, Safari"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: "Relevant Logs or Error Output"
|
||||
description: "Paste any relevant logs or error messages (if applicable)."
|
||||
render: shell
|
||||
placeholder: "e.g., Stack trace, log files, browser console logs, or console output"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: "Additional Context"
|
||||
description: "Add any other context about the problem here."
|
||||
placeholder: "e.g., Screenshots, video recordings, or related issues"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: agreement
|
||||
attributes:
|
||||
label: "Checklist"
|
||||
description: "Please confirm the following:"
|
||||
options:
|
||||
- label: "I have searched existing issues to ensure this bug hasn't been reported before."
|
||||
required: true
|
||||
@@ -0,0 +1,81 @@
|
||||
name: "Documentation Issue"
|
||||
description: "Report issues or suggest improvements for the documentation"
|
||||
title: "[Docs]: "
|
||||
type: "Documentation"
|
||||
labels: ["triage needed"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping improve the Miniflux documentation! Clear and accurate documentation helps everyone.
|
||||
|
||||
- type: dropdown
|
||||
id: issue_type
|
||||
attributes:
|
||||
label: "Documentation Issue Type"
|
||||
description: "What kind of documentation issue are you reporting?"
|
||||
options:
|
||||
- "Missing Information"
|
||||
- "Incorrect Information"
|
||||
- "Outdated Information"
|
||||
- "Unclear Explanation"
|
||||
- "Formatting/Structural Issue"
|
||||
- "Typo/Grammar Error"
|
||||
- "Documentation Request"
|
||||
- "Other"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: "Summary"
|
||||
description: "Briefly describe the documentation issue."
|
||||
placeholder: "e.g., The API authentication section is outdated"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
label: "Location"
|
||||
description: "Where is the documentation you're referring to? Provide URLs, file paths, or section names."
|
||||
placeholder: "e.g., README.md, docs/api.md, Installation section of the website"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Detailed Description"
|
||||
description: "Provide a detailed description of the issue or improvement."
|
||||
placeholder: "e.g., The API authentication section doesn't mention the new token-based authentication method introduced in version 2.0.5."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: current_content
|
||||
attributes:
|
||||
label: "Current Content (if applicable)"
|
||||
description: "What does the current documentation say?"
|
||||
placeholder: "Paste the current documentation text here."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: suggested_content
|
||||
attributes:
|
||||
label: "Suggested Changes"
|
||||
description: "If you have specific suggestions for how to improve the documentation, please provide them here."
|
||||
placeholder: "e.g., Add a new section about token-based authentication with these details..."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: "Version"
|
||||
description: "Which version of Miniflux does this documentation issue relate to?"
|
||||
placeholder: "e.g., 2.2.6, or 'all versions'"
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: wishlist
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
- [ ] I have read this document: https://miniflux.app/opinionated.html#feature-request
|
||||
@@ -0,0 +1,67 @@
|
||||
name: "Feature Request"
|
||||
description: "Suggest an idea or improvement for the project"
|
||||
title: "[Feature]: "
|
||||
type: "Feature"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest a feature! Please provide detailed information to help us understand and evaluate your idea.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: "Feature Summary"
|
||||
description: "Briefly describe the feature or enhancement."
|
||||
placeholder: "e.g., Add dark mode support"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: "What problem does this feature solve?"
|
||||
description: "Explain the problem or limitation this feature would address."
|
||||
placeholder: "e.g., It's difficult to use the app in low-light environments."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: "Proposed Solution"
|
||||
description: "Describe how you think this feature should work."
|
||||
placeholder: "e.g., Add a toggle in settings to switch between light and dark mode."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: "Alternatives Considered"
|
||||
description: "Have you considered other solutions or workarounds?"
|
||||
placeholder: "e.g., Using browser extensions to force dark mode."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: "Additional Context"
|
||||
description: "Add any other context, screenshots, or examples to explain your request."
|
||||
placeholder: "e.g., A screenshot of a similar feature in another project."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: agreement
|
||||
attributes:
|
||||
label: "Checklist"
|
||||
description: "Please confirm the following:"
|
||||
options:
|
||||
- label: "I have searched existing issues to ensure this feature hasn't been requested before."
|
||||
required: true
|
||||
- label: "I understand that feature requests are not guaranteed to be implemented."
|
||||
required: true
|
||||
- label: "I agree to follow the project's contribution guidelines."
|
||||
required: true
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
name: Feed Problems
|
||||
about: Problems with a feed or a website
|
||||
title: ''
|
||||
labels: feed problems, triage needed
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: "Feed/Website Issue"
|
||||
description: "Report problems with a specific feed or website"
|
||||
title: "[Feed Issue]: "
|
||||
type: "Feed Issue"
|
||||
labels: ["triage needed"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting an issue with a feed or website! Please provide detailed information to help us diagnose and resolve the problem.
|
||||
|
||||
- type: input
|
||||
id: feed_url
|
||||
attributes:
|
||||
label: "Feed URL"
|
||||
description: "Provide the URL of the feed that is not working correctly."
|
||||
placeholder: "e.g., https://example.com/feed.xml"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: website_url
|
||||
attributes:
|
||||
label: "Website URL"
|
||||
description: "Provide the URL of the website."
|
||||
placeholder: "e.g., https://example.com"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem_description
|
||||
attributes:
|
||||
label: "Problem Description"
|
||||
description: "Describe the issue you are experiencing with this feed."
|
||||
placeholder: |
|
||||
e.g.,
|
||||
- The feed URL returns a 403 error.
|
||||
- The content is malformed.
|
||||
- Images are not loading in the web ui.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected_behavior
|
||||
attributes:
|
||||
label: "Expected Behavior"
|
||||
description: "Describe what you expect to happen."
|
||||
placeholder: "e.g., The feed should show the images correctly."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: error_logs
|
||||
attributes:
|
||||
label: "Relevant Logs or Error Output"
|
||||
description: "Paste any relevant logs or error messages, if available."
|
||||
render: shell
|
||||
placeholder: "e.g., HTTP error codes, invalid XML warnings, etc."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: "Additional Context"
|
||||
description: "Add any other context, screenshots, or related information to help us troubleshoot."
|
||||
placeholder: "e.g., Is this a recurring problem? Did the feed work before?"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: troubleshooting
|
||||
attributes:
|
||||
label: "Troubleshooting Steps"
|
||||
description: "Please confirm that you have tried the following:"
|
||||
options:
|
||||
- label: "I have checked if the feed URL is correct and accessible in a web browser."
|
||||
required: true
|
||||
- label: "I have checked if the feed URL is correct and accessible with `curl`."
|
||||
required: true
|
||||
- label: "I have verified that the feed is valid using an RSS/Atom validator."
|
||||
required: false
|
||||
- label: "I have searched for existing issues to avoid duplicates."
|
||||
required: true
|
||||
@@ -0,0 +1,88 @@
|
||||
name: "Proposal / RFC"
|
||||
description: "Propose a significant change, or architectural decision"
|
||||
title: "[Proposal]: "
|
||||
type: "Proposal"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to submit a proposal! Please provide detailed information to ensure a productive discussion.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: "Proposal Summary"
|
||||
description: "A brief summary of the proposed change or idea."
|
||||
placeholder: "e.g., Refactor database schema for performance optimization"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: "Motivation and Context"
|
||||
description: "Explain the problem this proposal addresses. Why is it necessary? What are the current limitations or pain points?"
|
||||
placeholder: |
|
||||
e.g.,
|
||||
- The current database schema causes performance bottlenecks when querying large datasets.
|
||||
- Adding this feature will improve scalability and reliability for large-scale use cases.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed_solution
|
||||
attributes:
|
||||
label: "Proposed Solution"
|
||||
description: "Describe the proposed solution or approach. Include technical details, diagrams, and examples where possible."
|
||||
placeholder: |
|
||||
e.g.,
|
||||
- Redesign the schema to normalize tables and introduce indexing.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: "Alternatives Considered"
|
||||
description: "List any alternative approaches that were considered and explain why they were rejected."
|
||||
placeholder: |
|
||||
e.g.,
|
||||
- Use Redis for caching, but it adds operational complexity.
|
||||
- Stick with the current schema and optimize queries, but this has limited impact on performance.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: "Impact and Risks"
|
||||
description: "Describe the potential impact of this change. Highlight possible risks and backward compatibility concerns."
|
||||
placeholder: |
|
||||
e.g.,
|
||||
- May require data migration with downtime.
|
||||
- Could introduce breaking changes in API responses.
|
||||
- Affects core functionality, requiring extensive testing.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: "Additional Context or References"
|
||||
description: "Add any relevant context, links to related discussions, RFCs, or design documents."
|
||||
placeholder: "e.g., Links to research, GitHub issues, or similar projects"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: agreement
|
||||
attributes:
|
||||
label: "Checklist"
|
||||
description: "Please confirm the following:"
|
||||
options:
|
||||
- label: "I have reviewed existing proposals to ensure this change hasn't been proposed before."
|
||||
required: true
|
||||
- label: "I agree to provide follow-up updates and maintain discussion on this proposal."
|
||||
required: true
|
||||
- label: "I agree to follow the project's contribution guidelines."
|
||||
required: true
|
||||
@@ -4,52 +4,28 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
reviewers:
|
||||
- "fguillot"
|
||||
assignees:
|
||||
- "fguillot"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/packaging/docker/alpine"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
reviewers:
|
||||
- "fguillot"
|
||||
assignees:
|
||||
- "fguillot"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/packaging/docker/distroless"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
reviewers:
|
||||
- "fguillot"
|
||||
assignees:
|
||||
- "fguillot"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "packaging/debian"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
reviewers:
|
||||
- "fguillot"
|
||||
assignees:
|
||||
- "fguillot"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "packaging/rpm"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
reviewers:
|
||||
- "fguillot"
|
||||
assignees:
|
||||
- "fguillot"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
reviewers:
|
||||
- "fguillot"
|
||||
assignees:
|
||||
- "fguillot"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Do you follow the guidelines?
|
||||
Have you followed these guidelines?
|
||||
|
||||
- [ ] I have tested my changes
|
||||
- [ ] There are no breaking changes
|
||||
- [ ] I really tested my changes and there is no regression
|
||||
- [ ] Ideally, my commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
|
||||
- [ ] I read this document: https://miniflux.app/faq.html#pull-request
|
||||
- [ ] I have thoroughly tested my changes and verified there are no regressions
|
||||
- [ ] My commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
|
||||
- [ ] I have read and understood the [contribution guidelines](https://github.com/miniflux/v2/blob/main/CONTRIBUTING.md)
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: "1.24.x"
|
||||
check-latest: true
|
||||
- name: Compile binaries
|
||||
env:
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: "1.24.x"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
@@ -6,10 +6,14 @@ on:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
schedule:
|
||||
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
|
||||
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/debian/**' # Only run on changes to the debian packaging files
|
||||
jobs:
|
||||
test-packages:
|
||||
if: github.event_name == 'schedule'
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -28,15 +28,27 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
go-version: "1.24.x"
|
||||
- uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
args: >
|
||||
--timeout 10m
|
||||
--exclude-dirs=tests
|
||||
--disable errcheck
|
||||
--enable sqlclosecheck,misspell,gofmt,goimports,whitespace,gocritic
|
||||
- uses: dominikh/staticcheck-action@v1.3.1
|
||||
--enable sqlclosecheck,misspell,whitespace,gocritic
|
||||
- name: Run gofmt linter
|
||||
run: gofmt -d -e .
|
||||
|
||||
commitlint:
|
||||
if: github.event_name == 'pull_request'
|
||||
name: Commit Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
version: "2024.1.1"
|
||||
install-go: false
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Validate PR commits
|
||||
run: python3 .github/workflows/scripts/commit-checker.py --base ${{ github.event.pull_request.base.sha }} --head ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
@@ -6,10 +6,14 @@ on:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
schedule:
|
||||
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
|
||||
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/rpm/**' # Only run on changes to the rpm packaging files
|
||||
jobs:
|
||||
test-package:
|
||||
if: github.event_name == 'schedule'
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
from typing import Match
|
||||
|
||||
# Conventional commit pattern (including Git revert messages)
|
||||
CONVENTIONAL_COMMIT_PATTERN: str = (
|
||||
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
|
||||
)
|
||||
|
||||
|
||||
def get_commit_message(commit_hash: str) -> str:
|
||||
"""Get the commit message for a given commit hash."""
|
||||
try:
|
||||
result: subprocess.CompletedProcess = subprocess.run(
|
||||
["git", "show", "-s", "--format=%B", commit_hash],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error retrieving commit message: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check_commit_message(message: str, pattern: str = CONVENTIONAL_COMMIT_PATTERN) -> bool:
|
||||
"""Check if commit message follows conventional commit format."""
|
||||
first_line: str = message.split("\n")[0]
|
||||
match: Match[str] | None = re.match(pattern, first_line)
|
||||
return bool(match)
|
||||
|
||||
|
||||
def check_commit_range(base_ref: str, head_ref: str) -> list[dict[str, str]]:
|
||||
"""Check all commits in a range for compliance."""
|
||||
try:
|
||||
result: subprocess.CompletedProcess = subprocess.run(
|
||||
["git", "log", "--format=%H", f"{base_ref}..{head_ref}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
commit_hashes: list[str] = result.stdout.strip().split("\n")
|
||||
|
||||
# Filter out empty lines
|
||||
commit_hashes = [hash for hash in commit_hashes if hash]
|
||||
|
||||
non_compliant: list[dict[str, str]] = []
|
||||
for commit_hash in commit_hashes:
|
||||
message: str = get_commit_message(commit_hash)
|
||||
if not check_commit_message(message):
|
||||
non_compliant.append({"hash": commit_hash, "message": message.split("\n")[0]})
|
||||
|
||||
return non_compliant
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error checking commit range: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(description="Check conventional commit compliance")
|
||||
parser.add_argument("--base", required=True, help="Base ref (starting commit, exclusive)")
|
||||
parser.add_argument("--head", required=True, help="Head ref (ending commit, inclusive)")
|
||||
args: argparse.Namespace = parser.parse_args()
|
||||
|
||||
non_compliant: list[dict[str, str]] = check_commit_range(args.base, args.head)
|
||||
|
||||
if non_compliant:
|
||||
print("The following commits do not follow the conventional commit format:")
|
||||
for commit in non_compliant:
|
||||
print(f"- {commit['hash'][:8]}: {commit['message']}")
|
||||
print("\nPlease ensure your commit messages follow the format:")
|
||||
print("type(scope): subject")
|
||||
print("\nWhere type is one of: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("All commits follow the conventional commit format!")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
go-version: ["1.23.x"]
|
||||
go-version: ["1.24.x"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: "1.24.x"
|
||||
- name: Install Postgres client
|
||||
run: sudo apt update && sudo apt install -y postgresql-client
|
||||
- name: Run integration tests
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
./*.sha256
|
||||
./miniflux
|
||||
/miniflux
|
||||
.idea
|
||||
.vscode
|
||||
*.deb
|
||||
*.rpm
|
||||
miniflux-*
|
||||
miniflux-*
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
# Contributing to Miniflux
|
||||
|
||||
This document outlines how to contribute effectively to Miniflux.
|
||||
|
||||
## Philosophy
|
||||
|
||||
Miniflux follows a **minimalist philosophy**. The feature set is intentionally kept limited to avoid bloatware. Before contributing, please understand that:
|
||||
|
||||
- **Improving existing features takes priority over adding new ones**
|
||||
- **Quality over quantity** - well-implemented, focused features are preferred
|
||||
- **Simplicity is key** - complex solutions are discouraged in favor of simple, maintainable code
|
||||
|
||||
## Before You Start
|
||||
|
||||
### Feature Requests
|
||||
|
||||
Before implementing a new feature:
|
||||
|
||||
- Check if it aligns with Miniflux's philosophy
|
||||
- Consider if the feature could be implemented differently to maintain simplicity
|
||||
- Remember that developing software takes significant time, and this is a volunteer-driven project
|
||||
- If you need a specific feature, the best approach is to contribute it yourself
|
||||
|
||||
### Bug Reports
|
||||
|
||||
When reporting bugs:
|
||||
|
||||
- Search existing issues first to avoid duplicates
|
||||
- Provide clear reproduction steps
|
||||
- Include relevant system information (OS, browser, Miniflux version)
|
||||
- Include error messages, screenshots, and logs when applicable
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Requirements
|
||||
|
||||
- **Git**
|
||||
- **Go >= 1.24**
|
||||
- **PostgreSQL**
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. **Fork the repository** on GitHub
|
||||
2. **Clone your fork locally:**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/miniflux.git
|
||||
cd miniflux
|
||||
```
|
||||
|
||||
3. **Build the application binary:**
|
||||
```bash
|
||||
make miniflux
|
||||
```
|
||||
|
||||
4. **Run locally in debug mode:**
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
### Database Setup
|
||||
|
||||
For development and testing, you can run a local PostgreSQL database with Docker:
|
||||
|
||||
```bash
|
||||
# Start PostgreSQL container
|
||||
docker run --rm --name miniflux2-db -p 5432:5432 \
|
||||
-e POSTGRES_DB=miniflux2 \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
postgres
|
||||
```
|
||||
|
||||
You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE_URL` environment variable accordingly.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Code Quality
|
||||
|
||||
1. **Run the linter:**
|
||||
```bash
|
||||
make lint
|
||||
```
|
||||
Requires `staticcheck` and `golangci-lint` to be installed.
|
||||
|
||||
2. **Run unit tests:**
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
3. **Run integration tests:**
|
||||
```bash
|
||||
make integration-test
|
||||
make clean-integration-test
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
- **Current platform:** `make miniflux`
|
||||
- **All platforms:** `make build`
|
||||
- **Specific platforms:** `make linux-amd64`, `make darwin-arm64`, etc.
|
||||
- **Docker image:** `make docker-image`
|
||||
|
||||
### Cross-Platform Support
|
||||
|
||||
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
|
||||
- Linux (amd64, arm64, armv7, armv6, armv5)
|
||||
- macOS (amd64, arm64)
|
||||
- FreeBSD, OpenBSD, Windows (amd64)
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
### What Is Preferred
|
||||
|
||||
✅ **Good Pull Requests:**
|
||||
|
||||
- Focus on a single issue or feature
|
||||
- Include tests for new functionality
|
||||
- Maintain or improve performance
|
||||
- Follow existing code style and patterns
|
||||
- The commit messages follow the [conventional commit format](https://www.conventionalcommits.org/) (e.g., `feat: add new feature`, `fix: resolve bug`)
|
||||
- Update documentation when necessary
|
||||
|
||||
### What to Avoid
|
||||
|
||||
❌ **Pull Requests That Cannot Be Accepted:**
|
||||
|
||||
- **Too many changes** - makes review difficult
|
||||
- **Breaking changes** - disrupts existing functionality
|
||||
- **New bugs or regressions** - reduces software quality
|
||||
- **Unnecessary dependencies** - conflicts with minimalist approach
|
||||
- **Performance degradation** - slows down the software
|
||||
- **Poor-quality code** - hard to maintain
|
||||
- **Dependent PRs** - creates review complexity
|
||||
- **Radical UI changes** - disrupts user experience
|
||||
- **Conflicts with philosophy** - doesn't align with minimalist approach
|
||||
|
||||
### Pull Request Template
|
||||
|
||||
When creating a pull request, please include:
|
||||
|
||||
- **Description:** What does this PR do?
|
||||
- **Motivation:** Why is this change needed?
|
||||
- **Testing:** How was this tested?
|
||||
- **Breaking Changes:** Are there any breaking changes?
|
||||
- **Related Issues:** Link to any related issues
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow Go conventions and best practices
|
||||
- Use `gofmt` to format your Go code, and `jshint` for JavaScript
|
||||
- Write clear, descriptive variable and function names
|
||||
- Include comments for complex logic
|
||||
- Keep functions small and focused
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
- Write unit tests for new functions and methods
|
||||
- Ensure tests are fast and don't require external dependencies
|
||||
- Aim for good test coverage
|
||||
|
||||
### Integration Tests
|
||||
- Add integration tests for new API endpoints
|
||||
- Tests run against a real PostgreSQL database
|
||||
- Ensure tests clean up after themselves
|
||||
|
||||
## Communication
|
||||
|
||||
- **Discussions:** Use GitHub Discussions for general questions and community interaction
|
||||
- **Issues:** Use GitHub issues for bug reports and feature requests
|
||||
- **Pull Requests:** Use PR comments for code-specific discussions
|
||||
- **Philosophy Questions:** Refer to the FAQ for common questions about project direction
|
||||
|
||||
## Questions?
|
||||
|
||||
- Check the [FAQ](https://miniflux.app/faq.html) for common questions
|
||||
- Review the [development documentation](https://miniflux.app/docs/development.html) and [internationalization guide](https://miniflux.app/docs/i18n.html)
|
||||
- Look at existing issues and pull requests for examples
|
||||
@@ -1,7 +1,326 @@
|
||||
Version 2.2.11 (July 26, 2025)
|
||||
------------------------------
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
* **TLS support for Unix sockets**: Miniflux can now serve TLS over Unix domain sockets using `CERT_FILE` and `KEY_FILE` ([#fcf86e3](https://github.com/miniflux/v2/commit/fcf86e3)).
|
||||
* **RSS fallback**: If a feed entry has no URL, Miniflux now uses the enclosure URL as a fallback ([#d9de9d1](https://github.com/miniflux/v2/commit/d9de9d1)).
|
||||
* **Bearer token for Linkwarden**: The Linkwarden integration now uses Bearer token authorization instead of cookies ([#1d11623](https://github.com/miniflux/v2/commit/1d11623)).
|
||||
* **Cookie policy improvement**: `SameSiteStrictMode` is enforced for cookies when OAuth2/OIDC is not used ([#135ce1d](https://github.com/miniflux/v2/commit/135ce1d)).
|
||||
* **Readability engine**: Avoid removing elements with the `content` class during readability parsing ([#66b269e](https://github.com/miniflux/v2/commit/66b269e)).
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
* **Massive readability engine refactoring** and performance optimizations:
|
||||
|
||||
* Improved performance of `getClassWeight`, `getLinkDensity`, and `transformMisusedDivsIntoParagraphs`.
|
||||
* Simplified and optimized internal logic of `removeUnlikelyCandidates`, `getSelectionLength`, and `getArticle`.
|
||||
* Reduced memory allocation in sanitizer and readability components.
|
||||
* **Storage optimization**: Strings are now truncated on the Go side to respect `tsvector` limits, reducing DB load and ensuring valid UTF-8 ([#703f113](https://github.com/miniflux/v2/commit/703f113)).
|
||||
* **Simplified and clarified internal code structure**:
|
||||
|
||||
* Major cleanup and size optimization of internal structs (`Feed`, `FeedCreationRequest`, etc.).
|
||||
* Reduced memory use and improved CPU cache locality.
|
||||
* Numerous refactors across `config`, `template`, `locale`, `subscription`, and `fetcher` modules.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* Fixed an issue with feeds having excessive leading whitespace causing parser buffer issues ([#54abd0a](https://github.com/miniflux/v2/commit/54abd0a)).
|
||||
* Properly preserve UTF-8 when truncating strings for full-text search ([#703f113](https://github.com/miniflux/v2/commit/703f113)).
|
||||
* Fixed logic error in enclosure type detection ([#50d5cb9](https://github.com/miniflux/v2/commit/50d5cb9)).
|
||||
* Fixed incorrect filter rule parsing of Windows-style newlines ([#dc81725](https://github.com/miniflux/v2/commit/dc81725)).
|
||||
* Fixed a panic in `startAutoCertTLSServer` function when using Let's Encrypt automatic certificates ([#f7a6b02](https://github.com/miniflux/v2/commit/f7a6b02))
|
||||
* Improved UI spacing consistency around header/footer ([#32fbb4e](https://github.com/miniflux/v2/commit/32fbb4e)).
|
||||
|
||||
### ⚠️ Breaking Changes
|
||||
|
||||
* **Windows binary no longer distributed**: Windows is no longer a supported platform for binary distribution. Users must build from source if needed ([#b470b18](https://github.com/miniflux/v2/commit/b470b18)).
|
||||
|
||||
### 🧪 Tests & CI
|
||||
|
||||
* Test coverage significantly increased for modules like `readability`, `sanitizer`, `processor`, `locale`, and `storage`.
|
||||
* Commit linter updated to support new Git revert message format.
|
||||
|
||||
### 🐘 Docker & Environment
|
||||
|
||||
* Base Docker image updated to Alpine 3.22.
|
||||
* PostgreSQL Docker example updated to use the latest version.
|
||||
|
||||
### 🌐 Localization
|
||||
|
||||
* Updated Chinese and German translations.
|
||||
|
||||
### 🔒 Dependency Updates
|
||||
|
||||
* Bumped `github.com/go-webauthn/webauthn` to `0.13.4`
|
||||
* Bumped `github.com/tdewolff/minify/v2` to `2.23.10`
|
||||
* Bumped `golang.org/x/*` modules: `image`, `net`, `term`, `crypto`
|
||||
* Bumped `github.com/andybalholm/brotli` to `1.2.0`
|
||||
|
||||
Version 2.2.10 (June 23, 2025)
|
||||
------------------------------
|
||||
|
||||
* test(sanitizer): add unit test for 0x0 pixel tracker
|
||||
* test(sanitizer): add test case to cover Vimeo iframe rewrite without query string
|
||||
* refactor(youtube): Remove a regex and make use of `fetchWatchTime`
|
||||
* refactor(youtube): initialize two maps to the proper length
|
||||
* refactor(tests): use `b.Loop()` instead of for range `b.N`
|
||||
* refactor(server): avoid double call to `Sprintf`
|
||||
* refactor(sanitizer): use global variables to avoid recreating slices on every call
|
||||
* refactor(sanitizer): use a map for iframe allow list
|
||||
* refactor(sanitizer): remove two useless `www.` prefixes
|
||||
* refactor(sanitizer): make `isValidAttribute()` check O(1)
|
||||
* refactor(rewrite): rename `Rewriter` function to `ApplyContentRewriteRules`
|
||||
* refactor(processor): simplify Bilibili processing
|
||||
* refactor(processor): remove a useless type declaration
|
||||
* refactor(processor): remove a duplicated function call
|
||||
* refactor(processor): refactor common code into a `fetchWatchTime` function
|
||||
* refactor(processor): move filters to a `filter` package
|
||||
* refactor(processor): move `FilterEntryMaxAgeDays` filter to filter package
|
||||
* refactor(processor): move `RewriteEntryURL` function to `rewrite` package
|
||||
* refactor(processor): minor simplification of a loop
|
||||
* refactor(internal): add an `urllib.DomainWithoutWWW` function
|
||||
* refactor(http): rename package from `httpd` to `server` for consistency
|
||||
* refactor(http): Don't hardcode TLS configuration
|
||||
* refactor(filter): avoid code duplication between `IsBlockedEntry` and `IsAllowedEntry` functions
|
||||
* refactor(database): drop 3 columns in a single transaction
|
||||
* refactor(crypto): use `rand.Text()` instead of a custom implementation
|
||||
* refactor(config): remove deprecated config options
|
||||
* refactor(appjs): no need to check if always present elements are always present
|
||||
* perf(xml): optimized `NewXMLDecoder`
|
||||
* perf(xml): optimize XML filtering
|
||||
* perf(validator): slightly optimize a regex
|
||||
* perf(timezone): cache `getLocation`'s results
|
||||
* perf(storage): pre-allocate a slice in `RefreshFeedEntries`
|
||||
* perf(storage): optimize away two `Sprintf` calls
|
||||
* perf(sanitizer): use a switch-case instead of a map
|
||||
* perf(sanitizer): minor simplifications of the sanitizer
|
||||
* perf(sanitizer): extract a call to `url.Parse` and make intensive use of it
|
||||
* perf(rss): optimize a bit `BuildFeed`
|
||||
* perf(rss): early return when looking for an item's author
|
||||
* perf(rewrite): make `getPredefinedRewriteRules` O(1)
|
||||
* perf(reader): use a non-cryptographic hash when possible
|
||||
* perf(reader): optimize `RemoveTrackingParameters`
|
||||
* perf(readability): minor regex improvement
|
||||
* perf(media): minor regex simplification
|
||||
* perf(fetcher): pre-allocate the cipherSuites
|
||||
* perf(database): use `TRUNCATE` instead of `DELETE FROM` in migrations
|
||||
* perf(database): marginally speeds migrations up
|
||||
* perf(api): use `math/rand/v2` instead of `math/rand` for better performance
|
||||
* fix(readability): do not remove elements within code blocks
|
||||
* fix(karakeep): correct method name and improve error handling in `SaveURL`
|
||||
* fix(filter): skip invalid rules instead of exiting the loop
|
||||
* feat(ui): display external link in single entry view because the URL was not visible on mobile (no mouse over)
|
||||
* feat(ui): avoid showing an excessive number of tags
|
||||
* feat(ui): add user setting to control `target="_blank"` on links
|
||||
* feat(sanitizer): validate MathML XML namespace
|
||||
* feat(sanitizer): consider images of size 0x0 as pixel trackers
|
||||
* feat(sanitizer): add validation for empty `width` and `height` attributes in img tags
|
||||
* feat(sanitizer): add support for `fetchpriority` and `decoding` attributes in img tags
|
||||
* feat(rewrite): add support for YouTube Shorts video URL pattern
|
||||
* feat(rewrite): add `parkablogs.com` to the referer override list
|
||||
* feat(oidc): use `preferred_username` first instead of `email` claim
|
||||
* feat(locale): update Polish translations
|
||||
* feat(locale): update locales using machine translation
|
||||
* feat(locale): update Indonesian translations
|
||||
* feat(locale): update German translations
|
||||
* feat(locale): update Chinese translations
|
||||
* feat(integration)!: remove Pocket integration (Pocket will no longer be available after July 8, 2025)
|
||||
* feat(filter): add `EntryDate=max-age:duration` filter
|
||||
* feat(css): add margin-bottom to input for consistent spacing
|
||||
* feat(config)!: remove `SERVER_TIMING_HEADER` config option
|
||||
* feat: Allow multiple listen addresses
|
||||
* feat: adding support for saving entries to Karakeep
|
||||
* feat: add entry filters at the feed level
|
||||
* docs(readme): document a couple of nifty features
|
||||
* docs: add `CONTRIBUTING.md` file
|
||||
* chore(template): remove `X-UA-Compatible` meta tag specific to Internet Explorer
|
||||
* build(go): bump to go 1.24
|
||||
* build(deps): bump `library/alpine` in `/packaging/docker/alpine`
|
||||
* build(deps): bump `golang.org/x/net` from `0.40.0` to `0.41.0`
|
||||
* build(deps): bump `golang.org/x/image` from `0.27.0` to `0.28.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.38.0` to `0.39.0`
|
||||
|
||||
Version 2.2.9 (May 26, 2025)
|
||||
----------------------------
|
||||
|
||||
* refactor(googlereader): remove redundant log message
|
||||
* refactor(googlereader): move constants to separate files
|
||||
* fix(webauthn): correct argument in debug log
|
||||
* fix(sanitizer): MathML tags are not fully supported by `golang.org/x/net/html`
|
||||
* fix(migrations): prevent failure at version 45 with long entry URLs
|
||||
* fix(locale): localize Git commit label in about page
|
||||
* fix(googlereader): return a 400 instead of 500 for invalid edit requests
|
||||
* fix(googlereader): handle various item ID formats
|
||||
* fix(googlereader): avoid panic for inexisting feed or category
|
||||
* fix(googlereader): `/items/contents` should accept short form item IDs
|
||||
* feat(webauthn): prefer creation of a client-side discoverable credential
|
||||
* feat(urlcleaner): remove the `ref` parameter from url
|
||||
* feat(settings): replace `div.panel` with paragraph tags for OAuth2 links
|
||||
* feat(settings): add validation for entry order and categories sorting order
|
||||
* feat(settings): add option to always open articles externally
|
||||
* feat(server): add liveness and readiness probes
|
||||
* feat(sanitizer): add MathML tags to the sanitizer
|
||||
* feat(sanitized): allow Spotify iframes
|
||||
* feat(rssbridge): support authentication token for RSS-Bridge
|
||||
* feat(response): change error response content type to plain text and escape HTML
|
||||
* feat(reader): populate feed description automatically
|
||||
* feat(locale): update Russian translation
|
||||
* feat(locale): update Polish translation
|
||||
* feat(locale): update French translation
|
||||
* feat(googlereader): avoid SQL query to fetch username in streamItemContentsHandler
|
||||
* feat(googlereader): add `mark-all-as-read` endpoint
|
||||
* feat(api): add new endpoints to manage API keys
|
||||
* ci: remove deprecated `reviewers` field from `dependantbot.yml`
|
||||
* chore(gitignore): ignore miniflux binary in root directory
|
||||
* build(deps): bump `golangci/golangci-lint-action` from `7` to `8`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.29.0` to `0.30.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.39.0` to `0.40.0`
|
||||
* build(deps): bump `golang.org/x/image` from `0.26.0` to `0.27.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.37.0` to `0.38.0`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.23.3` to `2.23.8`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.23.1` to `2.23.3`
|
||||
* build(deps): bump `github.com/go-webauthn/webauthn` from `0.12.3` to `0.13.0`
|
||||
|
||||
Version 2.2.8 (April 22, 2025)
|
||||
------------------------------
|
||||
|
||||
* refactor(js): replace `DomHelper` methods with standalone functions
|
||||
* refactor: avoid logging twice the feed errors in the background worker
|
||||
* fix(api): `hide_globally` categories field should be a boolean
|
||||
* fix(ui): add missing `await` when calling `navigator.share()` method
|
||||
* fix(ui): replace share link with a form button for better accessibility
|
||||
* feat(telegrambot): replace "Go to website" button with "Go to Miniflux"
|
||||
* feat(locale): update Polish translation
|
||||
* feat(locale): update German translation
|
||||
* feat(locale): update Chinese translation
|
||||
* feat(config): add `SCHEDULER_ROUND_ROBIN_MAX_INTERVAL` option
|
||||
* feat(cli): add `-reset-feed-next-check-at` argument
|
||||
* feat(api): add `update_content` query parameter to `/entries/{entryID}/fetch-content` endpoint
|
||||
* feat: use `Cache-Control` max-age and `Expires` headers to calculate next check
|
||||
* feat: implement proxy URL per feed
|
||||
* feat: add proxy rotation functionality
|
||||
* ci(linter): replace commitlint with a Python script
|
||||
* ci: add documentation issue template
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.28.0` to `0.29.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.38.0` to `0.39.0`
|
||||
* build(deps): bump `golang.org/x/image` from `0.25.0` to `0.26.0`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.22.4` to `2.23.1`
|
||||
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.10.2` to `1.10.3`
|
||||
* build(deps): bump `github.com/prometheus/client_golang`
|
||||
* build(deps): bump `github.com/mattn/go-sqlite3` from `1.14.24` to `1.14.28`
|
||||
* build(deps): bump `github.com/go-webauthn/webauthn` from `0.12.2` to `0.12.3`
|
||||
* build(deps): bump `github.com/coreos/go-oidc/v3` from `3.13.0` to `3.14.1`
|
||||
|
||||
Version 2.2.7 (April 1, 2025)
|
||||
-----------------------------
|
||||
|
||||
* test(api): update base URL after upgrading Hugo
|
||||
* refactor(rewrite): reorganize referer rules and remove obsolete mappings
|
||||
* refactor: combine feed icon handlers to use only `externalIconID`
|
||||
* fix(ui): update share feature to correctly select the title element and handle empty titles
|
||||
* fix(ui): update entry tags display logic to show links based on user authentication
|
||||
* fix(ui): remove touch-action style to prevent horizontal scrolling issues
|
||||
* fix(ui): log a warning for an empty client secret
|
||||
* fix(ui): change labels from "Read / Unread" to "Mark as Read"
|
||||
* fix(ui): avoid 500 errors and NaN when marking a deleted entry as read
|
||||
* fix(subscription): add `/rss/feed.xml` to the list of known feed URLs
|
||||
* fix(security): use a more restrictive CSP for untrusted content
|
||||
* fix(rewrite): remove obsolete rule for `webtoons.com`
|
||||
* fix(processor): add missing quotation marks to import comments
|
||||
* fix(googlereader): return enclosures in the `streamItemContentsHandler` response
|
||||
* fix: address minor issues detected by Go linters
|
||||
* feat(urlcleaner): add more Google Analytics parameters
|
||||
* feat(storage): reduce the number of SQL queries when fetching entry enclosures
|
||||
* feat(sanitizer): allow the `<u>` tag in feeds
|
||||
* feat(sanitizer): allow the `<b>` tag
|
||||
* feat(locale): update Polish translation
|
||||
* feat(locale): add Romanian translation
|
||||
* feat(integrations/ntfy): make ntfy topics configurable per feed
|
||||
* feat(googlereader): add a feed icon endpoint
|
||||
* feat: show database size on the about page
|
||||
* feat: add a `make add string` command to add new localized strings
|
||||
* docs: update README
|
||||
* docs: update client README to remove references to deprecated functions
|
||||
* ci: replace GitHub Issue Markdown templates with YAML forms
|
||||
* build(deps): bump `golangci/golangci-lint-action` from `6` to `7`
|
||||
* build(deps): bump `golang.org/x/term` from `0.29.0` to `0.30.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.26.0` to` 0.28.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.35.0` to `0.38.0`
|
||||
* build(deps): bump `golang.org/x/image` from `0.24.0` to `0.25.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.33.0` to `0.36.0`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.21.3` to `2.22.4`
|
||||
* build(deps): bump `github.com/prometheus/client_golang`
|
||||
* build(deps): bump `github.com/golang-jwt/jwt/v5` from `5.2.1` to `5.2.2`
|
||||
* build(deps): bump `github.com/go-webauthn/webauthn` from `0.11.2` to `0.12.2`
|
||||
* build(deps): bump `github.com/go-jose/go-jose/v4` from `4.0.2` to `4.0.5`
|
||||
* build(deps): bump `github.com/coreos/go-oidc/v3` from `3.12.0` to `3.13.0`
|
||||
|
||||
Version 2.2.6 (February 22, 2025)
|
||||
---------------------------------
|
||||
|
||||
* test(encoding): add unit tests for `CharsetReader` function
|
||||
* refactor(xml): improve the performances of `NewXMLDecoder`
|
||||
* refactor(ui): remove superfluous cast
|
||||
* refactor(request): broaden an error condition when parsing cookies
|
||||
* refactor(processor): remove superfluous parenthesis
|
||||
* refactor(opml): don't define receivers on both values and pointer
|
||||
* refactor(model): simplify a condition
|
||||
* refactor(model): don't define methods both on instance and pointer
|
||||
* refactor(locale): sort JSON documents alphabetically by keys
|
||||
* refactor(locale): remove superfluous parenthesis
|
||||
* refactor(js): use proper types in `app.js`
|
||||
* refactor(js): replace the deprecated `window.pageYOffset` with `window.scollY`
|
||||
* refactor(js): remove a useless `return`
|
||||
* refactor(js): anchor `=` removal in `webauthn_handler.js` regex
|
||||
* refactor(js): add default value for parameter `fallbackSelf` in `goToPage` function
|
||||
* refactor(integration): don't use `defer` in a loop
|
||||
* refactor(icon): guard against a potential `null` dereference
|
||||
* refactor(date): use an else-if instead of two if statements
|
||||
* refactor(css): use shortcuts to declare padding
|
||||
* refactor(client): remove a useless cast
|
||||
* perf(sanitizer): remove two useless calls to `strings.ReplaceAll`
|
||||
* fix(ui): Redirect correctly post feed removal from category feeds list
|
||||
* fix(scraper): update TechCrunch scraper rule
|
||||
* fix(scraper): avoid encoding issue if charset meta tag is after 1024 bytes
|
||||
* fix(sanitizer): non-allowed attributes are not properly stripped
|
||||
* fix(sanitizer): correct HTML tag name from `tfooter` to `tfoot`
|
||||
* fix(rss): handle item title with `CDATA` content correctly
|
||||
* fix(locale): missing hyphen in `de_DE.json`
|
||||
* fix(css): avoid aside overflow on the pagination menu
|
||||
* fix(css): `--entry-content-aside-border-color` is missing from `system.css`
|
||||
* fix(api): return 500 response when JSON serialization fails
|
||||
* fix(api): JSON encoding is failing with dates at OAD and negative timezone offset
|
||||
* feat(urlcleaner): add trackers to the blocklist
|
||||
* feat(ui): open the `<details>` tag in edit feed page when the feature is enabled
|
||||
* feat(sanitizer): improve text truncation with better space handling
|
||||
* feat(sanitizer): allow `img` tags with only a `srcset` and no `src` attribute
|
||||
* feat(rss): add workaround for RSS item title with HTML content
|
||||
* feat(pushover): add integration with pushover.net
|
||||
* feat(processor): fetch YouTube watch time in bulk using the API
|
||||
* feat(locale): update Traditional Chinese translation
|
||||
* feat(locale): update Polish translation
|
||||
* feat(locale): update French translation
|
||||
* feat(locale): add Taiwanese POJ (nan-Latn-pehoeji)
|
||||
* feat(integration): update Linkace integration to support API v2
|
||||
* feat(integration): add webhook URL per feed
|
||||
* feat(integration): add Slack integration
|
||||
* feat(css): improve aside element position on smartphone
|
||||
* ci: update GitHub Actions workflows to use Go 1.24
|
||||
* ci: trigger packaging tests on pull requests
|
||||
* ci: add `commitlint` to validate PR commit messages
|
||||
* build(deps): bump `golang` in `/packaging/debian`
|
||||
* build(deps): bump `golang.org/x/term` from `0.28.0` to `0.29.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.25.0` to `0.26.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.34.0` to `0.35.0`
|
||||
* build(deps): bump `golang.org/x/image` from `0.23.0` to `0.24.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.32.0` to `0.33.0`
|
||||
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.10.1` to `1.10.2`
|
||||
|
||||
Version 2.2.5 (January 20, 2025)
|
||||
--------------------------------
|
||||
|
||||
* tests(js): improve `.jshintrc` (strict comparison, etc...)
|
||||
* test(js): improve `.jshintrc` (strict comparison, etc...)
|
||||
* test(sanitizer): add a fuzzer
|
||||
* refactor(rewriter): use custom title case converter implementation instead of `golang.org/x/text/cases.Title()`
|
||||
* refactor(readingtime): replace `whatlanggo` package with an ad-hoc implementation
|
||||
|
||||
@@ -22,16 +22,12 @@ export PGPASSWORD := postgres
|
||||
darwin-amd64 \
|
||||
darwin-arm64 \
|
||||
freebsd-amd64 \
|
||||
freebsd-x86 \
|
||||
openbsd-amd64 \
|
||||
openbsd-x86 \
|
||||
netbsd-x86 \
|
||||
netbsd-amd64 \
|
||||
windows-amd64 \
|
||||
windows-x86 \
|
||||
build \
|
||||
run \
|
||||
clean \
|
||||
add-string \
|
||||
test \
|
||||
lint \
|
||||
integration-test \
|
||||
@@ -85,30 +81,7 @@ openbsd-amd64:
|
||||
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
windows-amd64:
|
||||
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
|
||||
@ sha256sum $(APP)-$@.exe > $(APP)-$@.exe.sha256
|
||||
|
||||
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64 windows-amd64
|
||||
|
||||
# NOTE: unsupported targets
|
||||
netbsd-amd64:
|
||||
@ CGO_ENABLED=0 GOOS=netbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
linux-x86:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
freebsd-x86:
|
||||
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
netbsd-x86:
|
||||
@ CGO_ENABLED=0 GOOS=netbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
openbsd-x86:
|
||||
@ GOOS=openbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
windows-x86:
|
||||
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
|
||||
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64
|
||||
|
||||
run:
|
||||
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
|
||||
@@ -116,6 +89,14 @@ run:
|
||||
clean:
|
||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256
|
||||
|
||||
add-string:
|
||||
cd internal/locale/translations && \
|
||||
for file in *.json; do \
|
||||
jq --indent 4 --arg key "$(KEY)" --arg val "$(VAL)" \
|
||||
'. + {($$key): $$val} | to_entries | sort_by(.key) | from_entries' "$$file" > tmp && \
|
||||
mv tmp "$$file"; \
|
||||
done
|
||||
|
||||
test:
|
||||
go test -cover -race -count=1 ./...
|
||||
|
||||
@@ -134,7 +115,7 @@ integration-test:
|
||||
ADMIN_PASSWORD=test123 \
|
||||
CREATE_ADMIN=1 \
|
||||
RUN_MIGRATIONS=1 \
|
||||
DEBUG=1 \
|
||||
LOG_LEVEL=debug \
|
||||
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||
|
||||
while ! nc -z localhost 8080; do sleep 1; done
|
||||
|
||||
@@ -1,20 +1,114 @@
|
||||
Miniflux 2
|
||||
==========
|
||||
|
||||
Miniflux is a minimalist and opinionated feed reader:
|
||||
|
||||
- Written in Go (Golang)
|
||||
- Works only with Postgresql
|
||||
- Doesn't use any ORM
|
||||
- Doesn't use any complicated framework
|
||||
- Use only modern vanilla Javascript (ES6 and Fetch API)
|
||||
- Single binary compiled statically without dependency
|
||||
- The number of features is voluntarily limited
|
||||
|
||||
Miniflux is a minimalist and opinionated feed reader.
|
||||
It's simple, fast, lightweight and super easy to install.
|
||||
|
||||
Official website: <https://miniflux.app>
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
### Feed Reader
|
||||
|
||||
- Supported feed formats: Atom 0.3/1.0, RSS 1.0/2.0, and JSON Feed 1.0/1.1.
|
||||
- [OPML](https://en.wikipedia.org/wiki/OPML) file import/export and URL import.
|
||||
- Supports multiple attachments (podcasts, videos, music, and images enclosures).
|
||||
- Plays videos from YouTube directly inside Miniflux.
|
||||
- Organizes articles using categories and bookmarks.
|
||||
- Share individual articles publicly.
|
||||
- Fetches website icons (favicons).
|
||||
- Saves articles to third-party services.
|
||||
- Provides full-text search (powered by Postgres).
|
||||
- Available in 20 languages: Portuguese (Brazilian), Chinese (Simplified and Traditional), Dutch, English (US), Finnish, French, German, Greek, Hindi, Indonesian, Italian, Japanese, Polish, Romanian, Russian, Taiwanese POJ, Ukrainian, Spanish, and Turkish.
|
||||
|
||||
### Privacy and Security
|
||||
|
||||
- Removes pixel trackers.
|
||||
- Strips tracking parameters from URLs (e.g., `utm_source`, `utm_medium`, `utm_campaign`, `fbclid`, etc.).
|
||||
- Retrieves original links when feeds are sourced from FeedBurner.
|
||||
- Opens external links with attributes `rel="noopener noreferrer" referrerpolicy="no-referrer"` for improved security.
|
||||
- Implements the HTTP header `Referrer-Policy: no-referrer` to prevent referrer leakage.
|
||||
- Provides a media proxy to avoid tracking and resolve mixed content warnings when using HTTPS.
|
||||
- Plays YouTube videos via the privacy-focused domain `youtube-nocookie.com`.
|
||||
- Supports alternative YouTube video players such as [Invidious](https://invidio.us).
|
||||
- Blocks external JavaScript to prevent tracking and enhance security.
|
||||
- Sanitizes external content before rendering it.
|
||||
- Enforces a [Content Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) and a [Trusted Types Policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) to only application JavaScript and blocks inline scripts and styles.
|
||||
|
||||
### Bot Protection Bypass Mechanisms
|
||||
|
||||
- Optionally disable HTTP/2 to mitigate fingerprinting.
|
||||
- Allows configuration of a custom user agent.
|
||||
- Supports adding custom cookies for specific use cases.
|
||||
- Enables the use of proxies for enhanced privacy or bypassing restrictions.
|
||||
|
||||
### Content Manipulation
|
||||
|
||||
- Fetches the original article and extracts only the relevant content using a local Readability parser.
|
||||
- Allows custom scraper rules based on <abbr title="Cascading Style Sheets">CSS</abbr> selectors.
|
||||
- Supports custom rewriting rules for content manipulation.
|
||||
- Provides a regex filter to include or exclude articles based on specific patterns.
|
||||
- Optionally permits self-signed or invalid certificates (disabled by default).
|
||||
- Scrapes YouTube's website to retrieve video duration as read time or uses the YouTube API (disabled by default).
|
||||
|
||||
### User Interface
|
||||
|
||||
- Optimized stylesheet for readability.
|
||||
- Responsive design that adapts seamlessly to desktop, tablet, and mobile devices.
|
||||
- Minimalistic and distraction-free user interface.
|
||||
- No requirement to download an app from Apple App Store or Google Play Store.
|
||||
- Can be added directly to the home screen for quick access.
|
||||
- Supports a wide range of keyboard shortcuts for efficient navigation.
|
||||
- Optional touch gesture support for navigation on mobile devices.
|
||||
- Custom stylesheets and JavaScript to personalize the user interface to your preferences.
|
||||
- Themes:
|
||||
- Light (Sans-Serif)
|
||||
- Light (Serif)
|
||||
- Dark (Sans-Serif)
|
||||
- Dark (Serif)
|
||||
- System (Sans-Serif) – Automatically switches between Dark and Light themes based on system preferences.
|
||||
- System (Serif)
|
||||
|
||||
### Integrations
|
||||
|
||||
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
|
||||
- Bookmarklet for subscribing to websites directly from any web browser.
|
||||
- Webhooks for real-time notifications or custom integrations.
|
||||
- Compatibility with existing mobile applications using the Fever or Google Reader API.
|
||||
- REST API with client libraries available in [Go](https://github.com/miniflux/v2/tree/main/client) and [Python](https://github.com/miniflux/python-client).
|
||||
|
||||
### Authentication
|
||||
|
||||
- Local username and password.
|
||||
- Passkeys ([WebAuthn](https://en.wikipedia.org/wiki/WebAuthn)).
|
||||
- Google (OAuth2).
|
||||
- Generic OpenID Connect.
|
||||
- Reverse-Proxy authentication.
|
||||
|
||||
### Technical Stuff
|
||||
|
||||
- Written in [Go (Golang)](https://golang.org/).
|
||||
- Single binary compiled statically without dependency.
|
||||
- Works only with [PostgreSQL](https://www.postgresql.org/).
|
||||
- Does not use any ORM or any complicated frameworks.
|
||||
- Uses modern vanilla JavaScript only when necessary.
|
||||
- All static files are bundled into the application binary using the Go `embed` package.
|
||||
- Supports the Systemd `sd_notify` protocol for process monitoring.
|
||||
- Configures HTTPS automatically with Let's Encrypt.
|
||||
- Allows the use of custom <abbr title="Secure Sockets Layer">SSL</abbr> certificates.
|
||||
- Supports [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2) when TLS is enabled.
|
||||
- Updates feeds in the background using an internal scheduler or a traditional cron job.
|
||||
- Uses native lazy loading for images and iframes.
|
||||
- Compatible only with modern browsers.
|
||||
- Adheres to the [Twelve-Factor App](https://12factor.net/) methodology.
|
||||
- Provides official Debian/RPM packages and pre-built binaries.
|
||||
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM architecture support.
|
||||
- Uses a limited amount of third-party go dependencies
|
||||
- Has a comprehensive testsuite, with both unit tests and integration tests.
|
||||
- Only uses a couple of MB of memory and a negligible amount of CPU, even with several hundreds of feeds.
|
||||
- Respects/sends Last-Modified, If-Modified-Since, If-None-Match, Cache-Control, Expires and ETags headers, and has a default polling interval of 1h.
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
@@ -29,7 +123,7 @@ The Miniflux documentation is available here: <https://miniflux.app/docs/> ([Man
|
||||
- [Command Line Usage](https://miniflux.app/docs/cli.html)
|
||||
- [User Interface Usage](https://miniflux.app/docs/ui.html)
|
||||
- [Keyboard Shortcuts](https://miniflux.app/docs/keyboard_shortcuts.html)
|
||||
- [Integration with External Services](https://miniflux.app/docs/services.html)
|
||||
- [Integration with External Services](https://miniflux.app/docs/#integrations)
|
||||
- [Rewrite and Scraper Rules](https://miniflux.app/docs/rules.html)
|
||||
- [API Reference](https://miniflux.app/docs/api.html)
|
||||
- [Development](https://miniflux.app/docs/development.html)
|
||||
|
||||
+2
-2
@@ -27,10 +27,10 @@ import (
|
||||
|
||||
func main() {
|
||||
// Authentication with username/password:
|
||||
client := miniflux.New("https://api.example.org", "admin", "secret")
|
||||
client := miniflux.NewClient("https://api.example.org", "admin", "secret")
|
||||
|
||||
// Authentication with an API Key:
|
||||
client := miniflux.New("https://api.example.org", "my-secret-token")
|
||||
client := miniflux.NewClient("https://api.example.org", "my-secret-token")
|
||||
|
||||
// Fetch all feeds.
|
||||
feeds, err := client.Feeds()
|
||||
|
||||
+75
-4
@@ -18,6 +18,7 @@ type Client struct {
|
||||
}
|
||||
|
||||
// New returns a new Miniflux client.
|
||||
//
|
||||
// Deprecated: use NewClient instead.
|
||||
func New(endpoint string, credentials ...string) *Client {
|
||||
return NewClient(endpoint, credentials...)
|
||||
@@ -179,6 +180,45 @@ func (c *Client) DeleteUser(userID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/users/%d", userID))
|
||||
}
|
||||
|
||||
// APIKeys returns all API keys for the authenticated user.
|
||||
func (c *Client) APIKeys() (APIKeys, error) {
|
||||
body, err := c.request.Get("/v1/api-keys")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var apiKeys APIKeys
|
||||
if err := json.NewDecoder(body).Decode(&apiKeys); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return apiKeys, nil
|
||||
}
|
||||
|
||||
// CreateAPIKey creates a new API key for the authenticated user.
|
||||
func (c *Client) CreateAPIKey(description string) (*APIKey, error) {
|
||||
body, err := c.request.Post("/v1/api-keys", &APIKeyCreationRequest{
|
||||
Description: description,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var apiKey *APIKey
|
||||
if err := json.NewDecoder(body).Decode(&apiKey); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// DeleteAPIKey removes an API key for the authenticated user.
|
||||
func (c *Client) DeleteAPIKey(apiKeyID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/api-keys/%d", apiKeyID))
|
||||
}
|
||||
|
||||
// MarkAllAsRead marks all unread entries as read for a given user.
|
||||
func (c *Client) MarkAllAsRead(userID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
|
||||
@@ -238,8 +278,8 @@ func (c *Client) Categories() (Categories, error) {
|
||||
|
||||
// CreateCategory creates a new category.
|
||||
func (c *Client) CreateCategory(title string) (*Category, error) {
|
||||
body, err := c.request.Post("/v1/categories", map[string]interface{}{
|
||||
"title": title,
|
||||
body, err := c.request.Post("/v1/categories", &CategoryCreationRequest{
|
||||
Title: title,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -254,10 +294,25 @@ func (c *Client) CreateCategory(title string) (*Category, error) {
|
||||
return category, nil
|
||||
}
|
||||
|
||||
// CreateCategoryWithOptions creates a new category with options.
|
||||
func (c *Client) CreateCategoryWithOptions(createRequest *CategoryCreationRequest) (*Category, error) {
|
||||
body, err := c.request.Post("/v1/categories", createRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var category *Category
|
||||
if err := json.NewDecoder(body).Decode(&category); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
// UpdateCategory updates a category.
|
||||
func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), map[string]interface{}{
|
||||
"title": title,
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
|
||||
Title: SetOptionalField(title),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -272,6 +327,22 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro
|
||||
return category, nil
|
||||
}
|
||||
|
||||
// UpdateCategoryWithOptions updates a category with options.
|
||||
func (c *Client) UpdateCategoryWithOptions(categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/categories/%d", categoryID), categoryChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var category *Category
|
||||
if err := json.NewDecoder(body).Decode(&category); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return category, nil
|
||||
}
|
||||
|
||||
// MarkCategoryAsRead marks all unread entries in a category as read.
|
||||
func (c *Client) MarkCategoryAsRead(categoryID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/categories/%d/mark-all-as-read", categoryID), nil)
|
||||
|
||||
+108
-59
@@ -17,35 +17,37 @@ const (
|
||||
|
||||
// User represents a user in the system.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Theme string `json:"theme"`
|
||||
Language string `json:"language"`
|
||||
Timezone string `json:"timezone"`
|
||||
EntryDirection string `json:"entry_sorting_direction"`
|
||||
EntryOrder string `json:"entry_sorting_order"`
|
||||
Stylesheet string `json:"stylesheet"`
|
||||
CustomJS string `json:"custom_js"`
|
||||
GoogleID string `json:"google_id"`
|
||||
OpenIDConnectID string `json:"openid_connect_id"`
|
||||
EntriesPerPage int `json:"entries_per_page"`
|
||||
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime bool `json:"show_reading_time"`
|
||||
EntrySwipe bool `json:"entry_swipe"`
|
||||
GestureNav string `json:"gesture_nav"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
DisplayMode string `json:"display_mode"`
|
||||
DefaultReadingSpeed int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage string `json:"default_home_page"`
|
||||
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
||||
MarkReadOnView bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
ExternalFontHosts string `json:"external_font_hosts"`
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Theme string `json:"theme"`
|
||||
Language string `json:"language"`
|
||||
Timezone string `json:"timezone"`
|
||||
EntryDirection string `json:"entry_sorting_direction"`
|
||||
EntryOrder string `json:"entry_sorting_order"`
|
||||
Stylesheet string `json:"stylesheet"`
|
||||
CustomJS string `json:"custom_js"`
|
||||
GoogleID string `json:"google_id"`
|
||||
OpenIDConnectID string `json:"openid_connect_id"`
|
||||
EntriesPerPage int `json:"entries_per_page"`
|
||||
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime bool `json:"show_reading_time"`
|
||||
EntrySwipe bool `json:"entry_swipe"`
|
||||
GestureNav string `json:"gesture_nav"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
DisplayMode string `json:"display_mode"`
|
||||
DefaultReadingSpeed int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage string `json:"default_home_page"`
|
||||
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
||||
MarkReadOnView bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
ExternalFontHosts string `json:"external_font_hosts"`
|
||||
AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
|
||||
OpenExternalLinksInNewTab bool `json:"open_external_links_in_new_tab"`
|
||||
}
|
||||
|
||||
func (u User) String() string {
|
||||
@@ -63,33 +65,35 @@ type UserCreationRequest struct {
|
||||
|
||||
// UserModificationRequest represents the request to update a user.
|
||||
type UserModificationRequest struct {
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Theme *string `json:"theme"`
|
||||
Language *string `json:"language"`
|
||||
Timezone *string `json:"timezone"`
|
||||
EntryDirection *string `json:"entry_sorting_direction"`
|
||||
EntryOrder *string `json:"entry_sorting_order"`
|
||||
Stylesheet *string `json:"stylesheet"`
|
||||
CustomJS *string `json:"custom_js"`
|
||||
GoogleID *string `json:"google_id"`
|
||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||
EntriesPerPage *int `json:"entries_per_page"`
|
||||
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime *bool `json:"show_reading_time"`
|
||||
EntrySwipe *bool `json:"entry_swipe"`
|
||||
GestureNav *string `json:"gesture_nav"`
|
||||
DisplayMode *string `json:"display_mode"`
|
||||
DefaultReadingSpeed *int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed *int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage *string `json:"default_home_page"`
|
||||
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
||||
MarkReadOnView *bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate *float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
ExternalFontHosts *string `json:"external_font_hosts"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Theme *string `json:"theme"`
|
||||
Language *string `json:"language"`
|
||||
Timezone *string `json:"timezone"`
|
||||
EntryDirection *string `json:"entry_sorting_direction"`
|
||||
EntryOrder *string `json:"entry_sorting_order"`
|
||||
Stylesheet *string `json:"stylesheet"`
|
||||
CustomJS *string `json:"custom_js"`
|
||||
GoogleID *string `json:"google_id"`
|
||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||
EntriesPerPage *int `json:"entries_per_page"`
|
||||
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime *bool `json:"show_reading_time"`
|
||||
EntrySwipe *bool `json:"entry_swipe"`
|
||||
GestureNav *string `json:"gesture_nav"`
|
||||
DisplayMode *string `json:"display_mode"`
|
||||
DefaultReadingSpeed *int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed *int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage *string `json:"default_home_page"`
|
||||
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
||||
MarkReadOnView *bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate *float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
ExternalFontHosts *string `json:"external_font_hosts"`
|
||||
AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
|
||||
OpenExternalLinksInNewTab *bool `json:"open_external_links_in_new_tab"`
|
||||
}
|
||||
|
||||
// Users represents a list of users.
|
||||
@@ -97,9 +101,12 @@ type Users []User
|
||||
|
||||
// Category represents a feed category.
|
||||
type Category struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
HideGlobally bool `json:"hide_globally,omitempty"`
|
||||
FeedCount *int `json:"feed_count,omitempty"`
|
||||
TotalUnread *int `json:"total_unread,omitempty"`
|
||||
}
|
||||
|
||||
func (c Category) String() string {
|
||||
@@ -109,6 +116,18 @@ func (c Category) String() string {
|
||||
// Categories represents a list of categories.
|
||||
type Categories []*Category
|
||||
|
||||
// CategoryCreationRequest represents the request to create a category.
|
||||
type CategoryCreationRequest struct {
|
||||
Title string `json:"title"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
}
|
||||
|
||||
// CategoryModificationRequest represents the request to update a category.
|
||||
type CategoryModificationRequest struct {
|
||||
Title *string `json:"title"`
|
||||
HideGlobally *bool `json:"hide_globally"`
|
||||
}
|
||||
|
||||
// Subscription represents a feed subscription.
|
||||
type Subscription struct {
|
||||
Title string `json:"title"`
|
||||
@@ -141,8 +160,11 @@ type Feed struct {
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
ScraperRules string `json:"scraper_rules"`
|
||||
RewriteRules string `json:"rewrite_rules"`
|
||||
UrlRewriteRules string `json:"urlrewrite_rules"`
|
||||
BlocklistRules string `json:"blocklist_rules"`
|
||||
KeeplistRules string `json:"keeplist_rules"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
Crawler bool `json:"crawler"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Cookie string `json:"cookie"`
|
||||
@@ -151,6 +173,7 @@ type Feed struct {
|
||||
Category *Category `json:"category,omitempty"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
}
|
||||
|
||||
// FeedCreationRequest represents the request to create a feed.
|
||||
@@ -168,10 +191,14 @@ type FeedCreationRequest struct {
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
ScraperRules string `json:"scraper_rules"`
|
||||
RewriteRules string `json:"rewrite_rules"`
|
||||
UrlRewriteRules string `json:"urlrewrite_rules"`
|
||||
BlocklistRules string `json:"blocklist_rules"`
|
||||
KeeplistRules string `json:"keeplist_rules"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
}
|
||||
|
||||
// FeedModificationRequest represents the request to update a feed.
|
||||
@@ -181,8 +208,11 @@ type FeedModificationRequest struct {
|
||||
Title *string `json:"title"`
|
||||
ScraperRules *string `json:"scraper_rules"`
|
||||
RewriteRules *string `json:"rewrite_rules"`
|
||||
UrlRewriteRules *string `json:"urlrewrite_rules"`
|
||||
BlocklistRules *string `json:"blocklist_rules"`
|
||||
KeeplistRules *string `json:"keeplist_rules"`
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
Crawler *bool `json:"crawler"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
Cookie *string `json:"cookie"`
|
||||
@@ -195,6 +225,7 @@ type FeedModificationRequest struct {
|
||||
FetchViaProxy *bool `json:"fetch_via_proxy"`
|
||||
HideGlobally *bool `json:"hide_globally"`
|
||||
DisableHTTP2 *bool `json:"disable_http2"`
|
||||
ProxyURL *string `json:"proxy_url"`
|
||||
}
|
||||
|
||||
// FeedIcon represents the feed icon.
|
||||
@@ -307,6 +338,24 @@ type VersionResponse struct {
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
||||
// APIKey represents an application API key.
|
||||
type APIKey struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Token string `json:"token"`
|
||||
Description string `json:"description"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// APIKeys represents a collection of API keys.
|
||||
type APIKeys []*APIKey
|
||||
|
||||
// APIKeyCreationRequest represents the request to create an API key.
|
||||
type APIKeyCreationRequest struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func SetOptionalField[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
|
||||
|
||||
func (r *request) buildClient() http.Client {
|
||||
return http.Client{
|
||||
Timeout: time.Duration(defaultTimeout * time.Second),
|
||||
Timeout: defaultTimeout * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
# healthcheck:
|
||||
# test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
- ADMIN_PASSWORD=test123
|
||||
- BASE_URL=https://miniflux.example.org
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
|
||||
@@ -37,7 +37,7 @@ services:
|
||||
- "traefik.http.routers.miniflux.entrypoints=websecure"
|
||||
- "traefik.http.routers.miniflux.tls.certresolver=myresolver"
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
module miniflux.app/v2
|
||||
|
||||
// +heroku goVersion go1.23
|
||||
// +heroku goVersion go1.24
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.1
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/coreos/go-oidc/v3 v3.12.0
|
||||
github.com/go-webauthn/webauthn v0.11.2
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/go-webauthn/webauthn v0.13.4
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/tdewolff/minify/v2 v2.21.3
|
||||
golang.org/x/crypto v0.32.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.34.0
|
||||
golang.org/x/oauth2 v0.25.0
|
||||
golang.org/x/term v0.28.0
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/tdewolff/minify/v2 v2.23.10
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/image v0.29.0
|
||||
golang.org/x/net v0.42.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/term v0.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.1.14 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/google/go-tpm v0.9.1 // indirect
|
||||
github.com/go-webauthn/x v0.1.23 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.19 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.1 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
)
|
||||
|
||||
go 1.23
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
@@ -1,66 +1,64 @@
|
||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
|
||||
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
|
||||
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
|
||||
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
|
||||
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
||||
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.21.3 h1:KmhKNGrN/dGcvb2WDdB5yA49bo37s+hcD8RiF+lioV8=
|
||||
github.com/tdewolff/minify/v2 v2.21.3/go.mod h1:iGxHaGiONAnsYuo8CRyf8iPUcqRJVB/RhtEcTpqS7xw=
|
||||
github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg=
|
||||
github.com/tdewolff/parse/v2 v2.7.19/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.23.10 h1:puzRCH00Im+KDf+PxuuSmJykMTVd8Pp1HzTCxVutNmI=
|
||||
github.com/tdewolff/minify/v2 v2.23.10/go.mod h1:VW3ISUd3gDOZuQ/jwZr4sCzsuX+Qvsx87FDMjk6Rvno=
|
||||
github.com/tdewolff/parse/v2 v2.8.1 h1:J5GSHru6o3jF1uLlEKVXkDxxcVx6yzOlIVIotK4w2po=
|
||||
github.com/tdewolff/parse/v2 v2.8.1/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
@@ -72,10 +70,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
|
||||
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -90,10 +88,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -112,8 +110,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -123,8 +121,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -133,8 +131,9 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -142,7 +141,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -76,6 +76,9 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
||||
sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/integrations/status", handler.getIntegrationsStatus).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/api-keys", handler.createAPIKey).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/api-keys", handler.getAPIKeys).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/api-keys/{apiKeyID}", handler.deleteAPIKey).Methods(http.MethodDelete)
|
||||
}
|
||||
|
||||
func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -48,7 +48,7 @@ func newIntegrationTestConfig() *integrationTestConfig {
|
||||
testFeedURL: getDefaultEnvValues("TEST_MINIFLUX_FEED_URL", "https://miniflux.app/feed.xml"),
|
||||
testFeedTitle: getDefaultEnvValues("TEST_MINIFLUX_FEED_TITLE", "Miniflux"),
|
||||
testSubscriptionTitle: getDefaultEnvValues("TEST_MINIFLUX_SUBSCRIPTION_TITLE", "Miniflux Releases"),
|
||||
testWebsiteURL: getDefaultEnvValues("TEST_MINIFLUX_WEBSITE_URL", "https://miniflux.app"),
|
||||
testWebsiteURL: getDefaultEnvValues("TEST_MINIFLUX_WEBSITE_URL", "https://miniflux.app/"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,6 +729,116 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeysEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
apiKeys, err := regularUserClient.APIKeys()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(apiKeys) != 0 {
|
||||
t.Fatalf(`Expected no API keys, got %d`, len(apiKeys))
|
||||
}
|
||||
|
||||
// Create an API key for the user.
|
||||
apiKey, err := regularUserClient.CreateAPIKey("Test API Key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if apiKey.ID == 0 {
|
||||
t.Fatalf(`Invalid API key ID, got "%v"`, apiKey.ID)
|
||||
}
|
||||
if apiKey.UserID != regularTestUser.ID {
|
||||
t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKey.UserID, regularTestUser.ID)
|
||||
}
|
||||
if apiKey.Token == "" {
|
||||
t.Fatalf(`Invalid API key token, got "%v"`, apiKey.Token)
|
||||
}
|
||||
if apiKey.Description != "Test API Key" {
|
||||
t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKey.Description)
|
||||
}
|
||||
|
||||
// Create a duplicate API key with the same description.
|
||||
if _, err := regularUserClient.CreateAPIKey("Test API Key"); err == nil {
|
||||
t.Fatal(`Creating a duplicate API key with the same description should raise an error`)
|
||||
}
|
||||
|
||||
// Fetch the API keys again.
|
||||
apiKeys, err = regularUserClient.APIKeys()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(apiKeys) != 1 {
|
||||
t.Fatalf(`Expected 1 API key, got %d`, len(apiKeys))
|
||||
}
|
||||
if apiKeys[0].ID != apiKey.ID {
|
||||
t.Fatalf(`Invalid API key ID, got "%v" instead of "%v"`, apiKeys[0].ID, apiKey.ID)
|
||||
}
|
||||
if apiKeys[0].UserID != regularTestUser.ID {
|
||||
t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKeys[0].UserID, regularTestUser.ID)
|
||||
}
|
||||
if apiKeys[0].Token != apiKey.Token {
|
||||
t.Fatalf(`Invalid API key token, got "%v" instead of "%v"`, apiKeys[0].Token, apiKey.Token)
|
||||
}
|
||||
if apiKeys[0].Description != "Test API Key" {
|
||||
t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKeys[0].Description)
|
||||
}
|
||||
|
||||
// Create a new client using the API key.
|
||||
apiKeyClient := miniflux.NewClient(testConfig.testBaseURL, apiKey.Token)
|
||||
|
||||
// Fetch the user using the API key client.
|
||||
user, err := apiKeyClient.Me()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify the user matches the regular test user.
|
||||
if user.ID != regularTestUser.ID {
|
||||
t.Fatalf(`Expected user ID %d, got %d`, regularTestUser.ID, user.ID)
|
||||
}
|
||||
|
||||
// Delete the API key.
|
||||
if err := regularUserClient.DeleteAPIKey(apiKey.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify the API key is deleted.
|
||||
apiKeys, err = regularUserClient.APIKeys()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(apiKeys) != 0 {
|
||||
t.Fatalf(`Expected no API keys after deletion, got %d`, len(apiKeys))
|
||||
}
|
||||
|
||||
// Try to delete the API key again, it should return an error.
|
||||
err = regularUserClient.DeleteAPIKey(apiKey.ID)
|
||||
if err == nil {
|
||||
t.Fatal(`Deleting a non-existent API key should raise an error`)
|
||||
}
|
||||
if !errors.Is(err, miniflux.ErrNotFound) {
|
||||
t.Fatalf(`Expected "not found" error, got %v`, err)
|
||||
}
|
||||
|
||||
// Try to create an API key with an empty description.
|
||||
if _, err := regularUserClient.CreateAPIKey(""); err == nil {
|
||||
t.Fatal(`Creating an API key with an empty description should raise an error`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkUserAsReadEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
@@ -824,6 +934,10 @@ func TestCreateCategoryEndpoint(t *testing.T) {
|
||||
if category.Title != categoryName {
|
||||
t.Errorf(`Invalid title, got "%v" instead of "%v"`, category.Title, categoryName)
|
||||
}
|
||||
|
||||
if category.HideGlobally {
|
||||
t.Errorf(`Invalid hide globally value, got "%v"`, category.HideGlobally)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCategoryWithEmptyTitle(t *testing.T) {
|
||||
@@ -865,7 +979,49 @@ func TestCannotCreateDuplicatedCategory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCatgoryEndpoint(t *testing.T) {
|
||||
func TestCreateCategoryWithOptions(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
newCategory, err := regularUserClient.CreateCategoryWithOptions(&miniflux.CategoryCreationRequest{
|
||||
Title: "My category",
|
||||
HideGlobally: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf(`Creating a category with options should not raise an error: %v`, err)
|
||||
}
|
||||
|
||||
categories, err := regularUserClient.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, category := range categories {
|
||||
if category.ID == newCategory.ID {
|
||||
if category.Title != newCategory.Title {
|
||||
t.Errorf(`Invalid title, got %q instead of %q`, category.Title, newCategory.Title)
|
||||
}
|
||||
if category.HideGlobally != true {
|
||||
t.Errorf(`Invalid hide globally value, got "%v"`, category.HideGlobally)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCategoryEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
@@ -903,6 +1059,91 @@ func TestUpdateCatgoryEndpoint(t *testing.T) {
|
||||
if updatedCategory.Title != "new title" {
|
||||
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
|
||||
}
|
||||
|
||||
if updatedCategory.HideGlobally {
|
||||
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCategoryWithOptions(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
newCategory, err := regularUserClient.CreateCategoryWithOptions(&miniflux.CategoryCreationRequest{
|
||||
Title: "My category",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf(`Creating a category with options should not raise an error: %v`, err)
|
||||
}
|
||||
|
||||
updatedCategory, err := regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
|
||||
Title: miniflux.SetOptionalField("new title"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedCategory.ID != newCategory.ID {
|
||||
t.Errorf(`Invalid categoryID, got "%v"`, updatedCategory.ID)
|
||||
}
|
||||
|
||||
if updatedCategory.Title != "new title" {
|
||||
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
|
||||
}
|
||||
|
||||
if updatedCategory.HideGlobally {
|
||||
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
|
||||
}
|
||||
|
||||
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
|
||||
HideGlobally: miniflux.SetOptionalField(true),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedCategory.ID != newCategory.ID {
|
||||
t.Errorf(`Invalid categoryID, got "%v"`, updatedCategory.ID)
|
||||
}
|
||||
|
||||
if updatedCategory.Title != "new title" {
|
||||
t.Errorf(`Invalid title, got "%v" instead of "%v"`, updatedCategory.Title, "new title")
|
||||
}
|
||||
|
||||
if !updatedCategory.HideGlobally {
|
||||
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
|
||||
}
|
||||
|
||||
updatedCategory, err = regularUserClient.UpdateCategoryWithOptions(newCategory.ID, &miniflux.CategoryModificationRequest{
|
||||
HideGlobally: miniflux.SetOptionalField(false),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedCategory.ID != newCategory.ID {
|
||||
t.Errorf(`Invalid categoryID, got %q`, updatedCategory.ID)
|
||||
}
|
||||
|
||||
if updatedCategory.Title != "new title" {
|
||||
t.Errorf(`Invalid title, got %q instead of %q`, updatedCategory.Title, "new title")
|
||||
}
|
||||
|
||||
if updatedCategory.HideGlobally {
|
||||
t.Errorf(`Invalid hide globally value, got "%v"`, updatedCategory.HideGlobally)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInexistingCategory(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
var apiKeyCreationRequest model.APIKeyCreationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&apiKeyCreationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateAPIKeyCreation(h.store, userID, &apiKeyCreationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := h.store.CreateAPIKey(userID, apiKeyCreationRequest.Description)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, apiKey)
|
||||
}
|
||||
|
||||
func (h *handler) getAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
apiKeys, err := h.store.APIKeys(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
json.OK(w, r, apiKeys)
|
||||
}
|
||||
|
||||
func (h *handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
apiKeyID := request.RouteInt64Param(r, "apiKeyID")
|
||||
|
||||
if err := h.store.DeleteAPIKey(userID, apiKeyID); err != nil {
|
||||
if errors.Is(err, storage.ErrAPIKeyNotFound) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
+10
-10
@@ -19,18 +19,18 @@ import (
|
||||
func (h *handler) createCategory(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
var categoryRequest model.CategoryRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&categoryRequest); err != nil {
|
||||
var categoryCreationRequest model.CategoryCreationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&categoryCreationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryRequest); validationErr != nil {
|
||||
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryCreationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.store.CreateCategory(userID, &categoryRequest)
|
||||
category, err := h.store.CreateCategory(userID, &categoryCreationRequest)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -54,20 +54,20 @@ func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var categoryRequest model.CategoryRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&categoryRequest); err != nil {
|
||||
var categoryModificationRequest model.CategoryModificationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&categoryModificationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryRequest); validationErr != nil {
|
||||
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
categoryRequest.Patch(category)
|
||||
err = h.store.UpdateCategory(category)
|
||||
if err != nil {
|
||||
categoryModificationRequest.Patch(category)
|
||||
|
||||
if err := h.store.UpdateCategory(category); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
json_parser "encoding/json"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/model"
|
||||
@@ -33,7 +34,7 @@ func (h *handler) getEnclosureByID(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.ProxifyEnclosureURL(h.router)
|
||||
enclosure.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
json.OK(w, r, enclosure)
|
||||
}
|
||||
|
||||
+14
-2
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/integration"
|
||||
@@ -34,8 +35,7 @@ func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b
|
||||
}
|
||||
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
|
||||
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router)
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
json.OK(w, r, entry)
|
||||
}
|
||||
@@ -331,6 +331,18 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
shouldUpdateContent := request.QueryBoolParam(r, "update_content", false)
|
||||
if shouldUpdateContent {
|
||||
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, map[string]interface{}{"content": mediaproxy.RewriteDocumentWithRelativeProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, map[string]string{"content": entry.Content})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/proxyrotator"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
"miniflux.app/v2/internal/reader/subscription"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
@@ -29,24 +30,29 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
var rssbridgeURL string
|
||||
var rssbridgeToken string
|
||||
intg, err := h.store.Integration(request.UserID(r))
|
||||
if err == nil && intg != nil && intg.RSSBridgeEnabled {
|
||||
rssbridgeURL = intg.RSSBridgeURL
|
||||
rssbridgeToken = intg.RSSBridgeToken
|
||||
}
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
requestBuilder.WithCustomFeedProxyURL(subscriptionDiscoveryRequest.ProxyURL)
|
||||
requestBuilder.WithCustomApplicationProxyURL(config.Opts.HTTPClientProxyURL())
|
||||
requestBuilder.UseCustomApplicationProxyURL(subscriptionDiscoveryRequest.FetchViaProxy)
|
||||
requestBuilder.WithUserAgent(subscriptionDiscoveryRequest.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(subscriptionDiscoveryRequest.Cookie)
|
||||
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
|
||||
requestBuilder.UseProxy(subscriptionDiscoveryRequest.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
|
||||
|
||||
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
|
||||
subscriptionDiscoveryRequest.URL,
|
||||
rssbridgeURL,
|
||||
rssbridgeToken,
|
||||
)
|
||||
|
||||
if localizedError != nil {
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
@@ -84,18 +82,6 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
cleanEnd := regexp.MustCompile(`(?m)\r\n\s*$`)
|
||||
if userModificationRequest.BlockFilterEntryRules != nil {
|
||||
*userModificationRequest.BlockFilterEntryRules = cleanEnd.ReplaceAllLiteralString(*userModificationRequest.BlockFilterEntryRules, "")
|
||||
// Clean carriage returns for Windows environments
|
||||
*userModificationRequest.BlockFilterEntryRules = strings.ReplaceAll(*userModificationRequest.BlockFilterEntryRules, "\r\n", "\n")
|
||||
}
|
||||
if userModificationRequest.KeepFilterEntryRules != nil {
|
||||
*userModificationRequest.KeepFilterEntryRules = cleanEnd.ReplaceAllLiteralString(*userModificationRequest.KeepFilterEntryRules, "")
|
||||
// Clean carriage returns for Windows environments
|
||||
*userModificationRequest.KeepFilterEntryRules = strings.ReplaceAll(*userModificationRequest.KeepFilterEntryRules, "\r\n", "\n")
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
|
||||
+51
-30
@@ -13,46 +13,49 @@ import (
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/database"
|
||||
"miniflux.app/v2/internal/proxyrotator"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/static"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
flagInfoHelp = "Show build information"
|
||||
flagVersionHelp = "Show application version"
|
||||
flagMigrateHelp = "Run SQL migrations"
|
||||
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
|
||||
flagCreateAdminHelp = "Create an admin user from an interactive terminal"
|
||||
flagResetPasswordHelp = "Reset user password"
|
||||
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
|
||||
flagDebugModeHelp = "Show debug logs"
|
||||
flagConfigFileHelp = "Load configuration file"
|
||||
flagConfigDumpHelp = "Print parsed configuration values"
|
||||
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" try to guess the health check endpoint).`
|
||||
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
|
||||
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archives old entries)"
|
||||
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
|
||||
flagInfoHelp = "Show build information"
|
||||
flagVersionHelp = "Show application version"
|
||||
flagMigrateHelp = "Run SQL migrations"
|
||||
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
|
||||
flagCreateAdminHelp = "Create an admin user from an interactive terminal"
|
||||
flagResetPasswordHelp = "Reset user password"
|
||||
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
|
||||
flagDebugModeHelp = "Show debug logs"
|
||||
flagConfigFileHelp = "Load configuration file"
|
||||
flagConfigDumpHelp = "Print parsed configuration values"
|
||||
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" try to guess the health check endpoint).`
|
||||
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
|
||||
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archives old entries)"
|
||||
flagExportUserFeedsHelp = "Export user feeds (provide the username as argument)"
|
||||
flagResetNextCheckAtHelp = "Reset the next check time for all feeds"
|
||||
)
|
||||
|
||||
// Parse parses command line arguments.
|
||||
func Parse() {
|
||||
var (
|
||||
err error
|
||||
flagInfo bool
|
||||
flagVersion bool
|
||||
flagMigrate bool
|
||||
flagFlushSessions bool
|
||||
flagCreateAdmin bool
|
||||
flagResetPassword bool
|
||||
flagResetFeedErrors bool
|
||||
flagDebugMode bool
|
||||
flagConfigFile string
|
||||
flagConfigDump bool
|
||||
flagHealthCheck string
|
||||
flagRefreshFeeds bool
|
||||
flagRunCleanupTasks bool
|
||||
flagExportUserFeeds string
|
||||
err error
|
||||
flagInfo bool
|
||||
flagVersion bool
|
||||
flagMigrate bool
|
||||
flagFlushSessions bool
|
||||
flagCreateAdmin bool
|
||||
flagResetPassword bool
|
||||
flagResetFeedErrors bool
|
||||
flagResetFeedNextCheckAt bool
|
||||
flagDebugMode bool
|
||||
flagConfigFile string
|
||||
flagConfigDump bool
|
||||
flagHealthCheck string
|
||||
flagRefreshFeeds bool
|
||||
flagRunCleanupTasks bool
|
||||
flagExportUserFeeds string
|
||||
)
|
||||
|
||||
flag.BoolVar(&flagInfo, "info", false, flagInfoHelp)
|
||||
@@ -64,6 +67,7 @@ func Parse() {
|
||||
flag.BoolVar(&flagCreateAdmin, "create-admin", false, flagCreateAdminHelp)
|
||||
flag.BoolVar(&flagResetPassword, "reset-password", false, flagResetPasswordHelp)
|
||||
flag.BoolVar(&flagResetFeedErrors, "reset-feed-errors", false, flagResetFeedErrorsHelp)
|
||||
flag.BoolVar(&flagResetFeedNextCheckAt, "reset-feed-next-check-at", false, flagResetNextCheckAtHelp)
|
||||
flag.BoolVar(&flagDebugMode, "debug", false, flagDebugModeHelp)
|
||||
flag.StringVar(&flagConfigFile, "config-file", "", flagConfigFileHelp)
|
||||
flag.StringVar(&flagConfigFile, "c", "", flagConfigFileHelp)
|
||||
@@ -189,7 +193,16 @@ func Parse() {
|
||||
}
|
||||
|
||||
if flagResetFeedErrors {
|
||||
store.ResetFeedErrors()
|
||||
if err := store.ResetFeedErrors(); err != nil {
|
||||
printErrorAndExit(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if flagResetFeedNextCheckAt {
|
||||
if err := store.ResetNextCheckAt(); err != nil {
|
||||
printErrorAndExit(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -228,6 +241,14 @@ func Parse() {
|
||||
createAdminUserFromEnvironmentVariables(store)
|
||||
}
|
||||
|
||||
if config.Opts.HasHTTPClientProxiesConfigured() {
|
||||
slog.Info("Initializing proxy rotation", slog.Int("proxies_count", len(config.Opts.HTTPClientProxies())))
|
||||
proxyrotator.ProxyRotatorInstance, err = proxyrotator.NewProxyRotator(config.Opts.HTTPClientProxies())
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to initialize proxy rotator: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if flagRefreshFeeds {
|
||||
refreshFeeds(store)
|
||||
return
|
||||
|
||||
+15
-5
@@ -13,7 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
httpd "miniflux.app/v2/internal/http/server"
|
||||
"miniflux.app/v2/internal/http/server"
|
||||
"miniflux.app/v2/internal/metric"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/systemd"
|
||||
@@ -33,9 +33,9 @@ func startDaemon(store *storage.Storage) {
|
||||
runScheduler(store, pool)
|
||||
}
|
||||
|
||||
var httpServer *http.Server
|
||||
var httpServers []*http.Server
|
||||
if config.Opts.HasHTTPService() {
|
||||
httpServer = httpd.StartWebServer(store, pool)
|
||||
httpServers = server.StartWebServer(store, pool)
|
||||
}
|
||||
|
||||
if config.Opts.HasMetricsCollector() {
|
||||
@@ -78,8 +78,18 @@ func startDaemon(store *storage.Storage) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if httpServer != nil {
|
||||
httpServer.Shutdown(ctx)
|
||||
if len(httpServers) > 0 {
|
||||
slog.Debug("Shutting down HTTP servers...")
|
||||
for _, server := range httpServers {
|
||||
if server != nil {
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
slog.Error("HTTP server shutdown error", slog.Any("error", err), slog.String("addr", server.Addr))
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Debug("All HTTP servers shut down.")
|
||||
} else {
|
||||
slog.Debug("No HTTP servers to shut down.")
|
||||
}
|
||||
|
||||
slog.Debug("Process gracefully stopped")
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func doHealthCheck(healthCheckEndpoint string) {
|
||||
if healthCheckEndpoint == "auto" {
|
||||
healthCheckEndpoint = "http://" + config.Opts.ListenAddr() + config.Opts.BasePath() + "/healthcheck"
|
||||
healthCheckEndpoint = "http://" + config.Opts.ListenAddr()[0] + config.Opts.BasePath() + "/healthcheck"
|
||||
}
|
||||
|
||||
slog.Debug("Executing health check request", slog.String("endpoint", healthCheckEndpoint))
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
// Opts holds parsed configuration options.
|
||||
var Opts *Options
|
||||
var Opts *options
|
||||
|
||||
+145
-227
@@ -6,6 +6,7 @@ package config // import "miniflux.app/v2/internal/config"
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -184,35 +185,6 @@ func TestLogFormatWithInvalidValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugModeOn(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("DEBUG", "1")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
if opts.LogLevel() != "debug" {
|
||||
t.Fatalf(`Unexpected debug mode value, got %q`, opts.LogLevel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugModeOff(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
if opts.LogLevel() != "info" {
|
||||
t.Fatalf(`Unexpected debug mode value, got %q`, opts.LogLevel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomBaseURL(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("BASE_URL", "http://example.org")
|
||||
@@ -457,18 +429,18 @@ func TestListenAddr(t *testing.T) {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "foobar"
|
||||
expected := []string{"foobar"}
|
||||
result := opts.ListenAddr()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected LISTEN_ADDR value, got %q instead of %q`, result, expected)
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Fatalf(`Unexpected LISTEN_ADDR value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAddrWithPortDefined(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PORT", "3000")
|
||||
os.Setenv("LISTEN_ADDR", "foobar")
|
||||
os.Setenv("LISTEN_ADDR", "foobar") // This should be overridden by PORT
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
@@ -476,11 +448,11 @@ func TestListenAddrWithPortDefined(t *testing.T) {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := ":3000"
|
||||
expected := []string{":3000"}
|
||||
result := opts.ListenAddr()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected LISTEN_ADDR value, got %q instead of %q`, result, expected)
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Fatalf(`Unexpected LISTEN_ADDR value when PORT is set, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,11 +465,11 @@ func TestDefaultListenAddrValue(t *testing.T) {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := defaultListenAddr
|
||||
expected := []string{defaultListenAddr}
|
||||
result := opts.ListenAddr()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected LISTEN_ADDR value, got %q instead of %q`, result, expected)
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Fatalf(`Unexpected default LISTEN_ADDR value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1028,6 +1000,41 @@ func TestSchedulerRoundRobin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSchedulerRoundRobinMaxIntervalValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := defaultSchedulerRoundRobinMaxInterval
|
||||
result := opts.SchedulerRoundRobinMaxInterval()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerRoundRobinMaxInterval(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("SCHEDULER_ROUND_ROBIN_MAX_INTERVAL", "150")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := 150
|
||||
result := opts.SchedulerRoundRobinMaxInterval()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollingParsingErrorLimit(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("POLLING_PARSING_ERROR_LIMIT", "100")
|
||||
@@ -1431,41 +1438,6 @@ func TestCreateAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPocketConsumerKeyFromEnvVariable(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("POCKET_CONSUMER_KEY", "something")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "something"
|
||||
result := opts.PocketConsumerKey("default")
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPocketConsumerKeyFromUserPrefs(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "default"
|
||||
result := opts.PocketConsumerKey("default")
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaProxyMode(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("MEDIA_PROXY_MODE", "all")
|
||||
@@ -1652,147 +1624,6 @@ func TestMediaProxyPrivateKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyImagesOptionForBackwardCompatibility(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_IMAGES", "all")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := []string{"image"}
|
||||
if len(expected) != len(opts.MediaProxyResourceTypes()) {
|
||||
t.Fatalf(`Unexpected PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||
}
|
||||
|
||||
resultMap := make(map[string]bool)
|
||||
for _, mediaType := range opts.MediaProxyResourceTypes() {
|
||||
resultMap[mediaType] = true
|
||||
}
|
||||
|
||||
for _, mediaType := range expected {
|
||||
if !resultMap[mediaType] {
|
||||
t.Fatalf(`Unexpected PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
expectedProxyOption := "all"
|
||||
result := opts.MediaProxyMode()
|
||||
if result != expectedProxyOption {
|
||||
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expectedProxyOption)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyImageURLForBackwardCompatibility(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_IMAGE_URL", "http://example.org/proxy")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "http://example.org/proxy"
|
||||
result := opts.MediaCustomProxyURL()
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected PROXY_IMAGE_URL value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyURLOptionForBackwardCompatibility(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_URL", "http://example.org/proxy")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "http://example.org/proxy"
|
||||
result := opts.MediaCustomProxyURL()
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected PROXY_URL value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyMediaTypesOptionForBackwardCompatibility(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_MEDIA_TYPES", "image,audio")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
expected := []string{"audio", "image"}
|
||||
if len(expected) != len(opts.MediaProxyResourceTypes()) {
|
||||
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||
}
|
||||
|
||||
resultMap := make(map[string]bool)
|
||||
for _, mediaType := range opts.MediaProxyResourceTypes() {
|
||||
resultMap[mediaType] = true
|
||||
}
|
||||
|
||||
for _, mediaType := range expected {
|
||||
if !resultMap[mediaType] {
|
||||
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyOptionForBackwardCompatibility(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_OPTION", "all")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
expected := "all"
|
||||
result := opts.MediaProxyMode()
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHTTPClientTimeoutOptionForBackwardCompatibility(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_HTTP_CLIENT_TIMEOUT", "24")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
expected := 24
|
||||
result := opts.MediaProxyHTTPClientTimeout()
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyPrivateKeyOptionForBackwardCompatibility(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_PRIVATE_KEY", "foobar")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
expected := []byte("foobar")
|
||||
result := opts.MediaProxyPrivateKey()
|
||||
if !bytes.Equal(result, expected) {
|
||||
t.Fatalf(`Unexpected PROXY_PRIVATE_KEY value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSOff(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
@@ -1931,9 +1762,7 @@ func TestParseConfigFile(t *testing.T) {
|
||||
content := []byte(`
|
||||
# This is a comment
|
||||
|
||||
DEBUG = yes
|
||||
|
||||
POCKET_CONSUMER_KEY= >#1234
|
||||
LOG_LEVEL = debug
|
||||
|
||||
Invalid text
|
||||
`)
|
||||
@@ -1956,13 +1785,7 @@ Invalid text
|
||||
}
|
||||
|
||||
if opts.LogLevel() != "debug" {
|
||||
t.Errorf(`Unexpected debug mode value, got %q`, opts.LogLevel())
|
||||
}
|
||||
|
||||
expected := ">#1234"
|
||||
result := opts.PocketConsumerKey("default")
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
|
||||
t.Errorf(`Unexpected log level value, got %q`, opts.LogLevel())
|
||||
}
|
||||
|
||||
if err := tmpfile.Close(); err != nil {
|
||||
@@ -2134,12 +1957,33 @@ func TestYouTubeApiKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultYouTubeEmbedUrl(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
opts, err := NewParser().ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "https://www.youtube-nocookie.com/embed/"
|
||||
result := opts.YouTubeEmbedUrlOverride()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected default value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
expected = "www.youtube-nocookie.com"
|
||||
result = opts.YouTubeEmbedDomain()
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected YouTube embed domain, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeEmbedUrlOverride(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
opts, err := NewParser().ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
@@ -2150,6 +1994,12 @@ func TestYouTubeEmbedUrlOverride(t *testing.T) {
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected YOUTUBE_EMBED_URL_OVERRIDE value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
expected = "invidious.custom"
|
||||
result = opts.YouTubeEmbedDomain()
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected YouTube embed domain, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigDumpOutput(t *testing.T) {
|
||||
@@ -2186,3 +2036,71 @@ func TestParseConfigDumpOutput(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientProxies(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("HTTP_CLIENT_PROXIES", "http://proxy1.example.com,http://proxy2.example.com")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := []string{"http://proxy1.example.com", "http://proxy2.example.com"}
|
||||
result := opts.HTTPClientProxies()
|
||||
|
||||
if len(expected) != len(result) {
|
||||
t.Fatalf(`Unexpected HTTP_CLIENT_PROXIES value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
for i, proxy := range expected {
|
||||
if result[i] != proxy {
|
||||
t.Fatalf(`Unexpected HTTP_CLIENT_PROXIES value at index %d, got %q instead of %q`, i, result[i], proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultHTTPClientProxiesValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := []string{}
|
||||
result := opts.HTTPClientProxies()
|
||||
|
||||
if len(expected) != len(result) {
|
||||
t.Fatalf(`Unexpected default HTTP_CLIENT_PROXIES value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientProxy(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("HTTP_CLIENT_PROXY", "http://proxy.example.com")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "http://proxy.example.com"
|
||||
if opts.HTTPClientProxyURL() == nil || opts.HTTPClientProxyURL().String() != expected {
|
||||
t.Fatalf(`Unexpected HTTP_CLIENT_PROXY value, got %v instead of %v`, opts.HTTPClientProxyURL(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidHTTPClientProxy(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("HTTP_CLIENT_PROXY", "sche|me://invalid-proxy-url")
|
||||
|
||||
parser := NewParser()
|
||||
_, err := parser.ParseEnvironmentVariables()
|
||||
if err == nil {
|
||||
t.Fatalf(`Expected error for invalid HTTP_CLIENT_PROXY value, but got none`)
|
||||
}
|
||||
}
|
||||
|
||||
+164
-132
@@ -5,7 +5,9 @@ package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"maps"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,8 +24,6 @@ const (
|
||||
defaultHSTS = true
|
||||
defaultHTTPService = true
|
||||
defaultSchedulerService = true
|
||||
defaultDebug = false
|
||||
defaultTiming = false
|
||||
defaultBaseURL = "http://localhost"
|
||||
defaultRootURL = "http://localhost"
|
||||
defaultBasePath = ""
|
||||
@@ -36,6 +36,7 @@ const (
|
||||
defaultSchedulerEntryFrequencyMaxInterval = 24 * 60
|
||||
defaultSchedulerEntryFrequencyFactor = 1
|
||||
defaultSchedulerRoundRobinMinInterval = 60
|
||||
defaultSchedulerRoundRobinMaxInterval = 1440
|
||||
defaultPollingParsingErrorLimit = 3
|
||||
defaultRunMigrations = false
|
||||
defaultDatabaseURL = "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
|
||||
@@ -73,7 +74,6 @@ const (
|
||||
defaultOauth2OidcProviderName = "OpenID Connect"
|
||||
defaultOAuth2Provider = ""
|
||||
defaultDisableLocalAuth = false
|
||||
defaultPocketConsumerKey = ""
|
||||
defaultHTTPClientTimeout = 20
|
||||
defaultHTTPClientMaxBodySize = 15
|
||||
defaultHTTPClientProxy = ""
|
||||
@@ -94,14 +94,14 @@ const (
|
||||
|
||||
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
|
||||
|
||||
// Option contains a key to value map of a single option. It may be used to output debug strings.
|
||||
type Option struct {
|
||||
// option contains a key to value map of a single option. It may be used to output debug strings.
|
||||
type option struct {
|
||||
Key string
|
||||
Value interface{}
|
||||
Value any
|
||||
}
|
||||
|
||||
// Options contains configuration options.
|
||||
type Options struct {
|
||||
// options contains configuration options.
|
||||
type options struct {
|
||||
HTTPS bool
|
||||
logFile string
|
||||
logDateTime bool
|
||||
@@ -110,7 +110,6 @@ type Options struct {
|
||||
hsts bool
|
||||
httpService bool
|
||||
schedulerService bool
|
||||
serverTimingHeader bool
|
||||
baseURL string
|
||||
rootURL string
|
||||
basePath string
|
||||
@@ -119,7 +118,7 @@ type Options struct {
|
||||
databaseMinConns int
|
||||
databaseConnectionLifetime int
|
||||
runMigrations bool
|
||||
listenAddr string
|
||||
listenAddr []string
|
||||
certFile string
|
||||
certDomain string
|
||||
certKeyFile string
|
||||
@@ -136,6 +135,7 @@ type Options struct {
|
||||
schedulerEntryFrequencyMaxInterval int
|
||||
schedulerEntryFrequencyFactor int
|
||||
schedulerRoundRobinMinInterval int
|
||||
schedulerRoundRobinMaxInterval int
|
||||
pollingParsingErrorLimit int
|
||||
workerPoolSize int
|
||||
createAdmin bool
|
||||
@@ -152,6 +152,7 @@ type Options struct {
|
||||
filterEntryMaxAgeDays int
|
||||
youTubeApiKey string
|
||||
youTubeEmbedUrlOverride string
|
||||
youTubeEmbedDomain string
|
||||
oauth2UserCreationAllowed bool
|
||||
oauth2ClientID string
|
||||
oauth2ClientSecret string
|
||||
@@ -160,10 +161,10 @@ type Options struct {
|
||||
oidcProviderName string
|
||||
oauth2Provider string
|
||||
disableLocalAuth bool
|
||||
pocketConsumerKey string
|
||||
httpClientTimeout int
|
||||
httpClientMaxBodySize int64
|
||||
httpClientProxy string
|
||||
httpClientProxyURL *url.URL
|
||||
httpClientProxies []string
|
||||
httpClientUserAgent string
|
||||
httpServerTimeout int
|
||||
authProxyHeader string
|
||||
@@ -182,8 +183,8 @@ type Options struct {
|
||||
}
|
||||
|
||||
// NewOptions returns Options with default values.
|
||||
func NewOptions() *Options {
|
||||
return &Options{
|
||||
func NewOptions() *options {
|
||||
return &options{
|
||||
HTTPS: defaultHTTPS,
|
||||
logFile: defaultLogFile,
|
||||
logDateTime: defaultLogDateTime,
|
||||
@@ -192,7 +193,6 @@ func NewOptions() *Options {
|
||||
hsts: defaultHSTS,
|
||||
httpService: defaultHTTPService,
|
||||
schedulerService: defaultSchedulerService,
|
||||
serverTimingHeader: defaultTiming,
|
||||
baseURL: defaultBaseURL,
|
||||
rootURL: defaultRootURL,
|
||||
basePath: defaultBasePath,
|
||||
@@ -201,7 +201,7 @@ func NewOptions() *Options {
|
||||
databaseMinConns: defaultDatabaseMinConns,
|
||||
databaseConnectionLifetime: defaultDatabaseConnectionLifetime,
|
||||
runMigrations: defaultRunMigrations,
|
||||
listenAddr: defaultListenAddr,
|
||||
listenAddr: []string{defaultListenAddr},
|
||||
certFile: defaultCertFile,
|
||||
certDomain: defaultCertDomain,
|
||||
certKeyFile: defaultKeyFile,
|
||||
@@ -218,6 +218,7 @@ func NewOptions() *Options {
|
||||
schedulerEntryFrequencyMaxInterval: defaultSchedulerEntryFrequencyMaxInterval,
|
||||
schedulerEntryFrequencyFactor: defaultSchedulerEntryFrequencyFactor,
|
||||
schedulerRoundRobinMinInterval: defaultSchedulerRoundRobinMinInterval,
|
||||
schedulerRoundRobinMaxInterval: defaultSchedulerRoundRobinMaxInterval,
|
||||
pollingParsingErrorLimit: defaultPollingParsingErrorLimit,
|
||||
workerPoolSize: defaultWorkerPoolSize,
|
||||
createAdmin: defaultCreateAdmin,
|
||||
@@ -240,10 +241,10 @@ func NewOptions() *Options {
|
||||
oidcProviderName: defaultOauth2OidcProviderName,
|
||||
oauth2Provider: defaultOAuth2Provider,
|
||||
disableLocalAuth: defaultDisableLocalAuth,
|
||||
pocketConsumerKey: defaultPocketConsumerKey,
|
||||
httpClientTimeout: defaultHTTPClientTimeout,
|
||||
httpClientMaxBodySize: defaultHTTPClientMaxBodySize * 1024 * 1024,
|
||||
httpClientProxy: defaultHTTPClientProxy,
|
||||
httpClientProxyURL: nil,
|
||||
httpClientProxies: []string{},
|
||||
httpClientUserAgent: defaultHTTPClientUserAgent,
|
||||
httpServerTimeout: defaultHTTPServerTimeout,
|
||||
authProxyHeader: defaultAuthProxyHeader,
|
||||
@@ -262,408 +263,444 @@ func NewOptions() *Options {
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Options) LogFile() string {
|
||||
func (o *options) LogFile() string {
|
||||
return o.logFile
|
||||
}
|
||||
|
||||
// LogDateTime returns true if the date/time should be displayed in log messages.
|
||||
func (o *Options) LogDateTime() bool {
|
||||
func (o *options) LogDateTime() bool {
|
||||
return o.logDateTime
|
||||
}
|
||||
|
||||
// LogFormat returns the log format.
|
||||
func (o *Options) LogFormat() string {
|
||||
func (o *options) LogFormat() string {
|
||||
return o.logFormat
|
||||
}
|
||||
|
||||
// LogLevel returns the log level.
|
||||
func (o *Options) LogLevel() string {
|
||||
func (o *options) LogLevel() string {
|
||||
return o.logLevel
|
||||
}
|
||||
|
||||
// SetLogLevel sets the log level.
|
||||
func (o *Options) SetLogLevel(level string) {
|
||||
func (o *options) SetLogLevel(level string) {
|
||||
o.logLevel = level
|
||||
}
|
||||
|
||||
// HasMaintenanceMode returns true if maintenance mode is enabled.
|
||||
func (o *Options) HasMaintenanceMode() bool {
|
||||
func (o *options) HasMaintenanceMode() bool {
|
||||
return o.maintenanceMode
|
||||
}
|
||||
|
||||
// MaintenanceMessage returns maintenance message.
|
||||
func (o *Options) MaintenanceMessage() string {
|
||||
func (o *options) MaintenanceMessage() string {
|
||||
return o.maintenanceMessage
|
||||
}
|
||||
|
||||
// HasServerTimingHeader returns true if server-timing headers enabled.
|
||||
func (o *Options) HasServerTimingHeader() bool {
|
||||
return o.serverTimingHeader
|
||||
}
|
||||
|
||||
// BaseURL returns the application base URL with path.
|
||||
func (o *Options) BaseURL() string {
|
||||
func (o *options) BaseURL() string {
|
||||
return o.baseURL
|
||||
}
|
||||
|
||||
// RootURL returns the base URL without path.
|
||||
func (o *Options) RootURL() string {
|
||||
func (o *options) RootURL() string {
|
||||
return o.rootURL
|
||||
}
|
||||
|
||||
// BasePath returns the application base path according to the base URL.
|
||||
func (o *Options) BasePath() string {
|
||||
func (o *options) BasePath() string {
|
||||
return o.basePath
|
||||
}
|
||||
|
||||
// IsDefaultDatabaseURL returns true if the default database URL is used.
|
||||
func (o *Options) IsDefaultDatabaseURL() bool {
|
||||
func (o *options) IsDefaultDatabaseURL() bool {
|
||||
return o.databaseURL == defaultDatabaseURL
|
||||
}
|
||||
|
||||
// DatabaseURL returns the database URL.
|
||||
func (o *Options) DatabaseURL() string {
|
||||
func (o *options) DatabaseURL() string {
|
||||
return o.databaseURL
|
||||
}
|
||||
|
||||
// DatabaseMaxConns returns the maximum number of database connections.
|
||||
func (o *Options) DatabaseMaxConns() int {
|
||||
func (o *options) DatabaseMaxConns() int {
|
||||
return o.databaseMaxConns
|
||||
}
|
||||
|
||||
// DatabaseMinConns returns the minimum number of database connections.
|
||||
func (o *Options) DatabaseMinConns() int {
|
||||
func (o *options) DatabaseMinConns() int {
|
||||
return o.databaseMinConns
|
||||
}
|
||||
|
||||
// DatabaseConnectionLifetime returns the maximum amount of time a connection may be reused.
|
||||
func (o *Options) DatabaseConnectionLifetime() time.Duration {
|
||||
func (o *options) DatabaseConnectionLifetime() time.Duration {
|
||||
return time.Duration(o.databaseConnectionLifetime) * time.Minute
|
||||
}
|
||||
|
||||
// ListenAddr returns the listen address for the HTTP server.
|
||||
func (o *Options) ListenAddr() string {
|
||||
func (o *options) ListenAddr() []string {
|
||||
return o.listenAddr
|
||||
}
|
||||
|
||||
// CertFile returns the SSL certificate filename if any.
|
||||
func (o *Options) CertFile() string {
|
||||
func (o *options) CertFile() string {
|
||||
return o.certFile
|
||||
}
|
||||
|
||||
// CertKeyFile returns the private key filename for custom SSL certificate.
|
||||
func (o *Options) CertKeyFile() string {
|
||||
func (o *options) CertKeyFile() string {
|
||||
return o.certKeyFile
|
||||
}
|
||||
|
||||
// CertDomain returns the domain to use for Let's Encrypt certificate.
|
||||
func (o *Options) CertDomain() string {
|
||||
func (o *options) CertDomain() string {
|
||||
return o.certDomain
|
||||
}
|
||||
|
||||
// CleanupFrequencyHours returns the interval in hours for cleanup jobs.
|
||||
func (o *Options) CleanupFrequencyHours() int {
|
||||
func (o *options) CleanupFrequencyHours() int {
|
||||
return o.cleanupFrequencyHours
|
||||
}
|
||||
|
||||
// CleanupArchiveReadDays returns the number of days after which marking read items as removed.
|
||||
func (o *Options) CleanupArchiveReadDays() int {
|
||||
func (o *options) CleanupArchiveReadDays() int {
|
||||
return o.cleanupArchiveReadDays
|
||||
}
|
||||
|
||||
// CleanupArchiveUnreadDays returns the number of days after which marking unread items as removed.
|
||||
func (o *Options) CleanupArchiveUnreadDays() int {
|
||||
func (o *options) CleanupArchiveUnreadDays() int {
|
||||
return o.cleanupArchiveUnreadDays
|
||||
}
|
||||
|
||||
// CleanupArchiveBatchSize returns the number of entries to archive for each interval.
|
||||
func (o *Options) CleanupArchiveBatchSize() int {
|
||||
func (o *options) CleanupArchiveBatchSize() int {
|
||||
return o.cleanupArchiveBatchSize
|
||||
}
|
||||
|
||||
// CleanupRemoveSessionsDays returns the number of days after which to remove sessions.
|
||||
func (o *Options) CleanupRemoveSessionsDays() int {
|
||||
func (o *options) CleanupRemoveSessionsDays() int {
|
||||
return o.cleanupRemoveSessionsDays
|
||||
}
|
||||
|
||||
// WorkerPoolSize returns the number of background worker.
|
||||
func (o *Options) WorkerPoolSize() int {
|
||||
func (o *options) WorkerPoolSize() int {
|
||||
return o.workerPoolSize
|
||||
}
|
||||
|
||||
// PollingFrequency returns the interval to refresh feeds in the background.
|
||||
func (o *Options) PollingFrequency() int {
|
||||
func (o *options) PollingFrequency() int {
|
||||
return o.pollingFrequency
|
||||
}
|
||||
|
||||
// ForceRefreshInterval returns the force refresh interval
|
||||
func (o *Options) ForceRefreshInterval() int {
|
||||
func (o *options) ForceRefreshInterval() int {
|
||||
return o.forceRefreshInterval
|
||||
}
|
||||
|
||||
// BatchSize returns the number of feeds to send for background processing.
|
||||
func (o *Options) BatchSize() int {
|
||||
func (o *options) BatchSize() int {
|
||||
return o.batchSize
|
||||
}
|
||||
|
||||
// PollingScheduler returns the scheduler used for polling feeds.
|
||||
func (o *Options) PollingScheduler() string {
|
||||
func (o *options) PollingScheduler() string {
|
||||
return o.pollingScheduler
|
||||
}
|
||||
|
||||
// SchedulerEntryFrequencyMaxInterval returns the maximum interval in minutes for the entry frequency scheduler.
|
||||
func (o *Options) SchedulerEntryFrequencyMaxInterval() int {
|
||||
func (o *options) SchedulerEntryFrequencyMaxInterval() int {
|
||||
return o.schedulerEntryFrequencyMaxInterval
|
||||
}
|
||||
|
||||
// SchedulerEntryFrequencyMinInterval returns the minimum interval in minutes for the entry frequency scheduler.
|
||||
func (o *Options) SchedulerEntryFrequencyMinInterval() int {
|
||||
func (o *options) SchedulerEntryFrequencyMinInterval() int {
|
||||
return o.schedulerEntryFrequencyMinInterval
|
||||
}
|
||||
|
||||
// SchedulerEntryFrequencyFactor returns the factor for the entry frequency scheduler.
|
||||
func (o *Options) SchedulerEntryFrequencyFactor() int {
|
||||
func (o *options) SchedulerEntryFrequencyFactor() int {
|
||||
return o.schedulerEntryFrequencyFactor
|
||||
}
|
||||
|
||||
func (o *Options) SchedulerRoundRobinMinInterval() int {
|
||||
func (o *options) SchedulerRoundRobinMinInterval() int {
|
||||
return o.schedulerRoundRobinMinInterval
|
||||
}
|
||||
|
||||
func (o *options) SchedulerRoundRobinMaxInterval() int {
|
||||
return o.schedulerRoundRobinMaxInterval
|
||||
}
|
||||
|
||||
// PollingParsingErrorLimit returns the limit of errors when to stop polling.
|
||||
func (o *Options) PollingParsingErrorLimit() int {
|
||||
func (o *options) PollingParsingErrorLimit() int {
|
||||
return o.pollingParsingErrorLimit
|
||||
}
|
||||
|
||||
// IsOAuth2UserCreationAllowed returns true if user creation is allowed for OAuth2 users.
|
||||
func (o *Options) IsOAuth2UserCreationAllowed() bool {
|
||||
func (o *options) IsOAuth2UserCreationAllowed() bool {
|
||||
return o.oauth2UserCreationAllowed
|
||||
}
|
||||
|
||||
// OAuth2ClientID returns the OAuth2 Client ID.
|
||||
func (o *Options) OAuth2ClientID() string {
|
||||
func (o *options) OAuth2ClientID() string {
|
||||
return o.oauth2ClientID
|
||||
}
|
||||
|
||||
// OAuth2ClientSecret returns the OAuth2 client secret.
|
||||
func (o *Options) OAuth2ClientSecret() string {
|
||||
func (o *options) OAuth2ClientSecret() string {
|
||||
return o.oauth2ClientSecret
|
||||
}
|
||||
|
||||
// OAuth2RedirectURL returns the OAuth2 redirect URL.
|
||||
func (o *Options) OAuth2RedirectURL() string {
|
||||
func (o *options) OAuth2RedirectURL() string {
|
||||
return o.oauth2RedirectURL
|
||||
}
|
||||
|
||||
// OIDCDiscoveryEndpoint returns the OAuth2 OIDC discovery endpoint.
|
||||
func (o *Options) OIDCDiscoveryEndpoint() string {
|
||||
func (o *options) OIDCDiscoveryEndpoint() string {
|
||||
return o.oidcDiscoveryEndpoint
|
||||
}
|
||||
|
||||
// OIDCProviderName returns the OAuth2 OIDC provider's display name
|
||||
func (o *Options) OIDCProviderName() string {
|
||||
func (o *options) OIDCProviderName() string {
|
||||
return o.oidcProviderName
|
||||
}
|
||||
|
||||
// OAuth2Provider returns the name of the OAuth2 provider configured.
|
||||
func (o *Options) OAuth2Provider() string {
|
||||
func (o *options) OAuth2Provider() string {
|
||||
return o.oauth2Provider
|
||||
}
|
||||
|
||||
// DisableLocalAUth returns true if the local user database should not be used to authenticate users
|
||||
func (o *Options) DisableLocalAuth() bool {
|
||||
func (o *options) DisableLocalAuth() bool {
|
||||
return o.disableLocalAuth
|
||||
}
|
||||
|
||||
// HasHSTS returns true if HTTP Strict Transport Security is enabled.
|
||||
func (o *Options) HasHSTS() bool {
|
||||
func (o *options) HasHSTS() bool {
|
||||
return o.hsts
|
||||
}
|
||||
|
||||
// RunMigrations returns true if the environment variable RUN_MIGRATIONS is not empty.
|
||||
func (o *Options) RunMigrations() bool {
|
||||
func (o *options) RunMigrations() bool {
|
||||
return o.runMigrations
|
||||
}
|
||||
|
||||
// CreateAdmin returns true if the environment variable CREATE_ADMIN is not empty.
|
||||
func (o *Options) CreateAdmin() bool {
|
||||
func (o *options) CreateAdmin() bool {
|
||||
return o.createAdmin
|
||||
}
|
||||
|
||||
// AdminUsername returns the admin username if defined.
|
||||
func (o *Options) AdminUsername() string {
|
||||
func (o *options) AdminUsername() string {
|
||||
return o.adminUsername
|
||||
}
|
||||
|
||||
// AdminPassword returns the admin password if defined.
|
||||
func (o *Options) AdminPassword() string {
|
||||
func (o *options) AdminPassword() string {
|
||||
return o.adminPassword
|
||||
}
|
||||
|
||||
// FetchYouTubeWatchTime returns true if the YouTube video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchYouTubeWatchTime() bool {
|
||||
func (o *options) FetchYouTubeWatchTime() bool {
|
||||
return o.fetchYouTubeWatchTime
|
||||
}
|
||||
|
||||
// YouTubeApiKey returns the YouTube API key if defined.
|
||||
func (o *Options) YouTubeApiKey() string {
|
||||
func (o *options) YouTubeApiKey() string {
|
||||
return o.youTubeApiKey
|
||||
}
|
||||
|
||||
// YouTubeEmbedUrlOverride returns YouTube URL which will be used for embeds
|
||||
func (o *Options) YouTubeEmbedUrlOverride() string {
|
||||
// YouTubeEmbedUrlOverride returns the YouTube embed URL override if defined.
|
||||
func (o *options) YouTubeEmbedUrlOverride() string {
|
||||
return o.youTubeEmbedUrlOverride
|
||||
}
|
||||
|
||||
// YouTubeEmbedDomain returns the domain used for YouTube embeds.
|
||||
func (o *options) YouTubeEmbedDomain() string {
|
||||
if o.youTubeEmbedDomain != "" {
|
||||
return o.youTubeEmbedDomain
|
||||
}
|
||||
return "www.youtube-nocookie.com"
|
||||
}
|
||||
|
||||
// FetchNebulaWatchTime returns true if the Nebula video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchNebulaWatchTime() bool {
|
||||
func (o *options) FetchNebulaWatchTime() bool {
|
||||
return o.fetchNebulaWatchTime
|
||||
}
|
||||
|
||||
// FetchOdyseeWatchTime returns true if the Odysee video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchOdyseeWatchTime() bool {
|
||||
func (o *options) FetchOdyseeWatchTime() bool {
|
||||
return o.fetchOdyseeWatchTime
|
||||
}
|
||||
|
||||
// FetchBilibiliWatchTime returns true if the Bilibili video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchBilibiliWatchTime() bool {
|
||||
func (o *options) FetchBilibiliWatchTime() bool {
|
||||
return o.fetchBilibiliWatchTime
|
||||
}
|
||||
|
||||
// MediaProxyMode returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
|
||||
func (o *Options) MediaProxyMode() string {
|
||||
func (o *options) MediaProxyMode() string {
|
||||
return o.mediaProxyMode
|
||||
}
|
||||
|
||||
// MediaProxyResourceTypes returns a slice of resource types to proxy.
|
||||
func (o *Options) MediaProxyResourceTypes() []string {
|
||||
func (o *options) MediaProxyResourceTypes() []string {
|
||||
return o.mediaProxyResourceTypes
|
||||
}
|
||||
|
||||
// MediaCustomProxyURL returns the custom proxy URL for medias.
|
||||
func (o *Options) MediaCustomProxyURL() string {
|
||||
func (o *options) MediaCustomProxyURL() string {
|
||||
return o.mediaProxyCustomURL
|
||||
}
|
||||
|
||||
// MediaProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
|
||||
func (o *Options) MediaProxyHTTPClientTimeout() int {
|
||||
func (o *options) MediaProxyHTTPClientTimeout() int {
|
||||
return o.mediaProxyHTTPClientTimeout
|
||||
}
|
||||
|
||||
// MediaProxyPrivateKey returns the private key used by the media proxy.
|
||||
func (o *Options) MediaProxyPrivateKey() []byte {
|
||||
func (o *options) MediaProxyPrivateKey() []byte {
|
||||
return o.mediaProxyPrivateKey
|
||||
}
|
||||
|
||||
// HasHTTPService returns true if the HTTP service is enabled.
|
||||
func (o *Options) HasHTTPService() bool {
|
||||
func (o *options) HasHTTPService() bool {
|
||||
return o.httpService
|
||||
}
|
||||
|
||||
// HasSchedulerService returns true if the scheduler service is enabled.
|
||||
func (o *Options) HasSchedulerService() bool {
|
||||
func (o *options) HasSchedulerService() bool {
|
||||
return o.schedulerService
|
||||
}
|
||||
|
||||
// PocketConsumerKey returns the Pocket Consumer Key if configured.
|
||||
func (o *Options) PocketConsumerKey(defaultValue string) string {
|
||||
if o.pocketConsumerKey != "" {
|
||||
return o.pocketConsumerKey
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// HTTPClientTimeout returns the time limit in seconds before the HTTP client cancel the request.
|
||||
func (o *Options) HTTPClientTimeout() int {
|
||||
func (o *options) HTTPClientTimeout() int {
|
||||
return o.httpClientTimeout
|
||||
}
|
||||
|
||||
// HTTPClientMaxBodySize returns the number of bytes allowed for the HTTP client to transfer.
|
||||
func (o *Options) HTTPClientMaxBodySize() int64 {
|
||||
func (o *options) HTTPClientMaxBodySize() int64 {
|
||||
return o.httpClientMaxBodySize
|
||||
}
|
||||
|
||||
// HTTPClientProxy returns the proxy URL for HTTP client.
|
||||
func (o *Options) HTTPClientProxy() string {
|
||||
return o.httpClientProxy
|
||||
// HTTPClientProxyURL returns the client HTTP proxy URL if configured.
|
||||
func (o *options) HTTPClientProxyURL() *url.URL {
|
||||
return o.httpClientProxyURL
|
||||
}
|
||||
|
||||
// HasHTTPClientProxyURLConfigured returns true if the client HTTP proxy URL if configured.
|
||||
func (o *options) HasHTTPClientProxyURLConfigured() bool {
|
||||
return o.httpClientProxyURL != nil
|
||||
}
|
||||
|
||||
// HTTPClientProxies returns the list of proxies.
|
||||
func (o *options) HTTPClientProxies() []string {
|
||||
return o.httpClientProxies
|
||||
}
|
||||
|
||||
// HTTPClientProxiesString returns true if the list of rotating proxies are configured.
|
||||
func (o *options) HasHTTPClientProxiesConfigured() bool {
|
||||
return len(o.httpClientProxies) > 0
|
||||
}
|
||||
|
||||
// HTTPServerTimeout returns the time limit in seconds before the HTTP server cancel the request.
|
||||
func (o *Options) HTTPServerTimeout() int {
|
||||
func (o *options) HTTPServerTimeout() int {
|
||||
return o.httpServerTimeout
|
||||
}
|
||||
|
||||
// HasHTTPClientProxyConfigured returns true if the HTTP proxy is configured.
|
||||
func (o *Options) HasHTTPClientProxyConfigured() bool {
|
||||
return o.httpClientProxy != ""
|
||||
}
|
||||
|
||||
// AuthProxyHeader returns an HTTP header name that contains username for
|
||||
// authentication using auth proxy.
|
||||
func (o *Options) AuthProxyHeader() string {
|
||||
func (o *options) AuthProxyHeader() string {
|
||||
return o.authProxyHeader
|
||||
}
|
||||
|
||||
// IsAuthProxyUserCreationAllowed returns true if user creation is allowed for
|
||||
// users authenticated using auth proxy.
|
||||
func (o *Options) IsAuthProxyUserCreationAllowed() bool {
|
||||
func (o *options) IsAuthProxyUserCreationAllowed() bool {
|
||||
return o.authProxyUserCreation
|
||||
}
|
||||
|
||||
// HasMetricsCollector returns true if metrics collection is enabled.
|
||||
func (o *Options) HasMetricsCollector() bool {
|
||||
func (o *options) HasMetricsCollector() bool {
|
||||
return o.metricsCollector
|
||||
}
|
||||
|
||||
// MetricsRefreshInterval returns the refresh interval in seconds.
|
||||
func (o *Options) MetricsRefreshInterval() int {
|
||||
func (o *options) MetricsRefreshInterval() int {
|
||||
return o.metricsRefreshInterval
|
||||
}
|
||||
|
||||
// MetricsAllowedNetworks returns the list of networks allowed to connect to the metrics endpoint.
|
||||
func (o *Options) MetricsAllowedNetworks() []string {
|
||||
func (o *options) MetricsAllowedNetworks() []string {
|
||||
return o.metricsAllowedNetworks
|
||||
}
|
||||
|
||||
func (o *Options) MetricsUsername() string {
|
||||
func (o *options) MetricsUsername() string {
|
||||
return o.metricsUsername
|
||||
}
|
||||
|
||||
func (o *Options) MetricsPassword() string {
|
||||
func (o *options) MetricsPassword() string {
|
||||
return o.metricsPassword
|
||||
}
|
||||
|
||||
// HTTPClientUserAgent returns the global User-Agent header for miniflux.
|
||||
func (o *Options) HTTPClientUserAgent() string {
|
||||
func (o *options) HTTPClientUserAgent() string {
|
||||
return o.httpClientUserAgent
|
||||
}
|
||||
|
||||
// HasWatchdog returns true if the systemd watchdog is enabled.
|
||||
func (o *Options) HasWatchdog() bool {
|
||||
func (o *options) HasWatchdog() bool {
|
||||
return o.watchdog
|
||||
}
|
||||
|
||||
// InvidiousInstance returns the invidious instance used by miniflux
|
||||
func (o *Options) InvidiousInstance() string {
|
||||
func (o *options) InvidiousInstance() string {
|
||||
return o.invidiousInstance
|
||||
}
|
||||
|
||||
// WebAuthn returns true if WebAuthn logins are supported
|
||||
func (o *Options) WebAuthn() bool {
|
||||
func (o *options) WebAuthn() bool {
|
||||
return o.webAuthn
|
||||
}
|
||||
|
||||
// FilterEntryMaxAgeDays returns the number of days after which entries should be retained.
|
||||
func (o *Options) FilterEntryMaxAgeDays() int {
|
||||
func (o *options) FilterEntryMaxAgeDays() int {
|
||||
return o.filterEntryMaxAgeDays
|
||||
}
|
||||
|
||||
// SortedOptions returns options as a list of key value pairs, sorted by keys.
|
||||
func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
func (o *options) SortedOptions(redactSecret bool) []*option {
|
||||
var clientProxyURLRedacted string
|
||||
if o.httpClientProxyURL != nil {
|
||||
if redactSecret {
|
||||
clientProxyURLRedacted = o.httpClientProxyURL.Redacted()
|
||||
} else {
|
||||
clientProxyURLRedacted = o.httpClientProxyURL.String()
|
||||
}
|
||||
}
|
||||
|
||||
var clientProxyURLsRedacted string
|
||||
if len(o.httpClientProxies) > 0 {
|
||||
if redactSecret {
|
||||
var proxyURLs []string
|
||||
for range o.httpClientProxies {
|
||||
proxyURLs = append(proxyURLs, "<redacted>")
|
||||
}
|
||||
clientProxyURLsRedacted = strings.Join(proxyURLs, ",")
|
||||
} else {
|
||||
clientProxyURLsRedacted = strings.Join(o.httpClientProxies, ",")
|
||||
}
|
||||
}
|
||||
|
||||
var mediaProxyPrivateKeyValue string
|
||||
if len(o.mediaProxyPrivateKey) > 0 {
|
||||
mediaProxyPrivateKeyValue = "<binary-data>"
|
||||
}
|
||||
|
||||
var keyValues = map[string]interface{}{
|
||||
"ADMIN_PASSWORD": redactSecretValue(o.adminPassword, redactSecret),
|
||||
"ADMIN_USERNAME": o.adminUsername,
|
||||
@@ -694,14 +731,15 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"FETCH_BILIBILI_WATCH_TIME": o.fetchBilibiliWatchTime,
|
||||
"HTTPS": o.HTTPS,
|
||||
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
|
||||
"HTTP_CLIENT_PROXY": o.httpClientProxy,
|
||||
"HTTP_CLIENT_PROXIES": clientProxyURLsRedacted,
|
||||
"HTTP_CLIENT_PROXY": clientProxyURLRedacted,
|
||||
"HTTP_CLIENT_TIMEOUT": o.httpClientTimeout,
|
||||
"HTTP_CLIENT_USER_AGENT": o.httpClientUserAgent,
|
||||
"HTTP_SERVER_TIMEOUT": o.httpServerTimeout,
|
||||
"HTTP_SERVICE": o.httpService,
|
||||
"INVIDIOUS_INSTANCE": o.invidiousInstance,
|
||||
"KEY_FILE": o.certKeyFile,
|
||||
"LISTEN_ADDR": o.listenAddr,
|
||||
"LISTEN_ADDR": strings.Join(o.listenAddr, ","),
|
||||
"LOG_FILE": o.logFile,
|
||||
"LOG_DATE_TIME": o.logDateTime,
|
||||
"LOG_FORMAT": o.logFormat,
|
||||
@@ -721,7 +759,6 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"OAUTH2_REDIRECT_URL": o.oauth2RedirectURL,
|
||||
"OAUTH2_USER_CREATION": o.oauth2UserCreationAllowed,
|
||||
"DISABLE_LOCAL_AUTH": o.disableLocalAuth,
|
||||
"POCKET_CONSUMER_KEY": redactSecretValue(o.pocketConsumerKey, redactSecret),
|
||||
"POLLING_FREQUENCY": o.pollingFrequency,
|
||||
"FORCE_REFRESH_INTERVAL": o.forceRefreshInterval,
|
||||
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
|
||||
@@ -729,7 +766,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": o.mediaProxyHTTPClientTimeout,
|
||||
"MEDIA_PROXY_RESOURCE_TYPES": o.mediaProxyResourceTypes,
|
||||
"MEDIA_PROXY_MODE": o.mediaProxyMode,
|
||||
"MEDIA_PROXY_PRIVATE_KEY": redactSecretValue(string(o.mediaProxyPrivateKey), redactSecret),
|
||||
"MEDIA_PROXY_PRIVATE_KEY": mediaProxyPrivateKeyValue,
|
||||
"MEDIA_PROXY_CUSTOM_URL": o.mediaProxyCustomURL,
|
||||
"ROOT_URL": o.rootURL,
|
||||
"RUN_MIGRATIONS": o.runMigrations,
|
||||
@@ -737,8 +774,8 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL": o.schedulerEntryFrequencyMinInterval,
|
||||
"SCHEDULER_ENTRY_FREQUENCY_FACTOR": o.schedulerEntryFrequencyFactor,
|
||||
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL": o.schedulerRoundRobinMinInterval,
|
||||
"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL": o.schedulerRoundRobinMaxInterval,
|
||||
"SCHEDULER_SERVICE": o.schedulerService,
|
||||
"SERVER_TIMING_HEADER": o.serverTimingHeader,
|
||||
"WATCHDOG": o.watchdog,
|
||||
"WORKER_POOL_SIZE": o.workerPoolSize,
|
||||
"YOUTUBE_API_KEY": redactSecretValue(o.youTubeApiKey, redactSecret),
|
||||
@@ -746,20 +783,15 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"WEBAUTHN": o.webAuthn,
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(keyValues))
|
||||
for key := range keyValues {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var sortedOptions []*Option
|
||||
for _, key := range keys {
|
||||
sortedOptions = append(sortedOptions, &Option{Key: key, Value: keyValues[key]})
|
||||
sortedKeys := slices.Sorted(maps.Keys(keyValues))
|
||||
var sortedOptions = make([]*option, 0, len(sortedKeys))
|
||||
for _, key := range sortedKeys {
|
||||
sortedOptions = append(sortedOptions, &option{Key: key, Value: keyValues[key]})
|
||||
}
|
||||
return sortedOptions
|
||||
}
|
||||
|
||||
func (o *Options) String() string {
|
||||
func (o *options) String() string {
|
||||
var builder strings.Builder
|
||||
|
||||
for _, option := range o.SortedOptions(false) {
|
||||
|
||||
+43
-64
@@ -10,27 +10,26 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Parser handles configuration parsing.
|
||||
type Parser struct {
|
||||
opts *Options
|
||||
// parser handles configuration parsing.
|
||||
type parser struct {
|
||||
opts *options
|
||||
}
|
||||
|
||||
// NewParser returns a new Parser.
|
||||
func NewParser() *Parser {
|
||||
return &Parser{
|
||||
func NewParser() *parser {
|
||||
return &parser{
|
||||
opts: NewOptions(),
|
||||
}
|
||||
}
|
||||
|
||||
// ParseEnvironmentVariables loads configuration values from environment variables.
|
||||
func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
|
||||
func (p *parser) ParseEnvironmentVariables() (*options, error) {
|
||||
err := p.parseLines(os.Environ())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -39,7 +38,7 @@ func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
|
||||
}
|
||||
|
||||
// ParseFile loads configuration values from a local file.
|
||||
func (p *Parser) ParseFile(filename string) (*Options, error) {
|
||||
func (p *parser) ParseFile(filename string) (*options, error) {
|
||||
fp, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -53,7 +52,7 @@ func (p *Parser) ParseFile(filename string) (*Options, error) {
|
||||
return p.opts, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
|
||||
func (p *parser) parseFileContent(r io.Reader) (lines []string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
@@ -64,13 +63,15 @@ func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
|
||||
return lines
|
||||
}
|
||||
|
||||
func (p *Parser) parseLines(lines []string) (err error) {
|
||||
func (p *parser) parseLines(lines []string) (err error) {
|
||||
var port string
|
||||
|
||||
for _, line := range lines {
|
||||
fields := strings.SplitN(line, "=", 2)
|
||||
key := strings.TrimSpace(fields[0])
|
||||
value := strings.TrimSpace(fields[1])
|
||||
for lineNum, line := range lines {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("config: unable to parse configuration, invalid format on line %d", lineNum)
|
||||
}
|
||||
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
|
||||
|
||||
switch key {
|
||||
case "LOG_FILE":
|
||||
@@ -87,14 +88,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
if parsedValue == "json" || parsedValue == "text" {
|
||||
p.opts.logFormat = parsedValue
|
||||
}
|
||||
case "DEBUG":
|
||||
slog.Warn("The DEBUG environment variable is deprecated, use LOG_LEVEL instead")
|
||||
parsedValue := parseBool(value, defaultDebug)
|
||||
if parsedValue {
|
||||
p.opts.logLevel = "debug"
|
||||
}
|
||||
case "SERVER_TIMING_HEADER":
|
||||
p.opts.serverTimingHeader = parseBool(value, defaultTiming)
|
||||
case "BASE_URL":
|
||||
p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
|
||||
if err != nil {
|
||||
@@ -103,7 +96,7 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
case "PORT":
|
||||
port = value
|
||||
case "LISTEN_ADDR":
|
||||
p.opts.listenAddr = parseString(value, defaultListenAddr)
|
||||
p.opts.listenAddr = parseStringList(value, []string{defaultListenAddr})
|
||||
case "DATABASE_URL":
|
||||
p.opts.databaseURL = parseString(value, defaultDatabaseURL)
|
||||
case "DATABASE_URL_FILE":
|
||||
@@ -160,40 +153,21 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
p.opts.schedulerEntryFrequencyFactor = parseInt(value, defaultSchedulerEntryFrequencyFactor)
|
||||
case "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL":
|
||||
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
|
||||
case "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL":
|
||||
p.opts.schedulerRoundRobinMaxInterval = parseInt(value, defaultSchedulerRoundRobinMaxInterval)
|
||||
case "POLLING_PARSING_ERROR_LIMIT":
|
||||
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
|
||||
case "PROXY_IMAGES":
|
||||
slog.Warn("The PROXY_IMAGES environment variable is deprecated, use MEDIA_PROXY_MODE instead")
|
||||
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
|
||||
case "PROXY_HTTP_CLIENT_TIMEOUT":
|
||||
slog.Warn("The PROXY_HTTP_CLIENT_TIMEOUT environment variable is deprecated, use MEDIA_PROXY_HTTP_CLIENT_TIMEOUT instead")
|
||||
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
|
||||
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
|
||||
case "PROXY_OPTION":
|
||||
slog.Warn("The PROXY_OPTION environment variable is deprecated, use MEDIA_PROXY_MODE instead")
|
||||
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
|
||||
case "MEDIA_PROXY_MODE":
|
||||
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
|
||||
case "PROXY_MEDIA_TYPES":
|
||||
slog.Warn("The PROXY_MEDIA_TYPES environment variable is deprecated, use MEDIA_PROXY_RESOURCE_TYPES instead")
|
||||
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
|
||||
case "MEDIA_PROXY_RESOURCE_TYPES":
|
||||
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
|
||||
case "PROXY_IMAGE_URL":
|
||||
slog.Warn("The PROXY_IMAGE_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
|
||||
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||
case "PROXY_URL":
|
||||
slog.Warn("The PROXY_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
|
||||
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||
case "PROXY_PRIVATE_KEY":
|
||||
slog.Warn("The PROXY_PRIVATE_KEY environment variable is deprecated, use MEDIA_PROXY_PRIVATE_KEY instead")
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_PRIVATE_KEY":
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
if _, err := rand.Read(randomKey); err != nil {
|
||||
return fmt.Errorf("config: unable to generate random key: %w", err)
|
||||
}
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_CUSTOM_URL":
|
||||
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||
@@ -207,10 +181,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
p.opts.adminPassword = parseString(value, defaultAdminPassword)
|
||||
case "ADMIN_PASSWORD_FILE":
|
||||
p.opts.adminPassword = readSecretFile(value, defaultAdminPassword)
|
||||
case "POCKET_CONSUMER_KEY":
|
||||
p.opts.pocketConsumerKey = parseString(value, defaultPocketConsumerKey)
|
||||
case "POCKET_CONSUMER_KEY_FILE":
|
||||
p.opts.pocketConsumerKey = readSecretFile(value, defaultPocketConsumerKey)
|
||||
case "OAUTH2_USER_CREATION":
|
||||
p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
|
||||
case "OAUTH2_CLIENT_ID":
|
||||
@@ -236,7 +206,12 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
case "HTTP_CLIENT_MAX_BODY_SIZE":
|
||||
p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
|
||||
case "HTTP_CLIENT_PROXY":
|
||||
p.opts.httpClientProxy = parseString(value, defaultHTTPClientProxy)
|
||||
p.opts.httpClientProxyURL, err = url.Parse(parseString(value, defaultHTTPClientProxy))
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid HTTP_CLIENT_PROXY value: %w", err)
|
||||
}
|
||||
case "HTTP_CLIENT_PROXIES":
|
||||
p.opts.httpClientProxies = parseStringList(value, []string{})
|
||||
case "HTTP_CLIENT_USER_AGENT":
|
||||
p.opts.httpClientUserAgent = parseString(value, defaultHTTPClientUserAgent)
|
||||
case "HTTP_SERVER_TIMEOUT":
|
||||
@@ -285,8 +260,15 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
}
|
||||
|
||||
if port != "" {
|
||||
p.opts.listenAddr = ":" + port
|
||||
p.opts.listenAddr = []string{":" + port}
|
||||
}
|
||||
|
||||
youtubeEmbedURL, err := url.Parse(p.opts.youTubeEmbedUrlOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid YOUTUBE_EMBED_URL_OVERRIDE value: %w", err)
|
||||
}
|
||||
p.opts.youTubeEmbedDomain = youtubeEmbedURL.Hostname()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -295,9 +277,7 @@ func parseBaseURL(value string) (string, string, string, error) {
|
||||
return defaultBaseURL, defaultRootURL, "", nil
|
||||
}
|
||||
|
||||
if value[len(value)-1:] == "/" {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
value = strings.TrimSuffix(value, "/")
|
||||
|
||||
parsedURL, err := url.Parse(value)
|
||||
if err != nil {
|
||||
@@ -353,15 +333,14 @@ func parseStringList(value string, fallback []string) []string {
|
||||
}
|
||||
|
||||
var strList []string
|
||||
strMap := make(map[string]bool)
|
||||
present := make(map[string]bool)
|
||||
|
||||
items := strings.Split(value, ",")
|
||||
for _, item := range items {
|
||||
itemValue := strings.TrimSpace(item)
|
||||
|
||||
if _, found := strMap[itemValue]; !found {
|
||||
strMap[itemValue] = true
|
||||
strList = append(strList, itemValue)
|
||||
for item := range strings.SplitSeq(value, ",") {
|
||||
if itemValue := strings.TrimSpace(item); itemValue != "" {
|
||||
if !present[itemValue] {
|
||||
present[itemValue] = true
|
||||
strList = append(strList, itemValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -58,3 +59,108 @@ func TestParseIntValue(t *testing.T) {
|
||||
t.Errorf(`Defined variables should returns the specified value`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListenAddr(t *testing.T) {
|
||||
defaultExpected := []string{defaultListenAddr}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
listenAddr string
|
||||
port string
|
||||
expected []string
|
||||
lines []string // Used for direct lines parsing instead of individual env vars
|
||||
isLineOriented bool // Flag to indicate if we use lines
|
||||
}{
|
||||
{
|
||||
name: "Single LISTEN_ADDR",
|
||||
listenAddr: "127.0.0.1:8080",
|
||||
expected: []string{"127.0.0.1:8080"},
|
||||
},
|
||||
{
|
||||
name: "Multiple LISTEN_ADDR comma-separated",
|
||||
listenAddr: "127.0.0.1:8080,:8081,/tmp/miniflux.sock",
|
||||
expected: []string{"127.0.0.1:8080", ":8081", "/tmp/miniflux.sock"},
|
||||
},
|
||||
{
|
||||
name: "Multiple LISTEN_ADDR with spaces around commas",
|
||||
listenAddr: "127.0.0.1:8080 , :8081",
|
||||
expected: []string{"127.0.0.1:8080", ":8081"},
|
||||
},
|
||||
{
|
||||
name: "Empty LISTEN_ADDR",
|
||||
listenAddr: "",
|
||||
expected: defaultExpected,
|
||||
},
|
||||
{
|
||||
name: "PORT overrides LISTEN_ADDR",
|
||||
listenAddr: "127.0.0.1:8000",
|
||||
port: "8082",
|
||||
expected: []string{":8082"},
|
||||
},
|
||||
{
|
||||
name: "PORT overrides empty LISTEN_ADDR",
|
||||
listenAddr: "",
|
||||
port: "8083",
|
||||
expected: []string{":8083"},
|
||||
},
|
||||
{
|
||||
name: "LISTEN_ADDR with empty segment (comma)",
|
||||
listenAddr: "127.0.0.1:8080,,:8081",
|
||||
expected: []string{"127.0.0.1:8080", ":8081"},
|
||||
},
|
||||
{
|
||||
name: "PORT override with lines parsing",
|
||||
isLineOriented: true,
|
||||
lines: []string{"LISTEN_ADDR=127.0.0.1:8000", "PORT=8082"},
|
||||
expected: []string{":8082"},
|
||||
},
|
||||
{
|
||||
name: "LISTEN_ADDR only with lines parsing (comma)",
|
||||
isLineOriented: true,
|
||||
lines: []string{"LISTEN_ADDR=10.0.0.1:9090,10.0.0.2:9091"},
|
||||
expected: []string{"10.0.0.1:9090", "10.0.0.2:9091"},
|
||||
},
|
||||
{
|
||||
name: "Empty LISTEN_ADDR with lines parsing (default)",
|
||||
isLineOriented: true,
|
||||
lines: []string{"LISTEN_ADDR="},
|
||||
expected: defaultExpected,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := NewParser()
|
||||
var err error
|
||||
|
||||
if tt.isLineOriented {
|
||||
err = parser.parseLines(tt.lines)
|
||||
} else {
|
||||
// Simulate os.Environ() behaviour for individual var testing
|
||||
var envLines []string
|
||||
if tt.listenAddr != "" {
|
||||
envLines = append(envLines, "LISTEN_ADDR="+tt.listenAddr)
|
||||
}
|
||||
if tt.port != "" {
|
||||
envLines = append(envLines, "PORT="+tt.port)
|
||||
}
|
||||
// Add a dummy var if both are empty to avoid empty lines slice if not intended
|
||||
if tt.listenAddr == "" && tt.port == "" && tt.name == "Empty LISTEN_ADDR" {
|
||||
// This case specifically tests empty LISTEN_ADDR resulting in default
|
||||
// So, we pass LISTEN_ADDR=
|
||||
envLines = append(envLines, "LISTEN_ADDR=")
|
||||
}
|
||||
err = parser.parseLines(envLines)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("parseLines() error = %v", err)
|
||||
}
|
||||
|
||||
opts := parser.opts
|
||||
if !reflect.DeepEqual(opts.ListenAddr(), tt.expected) {
|
||||
t.Errorf("ListenAddr() got = %v, want %v", opts.ListenAddr(), tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+10
-15
@@ -8,38 +8,33 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HashFromBytes returns a SHA-256 checksum of the input.
|
||||
// HashFromBytes returns a non-cryptographic checksum of the input.
|
||||
func HashFromBytes(value []byte) string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256(value))
|
||||
h := fnv.New128a()
|
||||
h.Write(value)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// Hash returns a SHA-256 checksum of a string.
|
||||
func Hash(value string) string {
|
||||
return HashFromBytes([]byte(value))
|
||||
// SHA256 returns a SHA-256 checksum of a string.
|
||||
func SHA256(value string) string {
|
||||
h := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// GenerateRandomBytes returns random bytes.
|
||||
func GenerateRandomBytes(size int) []byte {
|
||||
b := make([]byte, size)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
rand.Read(b)
|
||||
return b
|
||||
}
|
||||
|
||||
// GenerateRandomString returns a random string.
|
||||
func GenerateRandomString(size int) string {
|
||||
return base64.URLEncoding.EncodeToString(GenerateRandomBytes(size))
|
||||
}
|
||||
|
||||
// GenerateRandomStringHex returns a random hexadecimal string.
|
||||
func GenerateRandomStringHex(size int) string {
|
||||
return hex.EncodeToString(GenerateRandomBytes(size))
|
||||
|
||||
@@ -14,11 +14,9 @@ func Migrate(db *sql.DB) error {
|
||||
var currentVersion int
|
||||
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
|
||||
|
||||
driver := getDriverStr()
|
||||
slog.Info("Running database migrations",
|
||||
slog.Int("current_version", currentVersion),
|
||||
slog.Int("latest_version", schemaVersion),
|
||||
slog.String("driver", driver),
|
||||
)
|
||||
|
||||
for version := currentVersion; version < schemaVersion; version++ {
|
||||
@@ -29,12 +27,12 @@ func Migrate(db *sql.DB) error {
|
||||
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
|
||||
}
|
||||
|
||||
if err := migrations[version](tx, driver); err != nil {
|
||||
if err := migrations[version](tx); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM schema_version`); err != nil {
|
||||
if _, err := tx.Exec(`TRUNCATE schema_version`); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
|
||||
}
|
||||
|
||||
+387
-234
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
||||
//go:build !sqlite
|
||||
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
@@ -25,7 +23,3 @@ func NewConnectionPool(dsn string, minConnections, maxConnections int, connectio
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func getDriverStr() string {
|
||||
return "postgresql"
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
//go:build sqlite
|
||||
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package database // import "miniflux.app/v2/internal/database"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// NewConnectionPool configures the database connection pool.
|
||||
func NewConnectionPool(dsn string, _, _ int, _ time.Duration) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func getDriverStr() string {
|
||||
return "sqlite3"
|
||||
}
|
||||
+215
-373
@@ -9,7 +9,6 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -20,6 +19,7 @@ import (
|
||||
"miniflux.app/v2/internal/integration"
|
||||
"miniflux.app/v2/internal/mediaproxy"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/proxyrotator"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
mff "miniflux.app/v2/internal/reader/handler"
|
||||
mfs "miniflux.app/v2/internal/reader/subscription"
|
||||
@@ -34,178 +34,12 @@ type handler struct {
|
||||
router *mux.Router
|
||||
}
|
||||
|
||||
const (
|
||||
// StreamPrefix is the prefix for astreams (read/starred/reading list and so on)
|
||||
StreamPrefix = "user/-/state/com.google/"
|
||||
// UserStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)
|
||||
UserStreamPrefix = "user/%d/state/com.google/"
|
||||
// LabelPrefix is the prefix for a label stream
|
||||
LabelPrefix = "user/-/label/"
|
||||
// UserLabelPrefix is the user specific prefix prefix for a label stream
|
||||
UserLabelPrefix = "user/%d/label/"
|
||||
// FeedPrefix is the prefix for a feed stream
|
||||
FeedPrefix = "feed/"
|
||||
// Read is the suffix for read stream
|
||||
Read = "read"
|
||||
// Starred is the suffix for starred stream
|
||||
Starred = "starred"
|
||||
// ReadingList is the suffix for reading list stream
|
||||
ReadingList = "reading-list"
|
||||
// KeptUnread is the suffix for kept unread stream
|
||||
KeptUnread = "kept-unread"
|
||||
// Broadcast is the suffix for broadcast stream
|
||||
Broadcast = "broadcast"
|
||||
// BroadcastFriends is the suffix for broadcast friends stream
|
||||
BroadcastFriends = "broadcast-friends"
|
||||
// Like is the suffix for like stream
|
||||
Like = "like"
|
||||
// EntryIDLong is the long entry id representation
|
||||
EntryIDLong = "tag:google.com,2005:reader/item/%016x"
|
||||
var (
|
||||
errEmptyFeedTitle = errors.New("googlereader: empty feed title")
|
||||
errFeedNotFound = errors.New("googlereader: feed not found")
|
||||
errCategoryNotFound = errors.New("googlereader: category not found")
|
||||
)
|
||||
|
||||
const (
|
||||
// ParamItemIDs - name of the parameter with the item ids
|
||||
ParamItemIDs = "i"
|
||||
// ParamStreamID - name of the parameter containing the stream to be included
|
||||
ParamStreamID = "s"
|
||||
// ParamStreamExcludes - name of the parameter containing streams to be excluded
|
||||
ParamStreamExcludes = "xt"
|
||||
// ParamStreamFilters - name of the parameter containing streams to be included
|
||||
ParamStreamFilters = "it"
|
||||
// ParamStreamMaxItems - name of the parameter containing number of items per page/max items returned
|
||||
ParamStreamMaxItems = "n"
|
||||
// ParamStreamOrder - name of the parameter containing the sort criteria
|
||||
ParamStreamOrder = "r"
|
||||
// ParamStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than
|
||||
ParamStreamStartTime = "ot"
|
||||
// ParamStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than
|
||||
ParamStreamStopTime = "nt"
|
||||
// ParamTagsRemove - name of the parameter containing tags (streams) to be removed
|
||||
ParamTagsRemove = "r"
|
||||
// ParamTagsAdd - name of the parameter containing tags (streams) to be added
|
||||
ParamTagsAdd = "a"
|
||||
// ParamSubscribeAction - name of the parameter indicating the action to take for subscription/edit
|
||||
ParamSubscribeAction = "ac"
|
||||
// ParamTitle - name of the parameter for the title of the subscription
|
||||
ParamTitle = "t"
|
||||
// ParamQuickAdd - name of the parameter for a URL being quick subscribed to
|
||||
ParamQuickAdd = "quickadd"
|
||||
// ParamDestination - name of the parameter for the new name of a tag
|
||||
ParamDestination = "dest"
|
||||
// ParamContinuation - name of the parameter for callers to pass to receive the next page of results
|
||||
ParamContinuation = "c"
|
||||
)
|
||||
|
||||
// StreamType represents the possible stream types
|
||||
type StreamType int
|
||||
|
||||
const (
|
||||
// NoStream - no stream type
|
||||
NoStream StreamType = iota
|
||||
// ReadStream - read stream type
|
||||
ReadStream
|
||||
// StarredStream - starred stream type
|
||||
StarredStream
|
||||
// ReadingListStream - reading list stream type
|
||||
ReadingListStream
|
||||
// KeptUnreadStream - kept unread stream type
|
||||
KeptUnreadStream
|
||||
// BroadcastStream - broadcast stream type
|
||||
BroadcastStream
|
||||
// BroadcastFriendsStream - broadcast friends stream type
|
||||
BroadcastFriendsStream
|
||||
// LabelStream - label stream type
|
||||
LabelStream
|
||||
// FeedStream - feed stream type
|
||||
FeedStream
|
||||
// LikeStream - like stream type
|
||||
LikeStream
|
||||
)
|
||||
|
||||
// Stream defines a stream type and its ID.
|
||||
type Stream struct {
|
||||
Type StreamType
|
||||
ID string
|
||||
}
|
||||
|
||||
func (s Stream) String() string {
|
||||
return fmt.Sprintf("%v - '%s'", s.Type, s.ID)
|
||||
}
|
||||
|
||||
func (st StreamType) String() string {
|
||||
switch st {
|
||||
case NoStream:
|
||||
return "NoStream"
|
||||
case ReadStream:
|
||||
return "ReadStream"
|
||||
case StarredStream:
|
||||
return "StarredStream"
|
||||
case ReadingListStream:
|
||||
return "ReadingListStream"
|
||||
case KeptUnreadStream:
|
||||
return "KeptUnreadStream"
|
||||
case BroadcastStream:
|
||||
return "BroadcastStream"
|
||||
case BroadcastFriendsStream:
|
||||
return "BroadcastFriendsStream"
|
||||
case LabelStream:
|
||||
return "LabelStream"
|
||||
case FeedStream:
|
||||
return "FeedStream"
|
||||
case LikeStream:
|
||||
return "LikeStream"
|
||||
default:
|
||||
return st.String()
|
||||
}
|
||||
}
|
||||
|
||||
// RequestModifiers are the parsed request parameters.
|
||||
type RequestModifiers struct {
|
||||
ExcludeTargets []Stream
|
||||
FilterTargets []Stream
|
||||
Streams []Stream
|
||||
Count int
|
||||
Offset int
|
||||
SortDirection string
|
||||
StartTime int64
|
||||
StopTime int64
|
||||
ContinuationToken string
|
||||
UserID int64
|
||||
}
|
||||
|
||||
func (r RequestModifiers) String() string {
|
||||
var results []string
|
||||
|
||||
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
|
||||
|
||||
var streamStr []string
|
||||
for _, s := range r.Streams {
|
||||
streamStr = append(streamStr, s.String())
|
||||
}
|
||||
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
|
||||
|
||||
var exclusions []string
|
||||
for _, s := range r.ExcludeTargets {
|
||||
exclusions = append(exclusions, s.String())
|
||||
}
|
||||
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
|
||||
|
||||
var filters []string
|
||||
for _, s := range r.FilterTargets {
|
||||
filters = append(filters, s.String())
|
||||
}
|
||||
results = append(results, fmt.Sprintf("Filters: [%s]", strings.Join(filters, ", ")))
|
||||
|
||||
results = append(results, fmt.Sprintf("Count: %d", r.Count))
|
||||
results = append(results, fmt.Sprintf("Offset: %d", r.Offset))
|
||||
results = append(results, fmt.Sprintf("Sort Direction: %s", r.SortDirection))
|
||||
results = append(results, fmt.Sprintf("Continuation Token: %s", r.ContinuationToken))
|
||||
results = append(results, fmt.Sprintf("Start Time: %d", r.StartTime))
|
||||
results = append(results, fmt.Sprintf("Stop Time: %d", r.StopTime))
|
||||
|
||||
return strings.Join(results, "; ")
|
||||
}
|
||||
|
||||
// Serve handles Google Reader API calls.
|
||||
func Serve(router *mux.Router, store *storage.Storage) {
|
||||
handler := &handler{store, router}
|
||||
@@ -227,102 +61,22 @@ func Serve(router *mux.Router, store *storage.Storage) {
|
||||
sr.HandleFunc("/subscription/quickadd", handler.quickAddHandler).Methods(http.MethodPost).Name("QuickAdd")
|
||||
sr.HandleFunc("/stream/items/ids", handler.streamItemIDsHandler).Methods(http.MethodGet).Name("StreamItemIDs")
|
||||
sr.HandleFunc("/stream/items/contents", handler.streamItemContentsHandler).Methods(http.MethodPost).Name("StreamItemsContents")
|
||||
sr.HandleFunc("/mark-all-as-read", handler.markAllAsReadHandler).Methods(http.MethodPost).Name("MarkAllAsRead")
|
||||
sr.PathPrefix("/").HandlerFunc(handler.serveHandler).Methods(http.MethodPost, http.MethodGet).Name("GoogleReaderApiEndpoint")
|
||||
}
|
||||
|
||||
func getStreamFilterModifiers(r *http.Request) (RequestModifiers, error) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
result := RequestModifiers{
|
||||
SortDirection: "desc",
|
||||
UserID: userID,
|
||||
}
|
||||
streamOrder := request.QueryStringParam(r, ParamStreamOrder, "d")
|
||||
if streamOrder == "o" {
|
||||
result.SortDirection = "asc"
|
||||
}
|
||||
var err error
|
||||
result.Streams, err = getStreams(request.QueryStringParamList(r, ParamStreamID), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
}
|
||||
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamExcludes), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
}
|
||||
|
||||
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamFilters), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
}
|
||||
|
||||
result.Count = request.QueryIntParam(r, ParamStreamMaxItems, 0)
|
||||
result.Offset = request.QueryIntParam(r, ParamContinuation, 0)
|
||||
result.StartTime = request.QueryInt64Param(r, ParamStreamStartTime, int64(0))
|
||||
result.StopTime = request.QueryInt64Param(r, ParamStreamStopTime, int64(0))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func getStream(streamID string, userID int64) (Stream, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(streamID, FeedPrefix):
|
||||
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
|
||||
case strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix):
|
||||
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID))
|
||||
id = strings.TrimPrefix(id, StreamPrefix)
|
||||
switch id {
|
||||
case Read:
|
||||
return Stream{ReadStream, ""}, nil
|
||||
case Starred:
|
||||
return Stream{StarredStream, ""}, nil
|
||||
case ReadingList:
|
||||
return Stream{ReadingListStream, ""}, nil
|
||||
case KeptUnread:
|
||||
return Stream{KeptUnreadStream, ""}, nil
|
||||
case Broadcast:
|
||||
return Stream{BroadcastStream, ""}, nil
|
||||
case BroadcastFriends:
|
||||
return Stream{BroadcastFriendsStream, ""}, nil
|
||||
case Like:
|
||||
return Stream{LikeStream, ""}, nil
|
||||
default:
|
||||
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
|
||||
}
|
||||
case strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix):
|
||||
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
|
||||
id = strings.TrimPrefix(id, LabelPrefix)
|
||||
return Stream{LabelStream, id}, nil
|
||||
case streamID == "":
|
||||
return Stream{NoStream, ""}, nil
|
||||
default:
|
||||
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
|
||||
}
|
||||
}
|
||||
|
||||
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
|
||||
streams := make([]Stream, 0)
|
||||
for _, streamID := range streamIDs {
|
||||
stream, err := getStream(streamID, userID)
|
||||
if err != nil {
|
||||
return []Stream{}, err
|
||||
}
|
||||
streams = append(streams, stream)
|
||||
}
|
||||
return streams, nil
|
||||
}
|
||||
|
||||
func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType]bool, error) {
|
||||
tags := make(map[StreamType]bool)
|
||||
for _, s := range addTags {
|
||||
switch s.Type {
|
||||
case ReadStream:
|
||||
if _, ok := tags[KeptUnreadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
}
|
||||
tags[ReadStream] = true
|
||||
case KeptUnreadStream:
|
||||
if _, ok := tags[ReadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
}
|
||||
tags[ReadStream] = false
|
||||
case StarredStream:
|
||||
@@ -337,17 +91,17 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
|
||||
switch s.Type {
|
||||
case ReadStream:
|
||||
if _, ok := tags[ReadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
}
|
||||
tags[ReadStream] = false
|
||||
case KeptUnreadStream:
|
||||
if _, ok := tags[ReadStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
|
||||
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
|
||||
}
|
||||
tags[ReadStream] = true
|
||||
case StarredStream:
|
||||
if _, ok := tags[StarredStream]; ok {
|
||||
return nil, fmt.Errorf("googlereader: %s should not be supplied for add and remove simultaneously", Starred)
|
||||
return nil, fmt.Errorf("googlereader: %s should not be supplied for add and remove simultaneously", starredStreamSuffix)
|
||||
}
|
||||
tags[StarredStream] = false
|
||||
case BroadcastStream, LikeStream:
|
||||
@@ -360,28 +114,6 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func getItemIDs(r *http.Request) ([]int64, error) {
|
||||
items := r.Form[ParamItemIDs]
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("googlereader: no items requested")
|
||||
}
|
||||
|
||||
itemIDs := make([]int64, len(items))
|
||||
|
||||
for i, item := range items {
|
||||
var itemID int64
|
||||
_, err := fmt.Sscanf(item, EntryIDLong, &itemID)
|
||||
if err != nil {
|
||||
itemID, err = strconv.ParseInt(item, 16, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("googlereader: could not parse item: %v", item)
|
||||
}
|
||||
}
|
||||
itemIDs[i] = itemID
|
||||
}
|
||||
return itemIDs, nil
|
||||
}
|
||||
|
||||
func checkOutputFormat(r *http.Request) error {
|
||||
var output string
|
||||
if r.Method == http.MethodPost {
|
||||
@@ -468,7 +200,7 @@ func (h *handler) clientLoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
slog.String("username", username),
|
||||
)
|
||||
|
||||
result := login{SID: token, LSID: token, Auth: token}
|
||||
result := loginResponse{SID: token, LSID: token, Auth: token}
|
||||
if output == "json" {
|
||||
json.OK(w, r, result)
|
||||
return
|
||||
@@ -537,12 +269,12 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
addTags, err := getStreams(r.PostForm[ParamTagsAdd], userID)
|
||||
addTags, err := getStreams(r.PostForm[paramTagsAdd], userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
removeTags, err := getStreams(r.PostForm[ParamTagsRemove], userID)
|
||||
removeTags, err := getStreams(r.PostForm[paramTagsRemove], userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -558,9 +290,9 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
itemIDs, err := getItemIDs(r)
|
||||
itemIDs, err := parseItemIDsFromRequest(r)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -655,7 +387,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
OK(w, r)
|
||||
sendOkayResponse(w)
|
||||
}
|
||||
|
||||
func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -675,7 +407,7 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
feedURL := r.Form.Get(ParamQuickAdd)
|
||||
feedURL := r.Form.Get(paramQuickAdd)
|
||||
if !validator.IsValidURL(feedURL) {
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid URL: %s", feedURL))
|
||||
return
|
||||
@@ -683,14 +415,16 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
|
||||
|
||||
var rssBridgeURL string
|
||||
var rssBridgeToken string
|
||||
if intg, err := h.store.Integration(userID); err == nil && intg != nil && intg.RSSBridgeEnabled {
|
||||
rssBridgeURL = intg.RSSBridgeURL
|
||||
rssBridgeToken = intg.RSSBridgeToken
|
||||
}
|
||||
|
||||
subscriptions, localizedError := mfs.NewSubscriptionFinder(requestBuilder).FindSubscriptions(feedURL, rssBridgeURL)
|
||||
subscriptions, localizedError := mfs.NewSubscriptionFinder(requestBuilder).FindSubscriptions(feedURL, rssBridgeURL, rssBridgeToken)
|
||||
if localizedError != nil {
|
||||
json.ServerError(w, r, localizedError.Error())
|
||||
return
|
||||
@@ -722,7 +456,7 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, quickAddResponse{
|
||||
NumResults: 1,
|
||||
Query: newFeed.FeedURL,
|
||||
StreamID: fmt.Sprintf(FeedPrefix+"%d", newFeed.ID),
|
||||
StreamID: fmt.Sprintf(feedPrefix+"%d", newFeed.ID),
|
||||
StreamName: newFeed.Title,
|
||||
})
|
||||
}
|
||||
@@ -735,17 +469,16 @@ func getFeed(stream Stream, store *storage.Storage, userID int64) (*model.Feed,
|
||||
return store.FeedByID(userID, feedID)
|
||||
}
|
||||
|
||||
func getOrCreateCategory(category Stream, store *storage.Storage, userID int64) (*model.Category, error) {
|
||||
func getOrCreateCategory(streamCategory Stream, store *storage.Storage, userID int64) (*model.Category, error) {
|
||||
switch {
|
||||
case category.ID == "":
|
||||
case streamCategory.ID == "":
|
||||
return store.FirstCategory(userID)
|
||||
case store.CategoryTitleExists(userID, category.ID):
|
||||
return store.CategoryByTitle(userID, category.ID)
|
||||
case store.CategoryTitleExists(userID, streamCategory.ID):
|
||||
return store.CategoryByTitle(userID, streamCategory.ID)
|
||||
default:
|
||||
catRequest := model.CategoryRequest{
|
||||
Title: category.ID,
|
||||
}
|
||||
return store.CreateCategory(userID, &catRequest)
|
||||
return store.CreateCategory(userID, &model.CategoryCreationRequest{
|
||||
Title: streamCategory.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,14 +529,25 @@ func unsubscribe(streams []Stream, store *storage.Storage, userID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func rename(stream Stream, title string, store *storage.Storage, userID int64) error {
|
||||
func rename(feedStream Stream, title string, store *storage.Storage, userID int64) error {
|
||||
slog.Debug("[GoogleReader] Renaming feed",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Any("feed_stream", feedStream),
|
||||
slog.String("new_title", title),
|
||||
)
|
||||
|
||||
if title == "" {
|
||||
return errors.New("empty title")
|
||||
return errEmptyFeedTitle
|
||||
}
|
||||
feed, err := getFeed(stream, store, userID)
|
||||
|
||||
feed, err := getFeed(feedStream, store, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if feed == nil {
|
||||
return errFeedNotFound
|
||||
}
|
||||
|
||||
feedModification := model.FeedModificationRequest{
|
||||
Title: &title,
|
||||
}
|
||||
@@ -811,15 +555,29 @@ func rename(stream Stream, title string, store *storage.Storage, userID int64) e
|
||||
return store.UpdateFeed(feed)
|
||||
}
|
||||
|
||||
func move(stream Stream, destination Stream, store *storage.Storage, userID int64) error {
|
||||
feed, err := getFeed(stream, store, userID)
|
||||
func move(feedStream Stream, labelStream Stream, store *storage.Storage, userID int64) error {
|
||||
slog.Debug("[GoogleReader] Moving feed",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Any("feed_stream", feedStream),
|
||||
slog.Any("label_stream", labelStream),
|
||||
)
|
||||
|
||||
feed, err := getFeed(feedStream, store, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
category, err := getOrCreateCategory(destination, store, userID)
|
||||
if feed == nil {
|
||||
return errFeedNotFound
|
||||
}
|
||||
|
||||
category, err := getOrCreateCategory(labelStream, store, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if category == nil {
|
||||
return errCategoryNotFound
|
||||
}
|
||||
|
||||
feedModification := model.FeedModificationRequest{
|
||||
CategoryID: &category.ID,
|
||||
}
|
||||
@@ -827,6 +585,14 @@ func move(stream Stream, destination Stream, store *storage.Storage, userID int6
|
||||
return store.UpdateFeed(feed)
|
||||
}
|
||||
|
||||
func (h *handler) feedIconURL(f *model.Feed) string {
|
||||
if f.Icon != nil && f.Icon.ExternalIconID != "" {
|
||||
return config.Opts.RootURL() + route.Path(h.router, "feedIcon", "externalIconID", f.Icon.ExternalIconID)
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
clientIP := request.ClientIP(r)
|
||||
@@ -843,20 +609,20 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
streamIds, err := getStreams(r.Form[ParamStreamID], userID)
|
||||
streamIds, err := getStreams(r.Form[paramStreamID], userID)
|
||||
if err != nil || len(streamIds) == 0 {
|
||||
json.BadRequest(w, r, errors.New("googlereader: no valid stream IDs provided"))
|
||||
return
|
||||
}
|
||||
|
||||
newLabel, err := getStream(r.Form.Get(ParamTagsAdd), userID)
|
||||
newLabel, err := getStream(r.Form.Get(paramTagsAdd), userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamTagsAdd))
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramTagsAdd))
|
||||
return
|
||||
}
|
||||
|
||||
title := r.Form.Get(ParamTitle)
|
||||
action := r.Form.Get(ParamSubscribeAction)
|
||||
title := r.Form.Get(paramTitle)
|
||||
action := r.Form.Get(paramSubscribeAction)
|
||||
|
||||
switch action {
|
||||
case "subscribe":
|
||||
@@ -874,32 +640,41 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
|
||||
case "edit":
|
||||
if title != "" {
|
||||
if err := rename(streamIds[0], title, h.store, userID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
if errors.Is(err, errFeedNotFound) || errors.Is(err, errEmptyFeedTitle) {
|
||||
json.BadRequest(w, r, err)
|
||||
} else {
|
||||
json.ServerError(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.Form.Has(ParamTagsAdd) {
|
||||
if r.Form.Has(paramTagsAdd) {
|
||||
if newLabel.Type != LabelStream {
|
||||
json.BadRequest(w, r, errors.New("destination must be a label"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := move(streamIds[0], newLabel, h.store, userID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
if errors.Is(err, errFeedNotFound) || errors.Is(err, errCategoryNotFound) {
|
||||
json.BadRequest(w, r, err)
|
||||
} else {
|
||||
json.ServerError(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
default:
|
||||
json.ServerError(w, r, fmt.Errorf("googlereader: unrecognized action %s", action))
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: unrecognized action %s", action))
|
||||
return
|
||||
}
|
||||
|
||||
OK(w, r)
|
||||
sendOkayResponse(w)
|
||||
}
|
||||
|
||||
func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
userName := request.UserName(r)
|
||||
clientIP := request.ClientIP(r)
|
||||
|
||||
slog.Debug("[GoogleReader] Handle /stream/items/contents",
|
||||
@@ -919,25 +694,20 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
var user *model.User
|
||||
if user, err = h.store.UserByID(userID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
requestModifiers, err := getStreamFilterModifiers(r)
|
||||
requestModifiers, err := parseStreamFilterFromRequest(r)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
userReadingList := fmt.Sprintf(UserStreamPrefix, userID) + ReadingList
|
||||
userRead := fmt.Sprintf(UserStreamPrefix, userID) + Read
|
||||
userStarred := fmt.Sprintf(UserStreamPrefix, userID) + Starred
|
||||
userReadingList := fmt.Sprintf(userStreamPrefix, userID) + readingListStreamSuffix
|
||||
userRead := fmt.Sprintf(userStreamPrefix, userID) + readStreamSuffix
|
||||
userStarred := fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix
|
||||
|
||||
itemIDs, err := getItemIDs(r)
|
||||
itemIDs, err := parseItemIDsFromRequest(r)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -950,6 +720,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithEnclosures()
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithEntryIDs(itemIDs)
|
||||
builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
|
||||
@@ -960,28 +731,17 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: no items returned from the database for item IDs: %v", itemIDs))
|
||||
return
|
||||
}
|
||||
|
||||
result := streamContentItems{
|
||||
result := streamContentItemsResponse{
|
||||
Direction: "ltr",
|
||||
ID: fmt.Sprintf("feed/%d", entries[0].FeedID),
|
||||
Title: entries[0].Feed.Title,
|
||||
Alternate: []contentHREFType{
|
||||
{
|
||||
HREF: entries[0].Feed.SiteURL,
|
||||
Type: "text/html",
|
||||
},
|
||||
},
|
||||
Updated: time.Now().Unix(),
|
||||
ID: "user/-/state/com.google/reading-list",
|
||||
Title: "Reading List",
|
||||
Updated: time.Now().Unix(),
|
||||
Self: []contentHREF{
|
||||
{
|
||||
HREF: config.Opts.RootURL() + route.Path(h.router, "StreamItemsContents"),
|
||||
},
|
||||
},
|
||||
Author: user.Username,
|
||||
Author: userName,
|
||||
}
|
||||
contentItems := make([]contentItem, len(entries))
|
||||
for i, entry := range entries {
|
||||
@@ -992,7 +752,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
categories := make([]string, 0)
|
||||
categories = append(categories, userReadingList)
|
||||
if entry.Feed.Category.Title != "" {
|
||||
categories = append(categories, fmt.Sprintf(UserLabelPrefix, userID)+entry.Feed.Category.Title)
|
||||
categories = append(categories, fmt.Sprintf(userLabelPrefix, userID)+entry.Feed.Category.Title)
|
||||
}
|
||||
if entry.Status == model.EntryStatusRead {
|
||||
categories = append(categories, userRead)
|
||||
@@ -1003,11 +763,10 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
|
||||
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router)
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
contentItems[i] = contentItem{
|
||||
ID: fmt.Sprintf(EntryIDLong, entry.ID),
|
||||
ID: convertEntryIDToLongFormItemID(entry.ID),
|
||||
Title: entry.Title,
|
||||
Author: entry.Author,
|
||||
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
|
||||
@@ -1063,9 +822,9 @@ func (h *handler) disableTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
streams, err := getStreams(r.Form[ParamStreamID], userID)
|
||||
streams, err := getStreams(r.Form[paramStreamID], userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamStreamID))
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramStreamID))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1084,7 +843,7 @@ func (h *handler) disableTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
OK(w, r)
|
||||
sendOkayResponse(w)
|
||||
}
|
||||
|
||||
func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1103,15 +862,15 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
source, err := getStream(r.Form.Get(ParamStreamID), userID)
|
||||
source, err := getStream(r.Form.Get(paramStreamID), userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamStreamID))
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramStreamID))
|
||||
return
|
||||
}
|
||||
|
||||
destination, err := getStream(r.Form.Get(ParamDestination), userID)
|
||||
destination, err := getStream(r.Form.Get(paramDestination), userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamDestination))
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramDestination))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1134,21 +893,24 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
categoryRequest := model.CategoryRequest{
|
||||
Title: destination.ID,
|
||||
|
||||
categoryModificationRequest := model.CategoryModificationRequest{
|
||||
Title: model.SetOptionalField(destination.ID),
|
||||
}
|
||||
verr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryRequest)
|
||||
if verr != nil {
|
||||
json.BadRequest(w, r, verr.Error())
|
||||
|
||||
if validationError := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationError != nil {
|
||||
json.BadRequest(w, r, validationError.Error())
|
||||
return
|
||||
}
|
||||
categoryRequest.Patch(category)
|
||||
err = h.store.UpdateCategory(category)
|
||||
if err != nil {
|
||||
|
||||
categoryModificationRequest.Patch(category)
|
||||
|
||||
if err := h.store.UpdateCategory(category); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
OK(w, r)
|
||||
|
||||
sendOkayResponse(w)
|
||||
}
|
||||
|
||||
func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1172,13 +934,13 @@ func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
result.Tags = make([]subscriptionCategory, 0)
|
||||
result.Tags = append(result.Tags, subscriptionCategory{
|
||||
ID: fmt.Sprintf(UserStreamPrefix, userID) + Starred,
|
||||
result.Tags = make([]subscriptionCategoryResponse, 0)
|
||||
result.Tags = append(result.Tags, subscriptionCategoryResponse{
|
||||
ID: fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix,
|
||||
})
|
||||
for _, category := range categories {
|
||||
result.Tags = append(result.Tags, subscriptionCategory{
|
||||
ID: fmt.Sprintf(UserLabelPrefix, userID) + category.Title,
|
||||
result.Tags = append(result.Tags, subscriptionCategoryResponse{
|
||||
ID: fmt.Sprintf(userLabelPrefix, userID) + category.Title,
|
||||
Label: category.Title,
|
||||
Type: "folder",
|
||||
})
|
||||
@@ -1207,15 +969,16 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
result.Subscriptions = make([]subscription, 0)
|
||||
|
||||
result.Subscriptions = make([]subscriptionResponse, 0)
|
||||
for _, feed := range feeds {
|
||||
result.Subscriptions = append(result.Subscriptions, subscription{
|
||||
ID: fmt.Sprintf(FeedPrefix+"%d", feed.ID),
|
||||
result.Subscriptions = append(result.Subscriptions, subscriptionResponse{
|
||||
ID: fmt.Sprintf(feedPrefix+"%d", feed.ID),
|
||||
Title: feed.Title,
|
||||
URL: feed.FeedURL,
|
||||
Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
|
||||
Categories: []subscriptionCategoryResponse{{fmt.Sprintf(userLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
|
||||
HTMLURL: feed.SiteURL,
|
||||
IconURL: "", // TODO: Icons are base64 encoded in the DB.
|
||||
IconURL: h.feedIconURL(feed),
|
||||
})
|
||||
}
|
||||
json.OK(w, r, result)
|
||||
@@ -1252,7 +1015,7 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
userInfo := userInfo{UserID: fmt.Sprint(user.ID), UserName: user.Username, UserProfileID: fmt.Sprint(user.ID), UserEmail: user.Username}
|
||||
userInfo := userInfoResponse{UserID: fmt.Sprint(user.ID), UserName: user.Username, UserProfileID: fmt.Sprint(user.ID), UserEmail: user.Username}
|
||||
json.OK(w, r, userInfo)
|
||||
}
|
||||
|
||||
@@ -1272,7 +1035,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rm, err := getStreamFilterModifiers(r)
|
||||
rm, err := parseStreamFilterFromRequest(r)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
@@ -1328,7 +1091,7 @@ func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.
|
||||
slog.String("handler", "handleReadingListStreamHandler"),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("filter_type", s.Type),
|
||||
slog.Int("filter_type", int(s.Type)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1499,3 +1262,82 @@ func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request
|
||||
|
||||
json.OK(w, r, streamIDResponse{itemRefs, continuation})
|
||||
}
|
||||
|
||||
func (h *handler) markAllAsReadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
clientIP := request.ClientIP(r)
|
||||
|
||||
slog.Debug("[GoogleReader] Handle /mark-all-as-read",
|
||||
slog.String("handler", "markAllAsReadHandler"),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
stream, err := getStream(r.Form.Get(paramStreamID), userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var before time.Time
|
||||
if timestampParamValue := r.Form.Get(paramTimestamp); timestampParamValue != "" {
|
||||
timestampParsedValue, err := strconv.ParseInt(timestampParamValue, 10, 64)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if timestampParsedValue > 0 {
|
||||
// It's unclear if the timestamp is in seconds or microseconds, so we try both using a naive approach.
|
||||
if len(timestampParamValue) >= 16 {
|
||||
before = time.UnixMicro(timestampParsedValue)
|
||||
} else {
|
||||
before = time.Unix(timestampParsedValue, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if before.IsZero() {
|
||||
before = time.Now()
|
||||
}
|
||||
|
||||
switch stream.Type {
|
||||
case FeedStream:
|
||||
feedID, err := strconv.ParseInt(stream.ID, 10, 64)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
err = h.store.MarkFeedAsRead(userID, feedID, before)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
case LabelStream:
|
||||
category, err := h.store.CategoryByTitle(userID, stream.ID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
if category == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := h.store.MarkCategoryAsRead(userID, category.ID, before); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
case ReadingListStream:
|
||||
if err = h.store.MarkAllAsReadBeforeDate(userID, before); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sendOkayResponse(w)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ItemIDPrefix = "tag:google.com,2005:reader/item/"
|
||||
ItemIDFormat = "tag:google.com,2005:reader/item/%016x"
|
||||
)
|
||||
|
||||
func convertEntryIDToLongFormItemID(entryID int64) string {
|
||||
// The entry ID is a 64-bit integer, so we need to format it as a 16-character hexadecimal string.
|
||||
return fmt.Sprintf(ItemIDFormat, entryID)
|
||||
}
|
||||
|
||||
// Expected format: "tag:google.com,2005:reader/item/00000000148b9369" (hexadecimal string with prefix and padding)
|
||||
// NetNewsWire uses this format: "tag:google.com,2005:reader/item/2f2" (hexadecimal string with prefix and no padding)
|
||||
// Reeder uses this format: "000000000000048c" (hexadecimal string without prefix and padding)
|
||||
// Liferea uses this format: "12345" (decimal string)
|
||||
// It returns the parsed ID as a int64 and an error if parsing fails.
|
||||
func parseItemID(itemIDValue string) (int64, error) {
|
||||
var itemID int64
|
||||
if strings.HasPrefix(itemIDValue, ItemIDPrefix) {
|
||||
n, err := fmt.Sscanf(itemIDValue, ItemIDFormat, &itemID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: %w", itemIDValue, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: expected 1 value, got %d", itemIDValue, n)
|
||||
}
|
||||
if itemID == 0 {
|
||||
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: item ID is zero", itemIDValue)
|
||||
}
|
||||
return itemID, nil
|
||||
}
|
||||
|
||||
if len(itemIDValue) == 16 {
|
||||
if n, err := fmt.Sscanf(itemIDValue, "%016x", &itemID); err == nil && n == 1 {
|
||||
return itemID, nil
|
||||
}
|
||||
}
|
||||
|
||||
itemID, err := strconv.ParseInt(itemIDValue, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse decimal item ID %s: %w", itemIDValue, err)
|
||||
}
|
||||
|
||||
return itemID, nil
|
||||
}
|
||||
|
||||
func parseItemIDsFromRequest(r *http.Request) ([]int64, error) {
|
||||
items := r.Form[paramItemIDs]
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("googlereader: no items requested")
|
||||
}
|
||||
|
||||
itemIDs := make([]int64, len(items))
|
||||
for i, item := range items {
|
||||
itemID, err := parseItemID(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("googlereader: failed to parse item ID %s: %w", item, err)
|
||||
}
|
||||
itemIDs[i] = itemID
|
||||
}
|
||||
|
||||
return itemIDs, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConvertEntryIDToLongFormItemID(t *testing.T) {
|
||||
entryID := int64(344691561)
|
||||
expected := "tag:google.com,2005:reader/item/00000000148b9369"
|
||||
result := convertEntryIDToLongFormItemID(entryID)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("expected %s, got %s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemIDsFromRequest(t *testing.T) {
|
||||
formValues := url.Values{}
|
||||
formValues.Add("i", "12345")
|
||||
formValues.Add("i", "tag:google.com,2005:reader/item/00000000148b9369")
|
||||
formValues.Add("i", "tag:google.com,2005:reader/item/2f2")
|
||||
formValues.Add("i", "000000000000046f")
|
||||
formValues.Add("i", "tag:google.com,2005:reader/item/272")
|
||||
|
||||
request := &http.Request{
|
||||
Form: formValues,
|
||||
}
|
||||
|
||||
result, err := parseItemIDsFromRequest(request)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var expected = []int64{12345, 344691561, 754, 1135, 626}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with no item IDs
|
||||
formValues = url.Values{}
|
||||
request = &http.Request{
|
||||
Form: formValues,
|
||||
}
|
||||
_, err = parseItemIDsFromRequest(request)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemID(t *testing.T) {
|
||||
// Test with long form ID and hex ID
|
||||
result, err := parseItemID("tag:google.com,2005:reader/item/0000000000000001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
expected := int64(1)
|
||||
if result != expected {
|
||||
t.Errorf("expected %d, got %d", expected, result)
|
||||
}
|
||||
|
||||
// Test with hexadecimal long form ID
|
||||
result, err = parseItemID("0000000000000468")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
expected = int64(1128)
|
||||
if result != expected {
|
||||
t.Errorf("expected %d, got %d", expected, result)
|
||||
}
|
||||
|
||||
// Test with short form ID
|
||||
result, err = parseItemID("12345")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
expected = int64(12345)
|
||||
if result != expected {
|
||||
t.Errorf("expected %d, got %d", expected, result)
|
||||
}
|
||||
|
||||
// Test with invalid long form ID
|
||||
_, err = parseItemID("tag:google.com,2005:reader/item/000000000000000g")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
|
||||
// Test with invalid short form ID
|
||||
_, err = parseItemID("invalid_id")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
|
||||
// Test with empty ID
|
||||
_, err = parseItemID("")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -74,7 +74,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(authorization)
|
||||
@@ -84,7 +84,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
if fields[0] != "GoogleLogin" {
|
||||
@@ -93,7 +93,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
auths := strings.Split(fields[1], "=")
|
||||
@@ -103,7 +103,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
if auths[0] != "auth" {
|
||||
@@ -112,7 +112,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
token = auths[1]
|
||||
@@ -126,7 +126,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("token", token),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
var integration *model.Integration
|
||||
@@ -139,7 +139,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
|
||||
@@ -149,7 +149,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
if user, err = m.store.UserByID(integration.UserID); err != nil {
|
||||
@@ -159,7 +159,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -169,22 +169,15 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("[GoogleReader] User authenticated successfully",
|
||||
slog.Bool("authentication_successful", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.String("username", user.Username),
|
||||
)
|
||||
|
||||
m.store.SetLastLogin(integration.UserID)
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
|
||||
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
|
||||
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
|
||||
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
|
||||
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
|
||||
const (
|
||||
// paramItemIDs - name of the parameter with the item ids
|
||||
paramItemIDs = "i"
|
||||
// paramStreamID - name of the parameter containing the stream to be included
|
||||
paramStreamID = "s"
|
||||
// paramStreamExcludes - name of the parameter containing streams to be excluded
|
||||
paramStreamExcludes = "xt"
|
||||
// paramStreamFilters - name of the parameter containing streams to be included
|
||||
paramStreamFilters = "it"
|
||||
// paramStreamMaxItems - name of the parameter containing number of items per page/max items returned
|
||||
paramStreamMaxItems = "n"
|
||||
// paramStreamOrder - name of the parameter containing the sort criteria
|
||||
paramStreamOrder = "r"
|
||||
// paramStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than
|
||||
paramStreamStartTime = "ot"
|
||||
// paramStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than
|
||||
paramStreamStopTime = "nt"
|
||||
// paramTagsRemove - name of the parameter containing tags (streams) to be removed
|
||||
paramTagsRemove = "r"
|
||||
// paramTagsAdd - name of the parameter containing tags (streams) to be added
|
||||
paramTagsAdd = "a"
|
||||
// paramSubscribeAction - name of the parameter indicating the action to take for subscription/edit
|
||||
paramSubscribeAction = "ac"
|
||||
// paramTitle - name of the parameter for the title of the subscription
|
||||
paramTitle = "t"
|
||||
// paramQuickAdd - name of the parameter for a URL being quick subscribed to
|
||||
paramQuickAdd = "quickadd"
|
||||
// paramDestination - name of the parameter for the new name of a tag
|
||||
paramDestination = "dest"
|
||||
// paramContinuation - name of the parameter for callers to pass to receive the next page of results
|
||||
paramContinuation = "c"
|
||||
// paramTimestamp - name of the parameter for unix timestamp
|
||||
paramTimestamp = "ts"
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
|
||||
const (
|
||||
// streamPrefix is the prefix for streams (read/starred/reading list and so on)
|
||||
streamPrefix = "user/-/state/com.google/"
|
||||
// userStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)
|
||||
userStreamPrefix = "user/%d/state/com.google/"
|
||||
// labelPrefix is the prefix for a label stream
|
||||
labelPrefix = "user/-/label/"
|
||||
// userLabelPrefix is the user specific prefix prefix for a label stream
|
||||
userLabelPrefix = "user/%d/label/"
|
||||
// feedPrefix is the prefix for a feed stream
|
||||
feedPrefix = "feed/"
|
||||
// readStreamSuffix is the suffix for read stream
|
||||
readStreamSuffix = "read"
|
||||
// starredStreamSuffix is the suffix for starred stream
|
||||
starredStreamSuffix = "starred"
|
||||
// readingListStreamSuffix is the suffix for reading list stream
|
||||
readingListStreamSuffix = "reading-list"
|
||||
// keptUnreadStreamSuffix is the suffix for kept unread stream
|
||||
keptUnreadStreamSuffix = "kept-unread"
|
||||
// broadcastStreamSuffix is the suffix for broadcast stream
|
||||
broadcastStreamSuffix = "broadcast"
|
||||
// broadcastFriendsStreamSuffix is the suffix for broadcast friends stream
|
||||
broadcastFriendsStreamSuffix = "broadcast-friends"
|
||||
// likeStreamSuffix is the suffix for like stream
|
||||
likeStreamSuffix = "like"
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
)
|
||||
|
||||
type RequestModifiers struct {
|
||||
ExcludeTargets []Stream
|
||||
FilterTargets []Stream
|
||||
Streams []Stream
|
||||
Count int
|
||||
Offset int
|
||||
SortDirection string
|
||||
StartTime int64
|
||||
StopTime int64
|
||||
ContinuationToken string
|
||||
UserID int64
|
||||
}
|
||||
|
||||
func (r RequestModifiers) String() string {
|
||||
var results []string
|
||||
|
||||
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
|
||||
|
||||
var streamStr []string
|
||||
for _, s := range r.Streams {
|
||||
streamStr = append(streamStr, s.String())
|
||||
}
|
||||
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
|
||||
|
||||
var exclusions []string
|
||||
for _, s := range r.ExcludeTargets {
|
||||
exclusions = append(exclusions, s.String())
|
||||
}
|
||||
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
|
||||
|
||||
var filters []string
|
||||
for _, s := range r.FilterTargets {
|
||||
filters = append(filters, s.String())
|
||||
}
|
||||
results = append(results, fmt.Sprintf("Filters: [%s]", strings.Join(filters, ", ")))
|
||||
|
||||
results = append(results, fmt.Sprintf("Count: %d", r.Count))
|
||||
results = append(results, fmt.Sprintf("Offset: %d", r.Offset))
|
||||
results = append(results, fmt.Sprintf("Sort Direction: %s", r.SortDirection))
|
||||
results = append(results, fmt.Sprintf("Continuation Token: %s", r.ContinuationToken))
|
||||
results = append(results, fmt.Sprintf("Start Time: %d", r.StartTime))
|
||||
results = append(results, fmt.Sprintf("Stop Time: %d", r.StopTime))
|
||||
|
||||
return strings.Join(results, "; ")
|
||||
}
|
||||
|
||||
func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
|
||||
userID := request.UserID(r)
|
||||
result := RequestModifiers{
|
||||
SortDirection: "desc",
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
streamOrder := request.QueryStringParam(r, paramStreamOrder, "d")
|
||||
if streamOrder == "o" {
|
||||
result.SortDirection = "asc"
|
||||
}
|
||||
var err error
|
||||
result.Streams, err = getStreams(request.QueryStringParamList(r, paramStreamID), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
}
|
||||
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, paramStreamExcludes), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
}
|
||||
|
||||
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, paramStreamFilters), userID)
|
||||
if err != nil {
|
||||
return RequestModifiers{}, err
|
||||
}
|
||||
|
||||
result.Count = request.QueryIntParam(r, paramStreamMaxItems, 0)
|
||||
result.Offset = request.QueryIntParam(r, paramContinuation, 0)
|
||||
result.StartTime = request.QueryInt64Param(r, paramStreamStartTime, int64(0))
|
||||
result.StopTime = request.QueryInt64Param(r, paramStreamStopTime, int64(0))
|
||||
return result, nil
|
||||
}
|
||||
@@ -6,34 +6,36 @@ package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
)
|
||||
|
||||
type login struct {
|
||||
type loginResponse struct {
|
||||
SID string `json:"SID,omitempty"`
|
||||
LSID string `json:"LSID,omitempty"`
|
||||
Auth string `json:"Auth,omitempty"`
|
||||
}
|
||||
|
||||
func (l login) String() string {
|
||||
func (l loginResponse) String() string {
|
||||
return fmt.Sprintf("SID=%s\nLSID=%s\nAuth=%s\n", l.SID, l.LSID, l.Auth)
|
||||
}
|
||||
|
||||
type userInfo struct {
|
||||
type userInfoResponse struct {
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
UserProfileID string `json:"userProfileId"`
|
||||
UserEmail string `json:"userEmail"`
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Categories []subscriptionCategory `json:"categories"`
|
||||
URL string `json:"url"`
|
||||
HTMLURL string `json:"htmlUrl"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
type subscriptionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Categories []subscriptionCategoryResponse `json:"categories"`
|
||||
URL string `json:"url"`
|
||||
HTMLURL string `json:"htmlUrl"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
}
|
||||
|
||||
type subscriptionsResponse struct {
|
||||
Subscriptions []subscriptionResponse `json:"subscriptions"`
|
||||
}
|
||||
|
||||
type quickAddResponse struct {
|
||||
@@ -43,14 +45,11 @@ type quickAddResponse struct {
|
||||
StreamName string `json:"streamName,omitempty"`
|
||||
}
|
||||
|
||||
type subscriptionCategory struct {
|
||||
type subscriptionCategoryResponse struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
type subscriptionsResponse struct {
|
||||
Subscriptions []subscription `json:"subscriptions"`
|
||||
}
|
||||
|
||||
type itemRef struct {
|
||||
ID string `json:"id"`
|
||||
@@ -64,18 +63,17 @@ type streamIDResponse struct {
|
||||
}
|
||||
|
||||
type tagsResponse struct {
|
||||
Tags []subscriptionCategory `json:"tags"`
|
||||
Tags []subscriptionCategoryResponse `json:"tags"`
|
||||
}
|
||||
|
||||
type streamContentItems struct {
|
||||
Direction string `json:"direction"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Self []contentHREF `json:"self"`
|
||||
Alternate []contentHREFType `json:"alternate"`
|
||||
Updated int64 `json:"updated"`
|
||||
Items []contentItem `json:"items"`
|
||||
Author string `json:"author"`
|
||||
type streamContentItemsResponse struct {
|
||||
Direction string `json:"direction"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Self []contentHREF `json:"self"`
|
||||
Updated int64 `json:"updated"`
|
||||
Items []contentItem `json:"items"`
|
||||
Author string `json:"author"`
|
||||
}
|
||||
|
||||
type contentItem struct {
|
||||
@@ -119,21 +117,15 @@ type contentItemOrigin struct {
|
||||
HTMLUrl string `json:"htmlUrl"`
|
||||
}
|
||||
|
||||
// Unauthorized sends a not authorized error to the client.
|
||||
func Unauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusUnauthorized)
|
||||
builder.WithHeader("Content-Type", "text/plain")
|
||||
builder.WithHeader("X-Reader-Google-Bad-Token", "true")
|
||||
builder.WithBody("Unauthorized")
|
||||
builder.Write()
|
||||
func sendUnauthorizedResponse(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("X-Reader-Google-Bad-Token", "true")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Unauthorized"))
|
||||
}
|
||||
|
||||
// OK sends a ok response to the client.
|
||||
func OK(w http.ResponseWriter, r *http.Request) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusOK)
|
||||
builder.WithHeader("Content-Type", "text/plain")
|
||||
builder.WithBody("OK")
|
||||
builder.Write()
|
||||
func sendOkayResponse(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StreamType int
|
||||
|
||||
const (
|
||||
// NoStream - no stream type
|
||||
NoStream StreamType = iota
|
||||
// ReadStream - read stream type
|
||||
ReadStream
|
||||
// StarredStream - starred stream type
|
||||
StarredStream
|
||||
// ReadingListStream - reading list stream type
|
||||
ReadingListStream
|
||||
// KeptUnreadStream - kept unread stream type
|
||||
KeptUnreadStream
|
||||
// BroadcastStream - broadcast stream type
|
||||
BroadcastStream
|
||||
// BroadcastFriendsStream - broadcast friends stream type
|
||||
BroadcastFriendsStream
|
||||
// LabelStream - label stream type
|
||||
LabelStream
|
||||
// FeedStream - feed stream type
|
||||
FeedStream
|
||||
// LikeStream - like stream type
|
||||
LikeStream
|
||||
)
|
||||
|
||||
// Stream defines a stream type and its ID.
|
||||
type Stream struct {
|
||||
Type StreamType
|
||||
ID string
|
||||
}
|
||||
|
||||
func (s Stream) String() string {
|
||||
return fmt.Sprintf("%v - '%s'", s.Type, s.ID)
|
||||
}
|
||||
|
||||
func (st StreamType) String() string {
|
||||
switch st {
|
||||
case NoStream:
|
||||
return "NoStream"
|
||||
case ReadStream:
|
||||
return "ReadStream"
|
||||
case StarredStream:
|
||||
return "StarredStream"
|
||||
case ReadingListStream:
|
||||
return "ReadingListStream"
|
||||
case KeptUnreadStream:
|
||||
return "KeptUnreadStream"
|
||||
case BroadcastStream:
|
||||
return "BroadcastStream"
|
||||
case BroadcastFriendsStream:
|
||||
return "BroadcastFriendsStream"
|
||||
case LabelStream:
|
||||
return "LabelStream"
|
||||
case FeedStream:
|
||||
return "FeedStream"
|
||||
case LikeStream:
|
||||
return "LikeStream"
|
||||
default:
|
||||
return st.String()
|
||||
}
|
||||
}
|
||||
|
||||
func getStream(streamID string, userID int64) (Stream, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(streamID, feedPrefix):
|
||||
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, feedPrefix)}, nil
|
||||
case strings.HasPrefix(streamID, fmt.Sprintf(userStreamPrefix, userID)), strings.HasPrefix(streamID, streamPrefix):
|
||||
id := strings.TrimPrefix(streamID, fmt.Sprintf(userStreamPrefix, userID))
|
||||
id = strings.TrimPrefix(id, streamPrefix)
|
||||
switch id {
|
||||
case readStreamSuffix:
|
||||
return Stream{ReadStream, ""}, nil
|
||||
case starredStreamSuffix:
|
||||
return Stream{StarredStream, ""}, nil
|
||||
case readingListStreamSuffix:
|
||||
return Stream{ReadingListStream, ""}, nil
|
||||
case keptUnreadStreamSuffix:
|
||||
return Stream{KeptUnreadStream, ""}, nil
|
||||
case broadcastStreamSuffix:
|
||||
return Stream{BroadcastStream, ""}, nil
|
||||
case broadcastFriendsStreamSuffix:
|
||||
return Stream{BroadcastFriendsStream, ""}, nil
|
||||
case likeStreamSuffix:
|
||||
return Stream{LikeStream, ""}, nil
|
||||
default:
|
||||
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
|
||||
}
|
||||
case strings.HasPrefix(streamID, fmt.Sprintf(userLabelPrefix, userID)), strings.HasPrefix(streamID, labelPrefix):
|
||||
id := strings.TrimPrefix(streamID, fmt.Sprintf(userLabelPrefix, userID))
|
||||
id = strings.TrimPrefix(id, labelPrefix)
|
||||
return Stream{LabelStream, id}, nil
|
||||
case streamID == "":
|
||||
return Stream{NoStream, ""}, nil
|
||||
default:
|
||||
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
|
||||
}
|
||||
}
|
||||
|
||||
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
|
||||
streams := make([]Stream, 0, len(streamIDs))
|
||||
for _, streamID := range streamIDs {
|
||||
stream, err := getStream(streamID, userID)
|
||||
if err != nil {
|
||||
return []Stream{}, err
|
||||
}
|
||||
streams = append(streams, stream)
|
||||
}
|
||||
return streams, nil
|
||||
}
|
||||
@@ -18,20 +18,26 @@ const (
|
||||
|
||||
// New creates a new cookie.
|
||||
func New(name, value string, isHTTPS bool, path string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
cookie := &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: basePath(path),
|
||||
Secure: isHTTPS,
|
||||
HttpOnly: true,
|
||||
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
|
||||
// OAuth doesn't work when cookies are in strict mode.
|
||||
if config.Opts.OAuth2Provider() != "" {
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
// Expired returns an expired cookie.
|
||||
func Expired(name string, isHTTPS bool, path string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
cookie := &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: basePath(path),
|
||||
@@ -39,8 +45,14 @@ func Expired(name string, isHTTPS bool, path string) *http.Cookie {
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
|
||||
// OAuth doesn't work when cookies are in strict mode.
|
||||
if config.Opts.OAuth2Provider() != "" {
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
func basePath(path string) string {
|
||||
|
||||
@@ -16,6 +16,7 @@ type ContextKey int
|
||||
// List of context keys.
|
||||
const (
|
||||
UserIDContextKey ContextKey = iota
|
||||
UserNameContextKey
|
||||
UserTimezoneContextKey
|
||||
IsAdminUserContextKey
|
||||
IsAuthenticatedContextKey
|
||||
@@ -28,7 +29,6 @@ const (
|
||||
OAuth2CodeVerifierContextKey
|
||||
FlashMessageContextKey
|
||||
FlashErrorMessageContextKey
|
||||
PocketRequestTokenContextKey
|
||||
LastForceRefreshContextKey
|
||||
ClientIPContextKey
|
||||
GoogleReaderToken
|
||||
@@ -64,6 +64,15 @@ func UserID(r *http.Request) int64 {
|
||||
return getContextInt64Value(r, UserIDContextKey)
|
||||
}
|
||||
|
||||
// UserName returns the username of the logged user.
|
||||
func UserName(r *http.Request) string {
|
||||
value := getContextStringValue(r, UserNameContextKey)
|
||||
if value == "" {
|
||||
value = "unknown"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// UserTimezone returns the timezone used by the logged user.
|
||||
func UserTimezone(r *http.Request) string {
|
||||
value := getContextStringValue(r, UserTimezoneContextKey)
|
||||
@@ -125,11 +134,6 @@ func FlashErrorMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashErrorMessageContextKey)
|
||||
}
|
||||
|
||||
// PocketRequestToken returns the Pocket Request Token if any.
|
||||
func PocketRequestToken(r *http.Request) string {
|
||||
return getContextStringValue(r, PocketRequestTokenContextKey)
|
||||
}
|
||||
|
||||
// LastForceRefresh returns the last force refresh timestamp.
|
||||
func LastForceRefresh(r *http.Request) int64 {
|
||||
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
|
||||
|
||||
@@ -390,28 +390,6 @@ func TestFlashErrorMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPocketRequestToken(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := PocketRequestToken(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, PocketRequestTokenContextKey, "request token")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = PocketRequestToken(r)
|
||||
expected = "request token"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import "net/http"
|
||||
// CookieValue returns the cookie value.
|
||||
func CookieValue(r *http.Request, name string) string {
|
||||
cookie, err := r.Cookie(name)
|
||||
if err == http.ErrNoCookie {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ type Builder struct {
|
||||
statusCode int
|
||||
headers map[string]string
|
||||
enableCompression bool
|
||||
body interface{}
|
||||
body any
|
||||
}
|
||||
|
||||
// WithStatus uses the given status code to build the response.
|
||||
@@ -41,7 +41,7 @@ func (b *Builder) WithHeader(key, value string) *Builder {
|
||||
}
|
||||
|
||||
// WithBody uses the given body to build the response.
|
||||
func (b *Builder) WithBody(body interface{}) *Builder {
|
||||
func (b *Builder) WithBody(body any) *Builder {
|
||||
b.body = body
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package html // import "miniflux.app/v2/internal/http/response/html"
|
||||
|
||||
import (
|
||||
"html"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
@@ -37,10 +38,10 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusInternalServerError)
|
||||
builder.WithHeader("Content-Security-Policy", `default-src 'self'`)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
|
||||
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBody(err)
|
||||
builder.WithBody(html.EscapeString(err.Error()))
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -61,10 +62,10 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusBadRequest)
|
||||
builder.WithHeader("Content-Security-Policy", `default-src 'self'`)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
|
||||
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBody(err)
|
||||
builder.WithBody(html.EscapeString(err.Error()))
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestServerErrorResponse(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ServerError(w, r, errors.New("Some error"))
|
||||
ServerError(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -69,13 +69,13 @@ func TestServerErrorResponse(t *testing.T) {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `Some error`
|
||||
expectedBody := `Some error with injected HTML <script>alert('XSS')</script>`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := "text/html; charset=utf-8"
|
||||
expectedContentType := "text/plain; charset=utf-8"
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
@@ -91,7 +91,7 @@ func TestBadRequestResponse(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
BadRequest(w, r, errors.New("Some error"))
|
||||
BadRequest(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -102,13 +102,13 @@ func TestBadRequestResponse(t *testing.T) {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `Some error`
|
||||
expectedBody := `Some error with injected HTML <script>alert('XSS')</script>`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := "text/html; charset=utf-8"
|
||||
expectedContentType := "text/plain; charset=utf-8"
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
|
||||
@@ -16,19 +16,31 @@ import (
|
||||
const contentTypeHeader = `application/json`
|
||||
|
||||
// OK creates a new JSON response with a 200 status code.
|
||||
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
func OK(w http.ResponseWriter, r *http.Request, body any) {
|
||||
responseBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSON(body))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// Created sends a created response to the client.
|
||||
func Created(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
func Created(w http.ResponseWriter, r *http.Request, body any) {
|
||||
responseBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusCreated)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSON(body))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -62,10 +74,17 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(err)
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusInternalServerError)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(err))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -84,10 +103,17 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(err)
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusBadRequest)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(err))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -105,10 +131,17 @@ func Unauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(errors.New("access unauthorized"))
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusUnauthorized)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(errors.New("access unauthorized")))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -126,10 +159,17 @@ func Forbidden(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(errors.New("access forbidden"))
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusForbidden)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(errors.New("access forbidden")))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -147,27 +187,29 @@ func NotFound(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(errors.New("resource not found"))
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusNotFound)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(errors.New("resource not found")))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
func toJSONError(err error) []byte {
|
||||
func generateJSONError(err error) ([]byte, error) {
|
||||
type errorMsg struct {
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
return toJSON(errorMsg{ErrorMessage: err.Error()})
|
||||
}
|
||||
|
||||
func toJSON(v interface{}) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
encodedBody, err := json.Marshal(errorMsg{ErrorMessage: err.Error()})
|
||||
if err != nil {
|
||||
slog.Error("Unable to marshal JSON response", slog.Any("error", err))
|
||||
return []byte("")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b
|
||||
return encodedBody, nil
|
||||
}
|
||||
|
||||
@@ -293,12 +293,12 @@ func TestBuildInvalidJSONResponse(t *testing.T) {
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusOK
|
||||
expectedStatusCode := http.StatusInternalServerError
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := ``
|
||||
expectedBody := `{"error_message":"json: unsupported type: chan int"}`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
// ContentSecurityPolicyForUntrustedContent is the default CSP for untrusted content.
|
||||
// default-src 'none' disables all content sources
|
||||
// form-action 'none' disables all form submissions
|
||||
// sandbox enables a sandbox for the requested resource
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/form-action
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
|
||||
const ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-action 'none'; sandbox;`
|
||||
+144
-98
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package httpd // import "miniflux.app/v2/internal/http/server"
|
||||
package server // import "miniflux.app/v2/internal/http/server"
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
@@ -30,36 +30,84 @@ import (
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
func StartWebServer(store *storage.Storage, pool *worker.Pool) *http.Server {
|
||||
func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
|
||||
listenAddresses := config.Opts.ListenAddr()
|
||||
var httpServers []*http.Server
|
||||
|
||||
certFile := config.Opts.CertFile()
|
||||
keyFile := config.Opts.CertKeyFile()
|
||||
certDomain := config.Opts.CertDomain()
|
||||
listenAddr := config.Opts.ListenAddr()
|
||||
server := &http.Server{
|
||||
ReadTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
WriteTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
IdleTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
Handler: setupHandler(store, pool),
|
||||
var sharedAutocertTLSConfig *tls.Config
|
||||
|
||||
if certDomain != "" {
|
||||
slog.Debug("Configuring autocert manager and shared TLS config", slog.String("domain", certDomain))
|
||||
certManager := autocert.Manager{
|
||||
Cache: storage.NewCertificateCache(store),
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(certDomain),
|
||||
}
|
||||
|
||||
sharedAutocertTLSConfig = &tls.Config{}
|
||||
sharedAutocertTLSConfig.GetCertificate = certManager.GetCertificate
|
||||
sharedAutocertTLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
|
||||
|
||||
challengeServer := &http.Server{
|
||||
Handler: certManager.HTTPHandler(nil),
|
||||
Addr: ":http",
|
||||
}
|
||||
slog.Info("Starting ACME HTTP challenge server for autocert", slog.String("address", challengeServer.Addr))
|
||||
go func() {
|
||||
if err := challengeServer.ListenAndServe(); err != http.ErrServerClosed {
|
||||
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
config.Opts.HTTPS = true
|
||||
httpServers = append(httpServers, challengeServer)
|
||||
}
|
||||
|
||||
switch {
|
||||
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
|
||||
startSystemdSocketServer(server)
|
||||
case strings.HasPrefix(listenAddr, "/"):
|
||||
startUnixSocketServer(server, listenAddr)
|
||||
case certDomain != "":
|
||||
config.Opts.HTTPS = true
|
||||
startAutoCertTLSServer(server, certDomain, store)
|
||||
case certFile != "" && keyFile != "":
|
||||
config.Opts.HTTPS = true
|
||||
server.Addr = listenAddr
|
||||
startTLSServer(server, certFile, keyFile)
|
||||
default:
|
||||
server.Addr = listenAddr
|
||||
startHTTPServer(server)
|
||||
for i, listenAddr := range listenAddresses {
|
||||
server := &http.Server{
|
||||
ReadTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
WriteTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
IdleTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
|
||||
Handler: setupHandler(store, pool),
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(listenAddr, "/") && os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
|
||||
server.Addr = listenAddr
|
||||
}
|
||||
|
||||
shouldAddServer := true
|
||||
|
||||
switch {
|
||||
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
|
||||
if i == 0 {
|
||||
slog.Info("Starting server using systemd socket for the first listen address", slog.String("address_info", listenAddr))
|
||||
startSystemdSocketServer(server)
|
||||
} else {
|
||||
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses ignored.", slog.String("skipped_address", listenAddr))
|
||||
shouldAddServer = false
|
||||
}
|
||||
case strings.HasPrefix(listenAddr, "/"): // Unix socket
|
||||
startUnixSocketServer(server, listenAddr)
|
||||
case certDomain != "" && (listenAddr == ":https" || (i == 0 && strings.Contains(listenAddr, ":"))):
|
||||
server.Addr = listenAddr
|
||||
startAutoCertTLSServer(server, sharedAutocertTLSConfig)
|
||||
case certFile != "" && keyFile != "":
|
||||
server.Addr = listenAddr
|
||||
startTLSServer(server, certFile, keyFile)
|
||||
config.Opts.HTTPS = true
|
||||
default:
|
||||
server.Addr = listenAddr
|
||||
startHTTPServer(server)
|
||||
}
|
||||
|
||||
if shouldAddServer {
|
||||
httpServers = append(httpServers, server)
|
||||
}
|
||||
}
|
||||
|
||||
return server
|
||||
return httpServers
|
||||
}
|
||||
|
||||
func startSystemdSocketServer(server *http.Server) {
|
||||
@@ -72,83 +120,66 @@ func startSystemdSocketServer(server *http.Server) {
|
||||
|
||||
slog.Info(`Starting server using systemd socket`)
|
||||
if err := server.Serve(listener); err != http.ErrServerClosed {
|
||||
printErrorAndExit(`Server failed to start: %v`, err)
|
||||
printErrorAndExit(`Systemd socket server failed to start: %v`, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startUnixSocketServer(server *http.Server, socketFile string) {
|
||||
os.Remove(socketFile)
|
||||
if err := os.Remove(socketFile); err != nil && !os.IsNotExist(err) {
|
||||
printErrorAndExit("Unable to remove existing Unix socket %s: %v", socketFile, err)
|
||||
}
|
||||
listener, err := net.Listen("unix", socketFile)
|
||||
if err != nil {
|
||||
printErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)
|
||||
}
|
||||
|
||||
go func(sock string) {
|
||||
listener, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
printErrorAndExit(`Server failed to start: %v`, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
if err := os.Chmod(socketFile, 0666); err != nil {
|
||||
printErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(sock, 0666); err != nil {
|
||||
printErrorAndExit(`Unable to change socket permission: %v`, err)
|
||||
}
|
||||
go func() {
|
||||
certFile := config.Opts.CertFile()
|
||||
keyFile := config.Opts.CertKeyFile()
|
||||
|
||||
slog.Info("Starting server using a Unix socket", slog.String("socket", sock))
|
||||
if err := server.Serve(listener); err != http.ErrServerClosed {
|
||||
printErrorAndExit(`Server failed to start: %v`, err)
|
||||
if certFile != "" && keyFile != "" {
|
||||
slog.Info("Starting TLS server using a Unix socket",
|
||||
slog.String("socket", socketFile),
|
||||
slog.String("cert_file", certFile),
|
||||
slog.String("key_file", keyFile),
|
||||
)
|
||||
// Ensure HTTPS is marked as true if any listener uses TLS
|
||||
config.Opts.HTTPS = true
|
||||
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
|
||||
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
} else {
|
||||
slog.Info("Starting server using a Unix socket", slog.String("socket", socketFile))
|
||||
if err := server.Serve(listener); err != http.ErrServerClosed {
|
||||
printErrorAndExit("Unix socket server failed to start on %s: %v", socketFile, err)
|
||||
}
|
||||
}
|
||||
}(socketFile)
|
||||
}()
|
||||
}
|
||||
|
||||
func tlsConfig() *tls.Config {
|
||||
// See https://blog.cloudflare.com/exposing-go-on-the-internet/
|
||||
// And https://wiki.mozilla.org/Security/Server_Side_TLS
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CurvePreferences: []tls.CurveID{
|
||||
tls.CurveP256,
|
||||
tls.X25519,
|
||||
},
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
},
|
||||
func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
|
||||
if server.TLSConfig == nil {
|
||||
server.TLSConfig = &tls.Config{}
|
||||
}
|
||||
}
|
||||
|
||||
func startAutoCertTLSServer(server *http.Server, certDomain string, store *storage.Storage) {
|
||||
server.Addr = ":https"
|
||||
certManager := autocert.Manager{
|
||||
Cache: storage.NewCertificateCache(store),
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(certDomain),
|
||||
}
|
||||
server.TLSConfig = tlsConfig()
|
||||
server.TLSConfig.GetCertificate = certManager.GetCertificate
|
||||
server.TLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
|
||||
|
||||
// Handle http-01 challenge.
|
||||
s := &http.Server{
|
||||
Handler: certManager.HTTPHandler(nil),
|
||||
Addr: ":http",
|
||||
}
|
||||
go s.ListenAndServe()
|
||||
server.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate
|
||||
server.TLSConfig.NextProtos = autoTLSConfig.NextProtos
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting TLS server using automatic certificate management",
|
||||
slog.String("listen_address", server.Addr),
|
||||
slog.String("domain", certDomain),
|
||||
)
|
||||
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
|
||||
printErrorAndExit(`Server failed to start: %v`, err)
|
||||
printErrorAndExit("Autocert server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startTLSServer(server *http.Server, certFile, keyFile string) {
|
||||
server.TLSConfig = tlsConfig()
|
||||
go func() {
|
||||
slog.Info("Starting TLS server using a certificate",
|
||||
slog.String("listen_address", server.Addr),
|
||||
@@ -156,7 +187,7 @@ func startTLSServer(server *http.Server, certFile, keyFile string) {
|
||||
slog.String("key_file", keyFile),
|
||||
)
|
||||
if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {
|
||||
printErrorAndExit(`Server failed to start: %v`, err)
|
||||
printErrorAndExit("TLS server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -167,49 +198,64 @@ func startHTTPServer(server *http.Server) {
|
||||
slog.String("listen_address", server.Addr),
|
||||
)
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
printErrorAndExit(`Server failed to start: %v`, err)
|
||||
printErrorAndExit("HTTP server failed to start on %s: %v", server.Addr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
|
||||
livenessProbe := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
readinessProbe := func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := store.Ping(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Database Connection Error: %q", err), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
// These routes do not take the base path into consideration and are always available at the root of the server.
|
||||
router.HandleFunc("/liveness", livenessProbe).Name("liveness")
|
||||
router.HandleFunc("/healthz", livenessProbe).Name("healthz")
|
||||
router.HandleFunc("/readiness", readinessProbe).Name("readiness")
|
||||
router.HandleFunc("/readyz", readinessProbe).Name("readyz")
|
||||
|
||||
var subrouter *mux.Router
|
||||
if config.Opts.BasePath() != "" {
|
||||
router = router.PathPrefix(config.Opts.BasePath()).Subrouter()
|
||||
subrouter = router.PathPrefix(config.Opts.BasePath()).Subrouter()
|
||||
} else {
|
||||
subrouter = router.NewRoute().Subrouter()
|
||||
}
|
||||
|
||||
if config.Opts.HasMaintenanceMode() {
|
||||
router.Use(func(next http.Handler) http.Handler {
|
||||
subrouter.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(config.Opts.MaintenanceMessage()))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
router.Use(middleware)
|
||||
subrouter.Use(middleware)
|
||||
|
||||
fever.Serve(router, store)
|
||||
googlereader.Serve(router, store)
|
||||
api.Serve(router, store, pool)
|
||||
ui.Serve(router, store, pool)
|
||||
fever.Serve(subrouter, store)
|
||||
googlereader.Serve(subrouter, store)
|
||||
api.Serve(subrouter, store, pool)
|
||||
ui.Serve(subrouter, store, pool)
|
||||
|
||||
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := store.Ping(); err != nil {
|
||||
http.Error(w, "Database Connection Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
subrouter.HandleFunc("/healthcheck", readinessProbe).Name("healthcheck")
|
||||
|
||||
w.Write([]byte("OK"))
|
||||
}).Name("healthcheck")
|
||||
|
||||
router.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
subrouter.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(version.Version))
|
||||
}).Name("version")
|
||||
|
||||
if config.Opts.HasMetricsCollector() {
|
||||
router.Handle("/metrics", promhttp.Handler()).Name("metrics")
|
||||
router.Use(func(next http.Handler) http.Handler {
|
||||
subrouter.Handle("/metrics", promhttp.Handler()).Name("metrics")
|
||||
subrouter.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
route := mux.CurrentRoute(r)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package httpd // import "miniflux.app/v2/internal/http/server"
|
||||
package server // import "miniflux.app/v2/internal/http/server"
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
@@ -69,7 +69,7 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
|
||||
@@ -82,7 +82,7 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
|
||||
@@ -6,13 +6,13 @@ package integration // import "miniflux.app/v2/internal/integration"
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/integration/apprise"
|
||||
"miniflux.app/v2/internal/integration/betula"
|
||||
"miniflux.app/v2/internal/integration/cubox"
|
||||
"miniflux.app/v2/internal/integration/discord"
|
||||
"miniflux.app/v2/internal/integration/espial"
|
||||
"miniflux.app/v2/internal/integration/instapaper"
|
||||
"miniflux.app/v2/internal/integration/karakeep"
|
||||
"miniflux.app/v2/internal/integration/linkace"
|
||||
"miniflux.app/v2/internal/integration/linkding"
|
||||
"miniflux.app/v2/internal/integration/linkwarden"
|
||||
@@ -22,12 +22,13 @@ import (
|
||||
"miniflux.app/v2/internal/integration/nunuxkeeper"
|
||||
"miniflux.app/v2/internal/integration/omnivore"
|
||||
"miniflux.app/v2/internal/integration/pinboard"
|
||||
"miniflux.app/v2/internal/integration/pocket"
|
||||
"miniflux.app/v2/internal/integration/pushover"
|
||||
"miniflux.app/v2/internal/integration/raindrop"
|
||||
"miniflux.app/v2/internal/integration/readeck"
|
||||
"miniflux.app/v2/internal/integration/readwise"
|
||||
"miniflux.app/v2/internal/integration/shaarli"
|
||||
"miniflux.app/v2/internal/integration/shiori"
|
||||
"miniflux.app/v2/internal/integration/slack"
|
||||
"miniflux.app/v2/internal/integration/telegrambot"
|
||||
"miniflux.app/v2/internal/integration/wallabag"
|
||||
"miniflux.app/v2/internal/integration/webhook"
|
||||
@@ -194,24 +195,6 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.PocketEnabled {
|
||||
slog.Debug("Sending entry to Pocket",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := pocket.NewClient(config.Opts.PocketConsumerKey(userIntegrations.PocketConsumerKey), userIntegrations.PocketAccessToken)
|
||||
if err := client.AddURL(entry.URL, entry.Title); err != nil {
|
||||
slog.Error("Unable to send entry to Pocket",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.LinkAceEnabled {
|
||||
slog.Debug("Sending entry to LinkAce",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
@@ -426,6 +409,24 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.KarakeepEnabled {
|
||||
slog.Debug("Sending entry to Karakeep",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := karakeep.NewClient(userIntegrations.KarakeepAPIKey, userIntegrations.KarakeepURL)
|
||||
if err := client.SaveURL(entry.URL); err != nil {
|
||||
slog.Error("Unable to send entry to Karakeep",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.RaindropEnabled {
|
||||
slog.Debug("Sending entry to Raindrop",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
@@ -473,35 +474,47 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
}
|
||||
|
||||
if userIntegrations.WebhookEnabled {
|
||||
var webhookURL string
|
||||
if feed.WebhookURL != "" {
|
||||
webhookURL = feed.WebhookURL
|
||||
} else {
|
||||
webhookURL = userIntegrations.WebhookURL
|
||||
}
|
||||
|
||||
slog.Debug("Sending new entries to Webhook",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("webhook_url", userIntegrations.WebhookURL),
|
||||
slog.String("webhook_url", webhookURL),
|
||||
)
|
||||
|
||||
webhookClient := webhook.NewClient(userIntegrations.WebhookURL, userIntegrations.WebhookSecret)
|
||||
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
|
||||
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
|
||||
slog.Debug("Unable to send new entries to Webhook",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("webhook_url", userIntegrations.WebhookURL),
|
||||
slog.String("webhook_url", webhookURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.NtfyEnabled && feed.NtfyEnabled {
|
||||
ntfyTopic := feed.NtfyTopic
|
||||
if ntfyTopic == "" {
|
||||
ntfyTopic = userIntegrations.NtfyTopic
|
||||
}
|
||||
slog.Debug("Sending new entries to Ntfy",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("topic", ntfyTopic),
|
||||
)
|
||||
|
||||
client := ntfy.NewClient(
|
||||
userIntegrations.NtfyURL,
|
||||
userIntegrations.NtfyTopic,
|
||||
ntfyTopic,
|
||||
userIntegrations.NtfyAPIToken,
|
||||
userIntegrations.NtfyUsername,
|
||||
userIntegrations.NtfyPassword,
|
||||
@@ -553,6 +566,42 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.SlackEnabled {
|
||||
slog.Debug("Sending new entries to Slack",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
client := slack.NewClient(
|
||||
userIntegrations.SlackWebhookLink,
|
||||
)
|
||||
|
||||
if err := client.SendSlackMsg(feed, entries); err != nil {
|
||||
slog.Warn("Unable to send new entries to Slack", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.PushoverEnabled && feed.PushoverEnabled {
|
||||
slog.Debug("Sending new entries to Pushover",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
client := pushover.New(
|
||||
userIntegrations.PushoverUser,
|
||||
userIntegrations.PushoverToken,
|
||||
feed.PushoverPriority,
|
||||
userIntegrations.PushoverDevice,
|
||||
userIntegrations.PushoverPrefix,
|
||||
)
|
||||
|
||||
if err := client.SendMessages(feed, entries); err != nil {
|
||||
slog.Warn("Unable to send new entries to Pushover", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Integrations that only support sending individual entries
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
for _, entry := range entries {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package karakeep // import "miniflux.app/v2/internal/integration/karakeep"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type errorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type saveURLPayload struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
wrapped *http.Client
|
||||
apiEndpoint string
|
||||
apiToken string
|
||||
}
|
||||
|
||||
func NewClient(apiToken string, apiEndpoint string) *Client {
|
||||
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken}
|
||||
}
|
||||
|
||||
func (c *Client) SaveURL(entryURL string) error {
|
||||
requestBody, err := json.Marshal(&saveURLPayload{
|
||||
Type: "link",
|
||||
URL: entryURL,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("karakeep: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("karakeep: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiToken))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
resp, err := c.wrapped.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("karakeep: unable to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("karakeep: failed to parse response: %s", err)
|
||||
}
|
||||
|
||||
if resp.Header.Get("Content-Type") != "application/json" {
|
||||
return fmt.Errorf("karakeep: unexpected content type response: %s", resp.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
var errResponse errorResponse
|
||||
if err := json.Unmarshal(responseBody, &errResponse); err != nil {
|
||||
return fmt.Errorf("karakeep: unable to parse error response: status=%d body=%s", resp.StatusCode, string(responseBody))
|
||||
}
|
||||
return fmt.Errorf("karakeep: failed to save URL: status=%d errorcode=%s %s", resp.StatusCode, errResponse.Code, errResponse.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
return c == ',' || c == ' '
|
||||
}
|
||||
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v2/links")
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
|
||||
}
|
||||
|
||||
@@ -35,12 +35,9 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return fmt.Errorf(`linkwarden: invalid API endpoint: %v`, err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&linkwardenBookmark{
|
||||
Url: entryURL,
|
||||
Name: "",
|
||||
Description: "",
|
||||
Tags: []string{},
|
||||
Collection: map[string]interface{}{},
|
||||
requestBody, err := json.Marshal(map[string]string{
|
||||
"url": entryURL,
|
||||
"name": entryTitle,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -54,8 +51,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.AddCookie(&http.Cookie{Name: "__Secure-next-auth.session-token", Value: c.apiKey})
|
||||
request.AddCookie(&http.Cookie{Name: "next-auth.session-token", Value: c.apiKey})
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
@@ -70,11 +66,3 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type linkwardenBookmark struct {
|
||||
Url string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
Collection map[string]interface{} `json:"collection"`
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pocket // import "miniflux.app/v2/internal/integration/pocket"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
// Connector manages the authorization flow with Pocket to get a personal access token.
|
||||
type Connector struct {
|
||||
consumerKey string
|
||||
}
|
||||
|
||||
// NewConnector returns a new Pocket Connector.
|
||||
func NewConnector(consumerKey string) *Connector {
|
||||
return &Connector{consumerKey}
|
||||
}
|
||||
|
||||
// RequestToken fetches a new request token from Pocket API.
|
||||
func (c *Connector) RequestToken(redirectURL string) (string, error) {
|
||||
apiEndpoint := "https://getpocket.com/v3/oauth/request"
|
||||
requestBody, err := json.Marshal(&createTokenRequest{ConsumerKey: c.consumerKey, RedirectURI: redirectURL})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("pocket: unable get request token: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
var result createTokenResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result.Code == "" {
|
||||
return "", errors.New("pocket: request token is empty")
|
||||
}
|
||||
|
||||
return result.Code, nil
|
||||
}
|
||||
|
||||
// AccessToken fetches a new access token once the end-user authorized the application.
|
||||
func (c *Connector) AccessToken(requestToken string) (string, error) {
|
||||
apiEndpoint := "https://getpocket.com/v3/oauth/authorize"
|
||||
requestBody, err := json.Marshal(&authorizeRequest{ConsumerKey: c.consumerKey, Code: requestToken})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("pocket: unable get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
var result authorizeReponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("pocket: unable to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result.AccessToken == "" {
|
||||
return "", errors.New("pocket: access token is empty")
|
||||
}
|
||||
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
|
||||
// AuthorizationURL returns the authorization URL for the end-user.
|
||||
func (c *Connector) AuthorizationURL(requestToken, redirectURL string) string {
|
||||
return fmt.Sprintf(
|
||||
"https://getpocket.com/auth/authorize?request_token=%s&redirect_uri=%s",
|
||||
requestToken,
|
||||
redirectURL,
|
||||
)
|
||||
}
|
||||
|
||||
type createTokenRequest struct {
|
||||
ConsumerKey string `json:"consumer_key"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
type createTokenResponse struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type authorizeRequest struct {
|
||||
ConsumerKey string `json:"consumer_key"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type authorizeReponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pocket // import "miniflux.app/v2/internal/integration/pocket"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
consumerKey string
|
||||
accessToken string
|
||||
}
|
||||
|
||||
func NewClient(consumerKey, accessToken string) *Client {
|
||||
return &Client{consumerKey, accessToken}
|
||||
}
|
||||
|
||||
func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
if c.consumerKey == "" || c.accessToken == "" {
|
||||
return fmt.Errorf("pocket: missing consumer key or access token")
|
||||
}
|
||||
|
||||
apiEndpoint := "https://getpocket.com/v3/add"
|
||||
requestBody, err := json.Marshal(&createItemRequest{
|
||||
AccessToken: c.accessToken,
|
||||
ConsumerKey: c.consumerKey,
|
||||
Title: entryTitle,
|
||||
URL: entryURL,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("pocket: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("pocket: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pocket: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("pocket: unable to create item: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type createItemRequest struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ConsumerKey string `json:"consumer_key"`
|
||||
Title string `json:"title,omitempty"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pushover // import "miniflux.app/v2/internal/integration/pushover"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClientTimeout = 10 * time.Second
|
||||
defaultPushoverURL = "https://api.pushover.net"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
prefix string
|
||||
|
||||
token string
|
||||
user string
|
||||
device string
|
||||
|
||||
priority int
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Token string `json:"token"`
|
||||
User string `json:"user"`
|
||||
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
|
||||
URL string `json:"url"`
|
||||
URLTitle string `json:"url_title"`
|
||||
Device string `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
User string `json:"user"`
|
||||
Errors []string `json:"errors"`
|
||||
Status int `json:"status"`
|
||||
Request string `json:"request"`
|
||||
}
|
||||
|
||||
func New(user, token string, priority int, device, urlPrefix string) *Client {
|
||||
if urlPrefix == "" {
|
||||
urlPrefix = defaultPushoverURL
|
||||
}
|
||||
if priority < -2 {
|
||||
priority = -2
|
||||
}
|
||||
if priority > 2 {
|
||||
priority = 2
|
||||
}
|
||||
|
||||
return &Client{
|
||||
user: user,
|
||||
token: token,
|
||||
device: device,
|
||||
prefix: urlPrefix,
|
||||
priority: priority,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
if c.token == "" || c.user == "" {
|
||||
return fmt.Errorf("pushover token and user are required")
|
||||
}
|
||||
for _, entry := range entries {
|
||||
msg := &Message{
|
||||
User: c.user,
|
||||
Token: c.token,
|
||||
Device: c.device,
|
||||
|
||||
Message: entry.Title,
|
||||
Title: feed.Title,
|
||||
Priority: c.priority,
|
||||
URL: entry.URL,
|
||||
}
|
||||
|
||||
slog.Debug("Sending Pushover message",
|
||||
slog.Int("priority", msg.Priority),
|
||||
slog.String("message", msg.Message),
|
||||
slog.String("entry_url", msg.URL),
|
||||
)
|
||||
|
||||
if err := c.makeRequest(msg); err != nil {
|
||||
return fmt.Errorf("c.makeRequest: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) makeRequest(payload *Message) error {
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("json.Marshal: %w", err)
|
||||
}
|
||||
url := c.prefix + "/1/messages.json"
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("http.NewRequest: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("httpClient.Do: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
errorMessage := resp.Status
|
||||
|
||||
var errResp ErrorResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
|
||||
if len(errResp.Errors) > 0 {
|
||||
errorMessage = strings.Join(errResp.Errors, ",")
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("pushover API error (%d): %s", resp.StatusCode, errorMessage)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -24,13 +24,16 @@ type BridgeMeta struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func DetectBridges(rssBridgeURL, websiteURL string) ([]*Bridge, error) {
|
||||
func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge, error) {
|
||||
endpointURL, err := url.Parse(rssBridgeURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RSS-Bridge: unable to parse bridge URL: %w", err)
|
||||
}
|
||||
|
||||
values := endpointURL.Query()
|
||||
if rssBridgeToken != "" {
|
||||
values.Add("token", rssBridgeToken)
|
||||
}
|
||||
values.Add("action", "findfeed")
|
||||
values.Add("format", "atom")
|
||||
values.Add("url", websiteURL)
|
||||
@@ -78,6 +81,15 @@ func DetectBridges(rssBridgeURL, websiteURL string) ([]*Bridge, error) {
|
||||
slog.String("url", bridge.URL),
|
||||
)
|
||||
}
|
||||
|
||||
if rssBridgeToken != "" {
|
||||
bridge.URL = bridge.URL + "&token=" + rssBridgeToken
|
||||
|
||||
slog.Debug("Appended token to RSS bridge URL",
|
||||
slog.String("name", bridge.BridgeMeta.Name),
|
||||
slog.String("url", bridge.URL),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return bridgeResponse, nil
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Slack Webhooks documentation: https://api.slack.com/messaging/webhooks
|
||||
|
||||
package slack // import "miniflux.app/v2/internal/integration/slack"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
const slackMsgColor = "#5865F2"
|
||||
|
||||
type Client struct {
|
||||
webhookURL string
|
||||
}
|
||||
|
||||
func NewClient(webhookURL string) *Client {
|
||||
return &Client{webhookURL: webhookURL}
|
||||
}
|
||||
|
||||
func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
|
||||
for _, entry := range entries {
|
||||
requestBody, err := json.Marshal(&slackMessage{
|
||||
Attachments: []slackAttachments{
|
||||
{
|
||||
Title: "RSS feed update from Miniflux",
|
||||
Color: slackMsgColor,
|
||||
Fields: []slackFields{
|
||||
{
|
||||
Title: "Updated feed",
|
||||
Value: feed.Title,
|
||||
},
|
||||
{
|
||||
Title: "Article title",
|
||||
Value: entry.Title,
|
||||
},
|
||||
{
|
||||
Title: "Article link",
|
||||
Value: entry.URL,
|
||||
},
|
||||
{
|
||||
Title: "Author",
|
||||
Value: entry.Author,
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Source website",
|
||||
Value: urllib.RootURL(feed.SiteURL),
|
||||
Short: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
slog.Debug("Sending Slack notification",
|
||||
slog.String("webhookURL", c.webhookURL),
|
||||
slog.String("title", feed.Title),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to send request: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type slackFields struct {
|
||||
Title string `json:"title"`
|
||||
Value string `json:"value"`
|
||||
Short bool `json:"short,omitempty"`
|
||||
}
|
||||
|
||||
type slackAttachments struct {
|
||||
Title string `json:"title"`
|
||||
Color string `json:"color"`
|
||||
Fields []slackFields `json:"fields"`
|
||||
}
|
||||
|
||||
type slackMessage struct {
|
||||
Attachments []slackAttachments `json:"attachments"`
|
||||
}
|
||||
@@ -5,8 +5,12 @@ package telegrambot // import "miniflux.app/v2/internal/integration/telegrambot"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
func PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, topicID *int64, disableWebPagePreview, disableNotification bool, disableButtons bool) error {
|
||||
@@ -32,8 +36,16 @@ func PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, to
|
||||
if !disableButtons {
|
||||
var markupRow []*InlineKeyboardButton
|
||||
|
||||
websiteURLButton := InlineKeyboardButton{Text: "Go to website", URL: feed.SiteURL}
|
||||
markupRow = append(markupRow, &websiteURLButton)
|
||||
baseURL := config.Opts.BaseURL()
|
||||
entryPath := "/unread/entry/" + strconv.FormatInt(entry.ID, 10)
|
||||
|
||||
minifluxEntryURL, err := urllib.JoinBaseURLAndPath(baseURL, entryPath)
|
||||
if err != nil {
|
||||
slog.Error("Unable to create Miniflux entry URL", slog.Any("error", err))
|
||||
} else {
|
||||
minifluxEntryURLButton := InlineKeyboardButton{Text: "Go to Miniflux", URL: minifluxEntryURL}
|
||||
markupRow = append(markupRow, &minifluxEntryURLButton)
|
||||
}
|
||||
|
||||
articleURLButton := InlineKeyboardButton{Text: "Go to article", URL: entry.URL}
|
||||
markupRow = append(markupRow, &articleURLButton)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type translationDict map[string]interface{}
|
||||
type translationDict map[string]any
|
||||
type catalog map[string]translationDict
|
||||
|
||||
var defaultCatalog = make(catalog, len(AvailableLanguages))
|
||||
@@ -17,7 +17,7 @@ var defaultCatalog = make(catalog, len(AvailableLanguages))
|
||||
//go:embed translations/*.json
|
||||
var translationFiles embed.FS
|
||||
|
||||
func GetTranslationDict(language string) (translationDict, error) {
|
||||
func getTranslationDict(language string) (translationDict, error) {
|
||||
if _, ok := defaultCatalog[language]; !ok {
|
||||
var err error
|
||||
if defaultCatalog[language], err = loadTranslationFile(language); err != nil {
|
||||
@@ -27,21 +27,8 @@ func GetTranslationDict(language string) (translationDict, error) {
|
||||
return defaultCatalog[language], nil
|
||||
}
|
||||
|
||||
// LoadCatalogMessages loads and parses all translations encoded in JSON.
|
||||
func LoadCatalogMessages() error {
|
||||
var err error
|
||||
|
||||
for language := range AvailableLanguages {
|
||||
defaultCatalog[language], err = loadTranslationFile(language)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadTranslationFile(language string) (translationDict, error) {
|
||||
translationFileData, err := translationFiles.ReadFile(fmt.Sprintf("translations/%s.json", language))
|
||||
translationFileData, err := translationFiles.ReadFile("translations/" + language + ".json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -33,8 +33,11 @@ func TestParser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadCatalog(t *testing.T) {
|
||||
if err := LoadCatalogMessages(); err != nil {
|
||||
t.Fatal(err)
|
||||
for language := range AvailableLanguages {
|
||||
_, err := loadTranslationFile(language)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +86,7 @@ func TestMissingTranslations(t *testing.T) {
|
||||
|
||||
for key := range references {
|
||||
if _, found := messages[key]; !found {
|
||||
t.Fatalf(`Translation key %q not found in language %q`, key, language)
|
||||
t.Errorf(`Translation key %q not found in language %q`, key, language)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,24 +94,26 @@ func TestMissingTranslations(t *testing.T) {
|
||||
|
||||
func TestTranslationFilePluralForms(t *testing.T) {
|
||||
var numberOfPluralFormsPerLanguage = map[string]int{
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fr_FR": 2,
|
||||
"de_DE": 2,
|
||||
"pl_PL": 3,
|
||||
"pt_BR": 2,
|
||||
"zh_CN": 1,
|
||||
"zh_TW": 1,
|
||||
"nl_NL": 2,
|
||||
"ru_RU": 3,
|
||||
"it_IT": 2,
|
||||
"ja_JP": 1,
|
||||
"tr_TR": 2,
|
||||
"el_EL": 2,
|
||||
"fi_FI": 2,
|
||||
"hi_IN": 2,
|
||||
"uk_UA": 3,
|
||||
"id_ID": 1,
|
||||
"de_DE": 2,
|
||||
"el_EL": 2,
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fi_FI": 2,
|
||||
"fr_FR": 2,
|
||||
"hi_IN": 2,
|
||||
"id_ID": 1,
|
||||
"it_IT": 2,
|
||||
"ja_JP": 1,
|
||||
"nan_Latn_pehoeji": 1,
|
||||
"nl_NL": 2,
|
||||
"pl_PL": 3,
|
||||
"pt_BR": 2,
|
||||
"ro_RO": 3,
|
||||
"ru_RU": 3,
|
||||
"tr_TR": 2,
|
||||
"uk_UA": 3,
|
||||
"zh_CN": 1,
|
||||
"zh_TW": 1,
|
||||
}
|
||||
for language := range AvailableLanguages {
|
||||
messages, err := loadTranslationFile(language)
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewLocalizedErrorWrapper(t *testing.T) {
|
||||
originalErr := errors.New("original error message")
|
||||
translationKey := "error.test_key"
|
||||
args := []any{"arg1", 42}
|
||||
|
||||
wrapper := NewLocalizedErrorWrapper(originalErr, translationKey, args...)
|
||||
|
||||
if wrapper.originalErr != originalErr {
|
||||
t.Errorf("Expected original error to be %v, got %v", originalErr, wrapper.originalErr)
|
||||
}
|
||||
|
||||
if wrapper.translationKey != translationKey {
|
||||
t.Errorf("Expected translation key to be %q, got %q", translationKey, wrapper.translationKey)
|
||||
}
|
||||
|
||||
if len(wrapper.translationArgs) != 2 {
|
||||
t.Errorf("Expected 2 translation args, got %d", len(wrapper.translationArgs))
|
||||
}
|
||||
|
||||
if wrapper.translationArgs[0] != "arg1" || wrapper.translationArgs[1] != 42 {
|
||||
t.Errorf("Expected translation args [arg1, 42], got %v", wrapper.translationArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedErrorWrapper_Error(t *testing.T) {
|
||||
originalErr := errors.New("original error message")
|
||||
wrapper := NewLocalizedErrorWrapper(originalErr, "error.test_key")
|
||||
|
||||
result := wrapper.Error()
|
||||
if result != originalErr {
|
||||
t.Errorf("Expected Error() to return original error %v, got %v", originalErr, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedErrorWrapper_Translate(t *testing.T) {
|
||||
// Set up test catalog
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"error.test_key": "Error: %s (code: %d)",
|
||||
},
|
||||
"fr_FR": translationDict{
|
||||
"error.test_key": "Erreur : %s (code : %d)",
|
||||
},
|
||||
}
|
||||
|
||||
originalErr := errors.New("original error")
|
||||
wrapper := NewLocalizedErrorWrapper(originalErr, "error.test_key", "test message", 404)
|
||||
|
||||
// Test English translation
|
||||
result := wrapper.Translate("en_US")
|
||||
expected := "Error: test message (code: 404)"
|
||||
if result != expected {
|
||||
t.Errorf("Expected English translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
// Test French translation
|
||||
result = wrapper.Translate("fr_FR")
|
||||
expected = "Erreur : test message (code : 404)"
|
||||
if result != expected {
|
||||
t.Errorf("Expected French translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
// Test with missing language (should use key as fallback with args applied)
|
||||
result = wrapper.Translate("invalid_lang")
|
||||
expected = "error.test_key%!(EXTRA string=test message, int=404)"
|
||||
if result != expected {
|
||||
t.Errorf("Expected fallback translation %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedErrorWrapper_TranslateWithEmptyKey(t *testing.T) {
|
||||
originalErr := errors.New("original error message")
|
||||
wrapper := NewLocalizedErrorWrapper(originalErr, "")
|
||||
|
||||
result := wrapper.Translate("en_US")
|
||||
expected := "original error message"
|
||||
if result != expected {
|
||||
t.Errorf("Expected original error message %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedErrorWrapper_TranslateWithNoArgs(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"error.simple": "Simple error message",
|
||||
},
|
||||
}
|
||||
|
||||
originalErr := errors.New("original error")
|
||||
wrapper := NewLocalizedErrorWrapper(originalErr, "error.simple")
|
||||
|
||||
result := wrapper.Translate("en_US")
|
||||
expected := "Simple error message"
|
||||
if result != expected {
|
||||
t.Errorf("Expected translation %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLocalizedError(t *testing.T) {
|
||||
translationKey := "error.validation"
|
||||
args := []any{"field1", "invalid"}
|
||||
|
||||
localizedErr := NewLocalizedError(translationKey, args...)
|
||||
|
||||
if localizedErr.translationKey != translationKey {
|
||||
t.Errorf("Expected translation key to be %q, got %q", translationKey, localizedErr.translationKey)
|
||||
}
|
||||
|
||||
if len(localizedErr.translationArgs) != 2 {
|
||||
t.Errorf("Expected 2 translation args, got %d", len(localizedErr.translationArgs))
|
||||
}
|
||||
|
||||
if localizedErr.translationArgs[0] != "field1" || localizedErr.translationArgs[1] != "invalid" {
|
||||
t.Errorf("Expected translation args [field1, invalid], got %v", localizedErr.translationArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_String(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"error.validation": "Validation failed for %s: %s",
|
||||
},
|
||||
}
|
||||
|
||||
localizedErr := NewLocalizedError("error.validation", "username", "too short")
|
||||
|
||||
result := localizedErr.String()
|
||||
expected := "Validation failed for username: too short"
|
||||
if result != expected {
|
||||
t.Errorf("Expected String() result %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_StringWithMissingTranslation(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{},
|
||||
}
|
||||
|
||||
localizedErr := NewLocalizedError("error.missing", "arg1")
|
||||
|
||||
result := localizedErr.String()
|
||||
expected := "error.missing%!(EXTRA string=arg1)"
|
||||
if result != expected {
|
||||
t.Errorf("Expected String() result %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_Error(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"error.database": "Database connection failed: %s",
|
||||
},
|
||||
}
|
||||
|
||||
localizedErr := NewLocalizedError("error.database", "timeout")
|
||||
|
||||
result := localizedErr.Error()
|
||||
if result == nil {
|
||||
t.Error("Expected Error() to return a non-nil error")
|
||||
}
|
||||
|
||||
expected := "Database connection failed: timeout"
|
||||
if result.Error() != expected {
|
||||
t.Errorf("Expected Error() message %q, got %q", expected, result.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_Translate(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"error.permission": "Permission denied for %s",
|
||||
},
|
||||
"es_ES": translationDict{
|
||||
"error.permission": "Permiso denegado para %s",
|
||||
},
|
||||
}
|
||||
|
||||
localizedErr := NewLocalizedError("error.permission", "admin panel")
|
||||
|
||||
// Test English translation
|
||||
result := localizedErr.Translate("en_US")
|
||||
expected := "Permission denied for admin panel"
|
||||
if result != expected {
|
||||
t.Errorf("Expected English translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
// Test Spanish translation
|
||||
result = localizedErr.Translate("es_ES")
|
||||
expected = "Permiso denegado para admin panel"
|
||||
if result != expected {
|
||||
t.Errorf("Expected Spanish translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
// Test with missing language
|
||||
result = localizedErr.Translate("invalid_lang")
|
||||
expected = "error.permission%!(EXTRA string=admin panel)"
|
||||
if result != expected {
|
||||
t.Errorf("Expected fallback translation %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_TranslateWithNoArgs(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"error.generic": "An error occurred",
|
||||
},
|
||||
"de_DE": translationDict{
|
||||
"error.generic": "Ein Fehler ist aufgetreten",
|
||||
},
|
||||
}
|
||||
|
||||
localizedErr := NewLocalizedError("error.generic")
|
||||
|
||||
// Test English
|
||||
result := localizedErr.Translate("en_US")
|
||||
expected := "An error occurred"
|
||||
if result != expected {
|
||||
t.Errorf("Expected English translation %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
// Test German
|
||||
result = localizedErr.Translate("de_DE")
|
||||
expected = "Ein Fehler ist aufgetreten"
|
||||
if result != expected {
|
||||
t.Errorf("Expected German translation %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_TranslateWithComplexArgs(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"error.complex": "Error %d: %s occurred at %s with severity %s",
|
||||
},
|
||||
}
|
||||
|
||||
localizedErr := NewLocalizedError("error.complex", 500, "Internal Server Error", "2024-01-01", "high")
|
||||
|
||||
result := localizedErr.Translate("en_US")
|
||||
expected := "Error 500: Internal Server Error occurred at 2024-01-01 with severity high"
|
||||
if result != expected {
|
||||
t.Errorf("Expected complex translation %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedErrorWrapper_WithNilError(t *testing.T) {
|
||||
// This tests edge case behavior - what happens with nil error
|
||||
wrapper := NewLocalizedErrorWrapper(nil, "error.test")
|
||||
|
||||
// Error() should return nil
|
||||
result := wrapper.Error()
|
||||
if result != nil {
|
||||
t.Errorf("Expected Error() to return nil, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedError_EmptyKey(t *testing.T) {
|
||||
localizedErr := NewLocalizedError("")
|
||||
|
||||
result := localizedErr.String()
|
||||
expected := ""
|
||||
if result != expected {
|
||||
t.Errorf("Expected empty string for empty key, got %q", result)
|
||||
}
|
||||
|
||||
result = localizedErr.Translate("en_US")
|
||||
if result != expected {
|
||||
t.Errorf("Expected empty string for empty key translation, got %q", result)
|
||||
}
|
||||
}
|
||||
+20
-18
@@ -5,22 +5,24 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
// AvailableLanguages is the list of available languages.
|
||||
var AvailableLanguages = map[string]string{
|
||||
"en_US": "English",
|
||||
"es_ES": "Español",
|
||||
"fr_FR": "Français",
|
||||
"de_DE": "Deutsch",
|
||||
"pl_PL": "Polski",
|
||||
"pt_BR": "Português Brasileiro",
|
||||
"zh_CN": "简体中文",
|
||||
"zh_TW": "繁體中文",
|
||||
"nl_NL": "Nederlands",
|
||||
"ru_RU": "Русский",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"tr_TR": "Türkçe",
|
||||
"el_EL": "Ελληνικά",
|
||||
"fi_FI": "Suomi",
|
||||
"hi_IN": "हिन्दी",
|
||||
"uk_UA": "Українська",
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
"de_DE": "Deutsch",
|
||||
"el_EL": "Ελληνικά",
|
||||
"en_US": "English",
|
||||
"es_ES": "Español",
|
||||
"fi_FI": "Suomi",
|
||||
"fr_FR": "Français",
|
||||
"hi_IN": "हिन्दी",
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"nan_Latn_pehoeji": "Pe̍h-ōe-jī",
|
||||
"nl_NL": "Nederlands",
|
||||
"pl_PL": "Polski",
|
||||
"pt_BR": "Português Brasileiro",
|
||||
"ro_RO": "Română",
|
||||
"ru_RU": "Русский",
|
||||
"tr_TR": "Türkçe",
|
||||
"uk_UA": "Українська",
|
||||
"zh_CN": "简体中文",
|
||||
"zh_TW": "繁體中文",
|
||||
}
|
||||
|
||||
+33
-64
@@ -5,16 +5,9 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
|
||||
// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
|
||||
var pluralForms = map[string](func(n int) int){
|
||||
// nplurals=2; plural=(n != 1);
|
||||
"default": func(n int) int {
|
||||
if n != 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
// nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);
|
||||
"ar_AR": func(n int) int {
|
||||
func getPluralForm(lang string, n int) int {
|
||||
switch lang {
|
||||
case "ar_AR":
|
||||
switch {
|
||||
case n == 0:
|
||||
return 0
|
||||
@@ -26,77 +19,53 @@ var pluralForms = map[string](func(n int) int){
|
||||
return 3
|
||||
case n%100 >= 11:
|
||||
return 4
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
return 5
|
||||
},
|
||||
// nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
|
||||
"cs_CZ": func(n int) int {
|
||||
case "cs_CZ":
|
||||
switch {
|
||||
case n == 1:
|
||||
return 0
|
||||
case n >= 2 && n <= 4:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
return 2
|
||||
},
|
||||
// nplurals=2; plural=(n > 1);
|
||||
"fr_FR": func(n int) int {
|
||||
if n > 1 {
|
||||
return 1
|
||||
}
|
||||
case "id_ID", "ja_JP":
|
||||
return 0
|
||||
},
|
||||
// nplurals=1; plural=0;
|
||||
"id_ID": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
// nplurals=1; plural=0;
|
||||
"ja_JP": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
// nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
|
||||
"pl_PL": func(n int) int {
|
||||
case "pl_PL":
|
||||
switch {
|
||||
case n == 1:
|
||||
return 0
|
||||
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
return 2
|
||||
},
|
||||
// nplurals=2; plural=(n > 1);
|
||||
"pt_BR": func(n int) int {
|
||||
case "ro_RO":
|
||||
switch {
|
||||
case n == 1:
|
||||
return 0
|
||||
case n == 0 || (n%100 > 0 && n%100 < 20):
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
case "ru_RU", "uk_UA", "sr_RS":
|
||||
switch {
|
||||
case n%10 == 1 && n%100 != 11:
|
||||
return 0
|
||||
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
case "zh_CN", "zh_TW", "nan_Latn_pehoeji":
|
||||
return 0
|
||||
default: // includes fr_FR, pr_BR, tr_TR
|
||||
if n > 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
"ru_RU": pluralFormRuSrUa,
|
||||
// nplurals=2; plural=(n > 1);
|
||||
"tr_TR": func(n int) int {
|
||||
if n > 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
"uk_UA": pluralFormRuSrUa,
|
||||
"sr_RS": pluralFormRuSrUa,
|
||||
// nplurals=1; plural=0;
|
||||
"zh_CN": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
"zh_TW": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
}
|
||||
|
||||
// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
|
||||
func pluralFormRuSrUa(n int) int {
|
||||
switch {
|
||||
case n%10 == 1 && n%100 != 11:
|
||||
return 0
|
||||
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
+160
-43
@@ -7,81 +7,198 @@ import "testing"
|
||||
|
||||
func TestPluralRules(t *testing.T) {
|
||||
scenarios := map[string]map[int]int{
|
||||
// Default rule (covers fr_FR, pt_BR, tr_TR, and other unlisted languages)
|
||||
"default": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 1,
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
5: 1, // n > 1
|
||||
},
|
||||
// Arabic (ar_AR) - 6 forms
|
||||
"ar_AR": {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
5: 3,
|
||||
11: 4,
|
||||
200: 5,
|
||||
0: 0, // n == 0
|
||||
1: 1, // n == 1
|
||||
2: 2, // n == 2
|
||||
3: 3, // n%100 >= 3 && n%100 <= 10
|
||||
5: 3, // n%100 >= 3 && n%100 <= 10
|
||||
10: 3, // n%100 >= 3 && n%100 <= 10
|
||||
11: 4, // n%100 >= 11
|
||||
15: 4, // n%100 >= 11
|
||||
99: 4, // n%100 >= 11
|
||||
100: 5, // default case (n%100 == 0, doesn't match any condition)
|
||||
101: 5, // default case (n%100 == 1, but n != 1)
|
||||
200: 5, // default case
|
||||
},
|
||||
// Czech (cs_CZ) - 3 forms
|
||||
"cs_CZ": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 2,
|
||||
1: 0, // n == 1
|
||||
2: 1, // n >= 2 && n <= 4
|
||||
3: 1, // n >= 2 && n <= 4
|
||||
4: 1, // n >= 2 && n <= 4
|
||||
5: 2, // default case
|
||||
},
|
||||
// French (fr_FR) - uses default rule
|
||||
"fr_FR": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 1,
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
5: 1, // n > 1
|
||||
},
|
||||
// Indonesian (id_ID) - always form 0
|
||||
"id_ID": {
|
||||
1: 0,
|
||||
5: 0,
|
||||
0: 0,
|
||||
1: 0,
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Japanese (ja_JP) - always form 0
|
||||
"ja_JP": {
|
||||
1: 0,
|
||||
2: 0,
|
||||
5: 0,
|
||||
0: 0,
|
||||
1: 0,
|
||||
2: 0,
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Polish (pl_PL) - 3 forms
|
||||
"pl_PL": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 2,
|
||||
1: 0, // n == 1
|
||||
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
3: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
4: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
5: 2, // default case
|
||||
10: 2, // default case (n%100 < 10, but n%10 not in 2-4)
|
||||
11: 2, // default case (n%100 >= 10 and < 20)
|
||||
12: 2, // default case (n%100 >= 10 and < 20)
|
||||
22: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)
|
||||
24: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)
|
||||
},
|
||||
// Portuguese Brazilian (pt_BR) - uses default rule
|
||||
"pt_BR": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 1,
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
5: 1, // n > 1
|
||||
},
|
||||
// Romanian (ro_RO) - 3 forms
|
||||
"ro_RO": {
|
||||
0: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
|
||||
1: 0, // n == 1
|
||||
2: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
|
||||
5: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
|
||||
19: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
|
||||
20: 2, // default case
|
||||
21: 2, // default case
|
||||
100: 2, // default case (n%100 == 0, so condition fails)
|
||||
101: 1, // n%100 == 1, so n%100 > 0 && n%100 < 20
|
||||
},
|
||||
// Russian (ru_RU) - 3 forms
|
||||
"ru_RU": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 2,
|
||||
1: 0, // n%10 == 1 && n%100 != 11
|
||||
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
3: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
4: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
5: 2, // default case
|
||||
11: 2, // n%10 == 1 but n%100 == 11, so default case
|
||||
12: 2, // default case
|
||||
21: 0, // n%10 == 1 && n%100 != 11
|
||||
22: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)
|
||||
},
|
||||
// Serbian (sr_RS) - same as Russian
|
||||
"sr_RS": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 2,
|
||||
1: 0, // n%10 == 1 && n%100 != 11
|
||||
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
5: 2, // default case
|
||||
11: 2, // n%10 == 1 but n%100 == 11, so default case
|
||||
21: 0, // n%10 == 1 && n%100 != 11
|
||||
},
|
||||
// Turkish (tr_TR) - uses default rule
|
||||
"tr_TR": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 1,
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
5: 1, // n > 1
|
||||
},
|
||||
// Ukrainian (uk_UA) - same as Russian
|
||||
"uk_UA": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 2,
|
||||
1: 0, // n%10 == 1 && n%100 != 11
|
||||
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
|
||||
5: 2, // default case
|
||||
11: 2, // n%10 == 1 but n%100 == 11, so default case
|
||||
21: 0, // n%10 == 1 && n%100 != 11
|
||||
},
|
||||
// Chinese Simplified (zh_CN) - always form 0
|
||||
"zh_CN": {
|
||||
1: 0,
|
||||
5: 0,
|
||||
0: 0,
|
||||
1: 0,
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Chinese Traditional (zh_TW) - always form 0
|
||||
"zh_TW": {
|
||||
1: 0,
|
||||
5: 0,
|
||||
0: 0,
|
||||
1: 0,
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Min Nan (nan_Latn_pehoeji) - always form 0
|
||||
"nan_Latn_pehoeji": {
|
||||
0: 0,
|
||||
1: 0,
|
||||
5: 0,
|
||||
100: 0,
|
||||
},
|
||||
// Additional languages from AvailableLanguages that use default rule
|
||||
"de_DE": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"el_EL": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"en_US": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"es_ES": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"fi_FI": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"hi_IN": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"it_IT": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
"nl_NL": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
// Test a language not in the switch (should use default rule)
|
||||
"unknown_language": {
|
||||
0: 0, // n <= 1
|
||||
1: 0, // n <= 1
|
||||
2: 1, // n > 1
|
||||
},
|
||||
}
|
||||
|
||||
for rule, values := range scenarios {
|
||||
for input, expected := range values {
|
||||
result := pluralForms[rule](input)
|
||||
result := getPluralForm(rule, input)
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result for %q rule, got %d instead of %d for %d as input`, rule, result, expected, input)
|
||||
}
|
||||
|
||||
+15
-23
@@ -10,8 +10,13 @@ type Printer struct {
|
||||
language string
|
||||
}
|
||||
|
||||
// NewPrinter creates a new Printer instance for the given language.
|
||||
func NewPrinter(language string) *Printer {
|
||||
return &Printer{language}
|
||||
}
|
||||
|
||||
func (p *Printer) Print(key string) string {
|
||||
if dict, err := GetTranslationDict(p.language); err == nil {
|
||||
if dict, err := getTranslationDict(p.language); err == nil {
|
||||
if str, ok := dict[key]; ok {
|
||||
if translation, ok := str.(string); ok {
|
||||
return translation
|
||||
@@ -22,15 +27,12 @@ func (p *Printer) Print(key string) string {
|
||||
}
|
||||
|
||||
// Printf is like fmt.Printf, but using language-specific formatting.
|
||||
func (p *Printer) Printf(key string, args ...interface{}) string {
|
||||
func (p *Printer) Printf(key string, args ...any) string {
|
||||
translation := key
|
||||
|
||||
if dict, err := GetTranslationDict(p.language); err == nil {
|
||||
str, found := dict[key]
|
||||
if found {
|
||||
var valid bool
|
||||
translation, valid = str.(string)
|
||||
if !valid {
|
||||
if dict, err := getTranslationDict(p.language); err == nil {
|
||||
if str, ok := dict[key]; ok {
|
||||
if translation, ok = str.(string); !ok {
|
||||
translation = key
|
||||
}
|
||||
}
|
||||
@@ -41,7 +43,7 @@ func (p *Printer) Printf(key string, args ...interface{}) string {
|
||||
|
||||
// Plural returns the translation of the given key by using the language plural form.
|
||||
func (p *Printer) Plural(key string, n int, args ...interface{}) string {
|
||||
dict, err := GetTranslationDict(p.language)
|
||||
dict, err := getTranslationDict(p.language)
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
@@ -50,22 +52,17 @@ func (p *Printer) Plural(key string, n int, args ...interface{}) string {
|
||||
var plurals []string
|
||||
|
||||
switch v := choices.(type) {
|
||||
case []interface{}:
|
||||
case []string:
|
||||
plurals = v
|
||||
case []any:
|
||||
for _, v := range v {
|
||||
plurals = append(plurals, fmt.Sprint(v))
|
||||
}
|
||||
case []string:
|
||||
plurals = v
|
||||
default:
|
||||
return key
|
||||
}
|
||||
|
||||
pluralForm, found := pluralForms[p.language]
|
||||
if !found {
|
||||
pluralForm = pluralForms["default"]
|
||||
}
|
||||
|
||||
index := pluralForm(n)
|
||||
index := getPluralForm(p.language, n)
|
||||
if len(plurals) > index {
|
||||
return fmt.Sprintf(plurals[index], args...)
|
||||
}
|
||||
@@ -73,8 +70,3 @@ func (p *Printer) Plural(key string, n int, args ...interface{}) string {
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// NewPrinter creates a new Printer.
|
||||
func NewPrinter(language string) *Printer {
|
||||
return &Printer{language}
|
||||
}
|
||||
|
||||
+260
-10
@@ -5,7 +5,7 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTranslateWithMissingLanguage(t *testing.T) {
|
||||
func TestPrintfWithMissingLanguage(t *testing.T) {
|
||||
defaultCatalog = catalog{}
|
||||
translation := NewPrinter("invalid").Printf("missing.key")
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestTranslateWithMissingLanguage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateWithMissingKey(t *testing.T) {
|
||||
func TestPrintfWithMissingKey(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"k": "v",
|
||||
@@ -27,7 +27,7 @@ func TestTranslateWithMissingKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateWithExistingKey(t *testing.T) {
|
||||
func TestPrintfWithExistingKey(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"auth.username": "Login",
|
||||
@@ -40,7 +40,7 @@ func TestTranslateWithExistingKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateWithExistingKeyAndPlaceholder(t *testing.T) {
|
||||
func TestPrintfWithExistingKeyAndPlaceholder(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"key": "Test: %s",
|
||||
@@ -56,7 +56,7 @@ func TestTranslateWithExistingKeyAndPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateWithMissingKeyAndPlaceholder(t *testing.T) {
|
||||
func TestPrintfWithMissingKeyAndPlaceholder(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"auth.username": "Login",
|
||||
@@ -72,7 +72,7 @@ func TestTranslateWithMissingKeyAndPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateWithInvalidValue(t *testing.T) {
|
||||
func TestPrintfWithInvalidValue(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"auth.username": "Login",
|
||||
@@ -88,7 +88,134 @@ func TestTranslateWithInvalidValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslatePluralWithDefaultRule(t *testing.T) {
|
||||
func TestPrintWithMissingLanguage(t *testing.T) {
|
||||
defaultCatalog = catalog{}
|
||||
translation := NewPrinter("invalid").Print("missing.key")
|
||||
|
||||
if translation != "missing.key" {
|
||||
t.Errorf(`Wrong translation, got %q`, translation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithMissingKey(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"existing.key": "value",
|
||||
},
|
||||
}
|
||||
|
||||
translation := NewPrinter("en_US").Print("missing.key")
|
||||
if translation != "missing.key" {
|
||||
t.Errorf(`Wrong translation, got %q`, translation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithExistingKey(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"auth.username": "Login",
|
||||
},
|
||||
}
|
||||
|
||||
translation := NewPrinter("en_US").Print("auth.username")
|
||||
if translation != "Login" {
|
||||
t.Errorf(`Wrong translation, got %q`, translation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithDifferentLanguages(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"greeting": "Hello",
|
||||
},
|
||||
"fr_FR": translationDict{
|
||||
"greeting": "Bonjour",
|
||||
},
|
||||
"es_ES": translationDict{
|
||||
"greeting": "Hola",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
language string
|
||||
expected string
|
||||
}{
|
||||
{"en_US", "Hello"},
|
||||
{"fr_FR", "Bonjour"},
|
||||
{"es_ES", "Hola"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
translation := NewPrinter(test.language).Print("greeting")
|
||||
if translation != test.expected {
|
||||
t.Errorf(`Wrong translation for %s, got %q instead of %q`, test.language, translation, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithInvalidTranslationType(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"valid.key": "valid string",
|
||||
"invalid.key": 12345, // not a string
|
||||
},
|
||||
}
|
||||
|
||||
printer := NewPrinter("en_US")
|
||||
|
||||
// Valid string should work
|
||||
translation := printer.Print("valid.key")
|
||||
if translation != "valid string" {
|
||||
t.Errorf(`Wrong translation for valid key, got %q`, translation)
|
||||
}
|
||||
|
||||
// Invalid type should return the key itself
|
||||
translation = printer.Print("invalid.key")
|
||||
if translation != "invalid.key" {
|
||||
t.Errorf(`Wrong translation for invalid key, got %q`, translation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithNilTranslation(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"nil.key": nil,
|
||||
},
|
||||
}
|
||||
|
||||
translation := NewPrinter("en_US").Print("nil.key")
|
||||
if translation != "nil.key" {
|
||||
t.Errorf(`Wrong translation for nil value, got %q`, translation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithEmptyKey(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"": "empty key translation",
|
||||
},
|
||||
}
|
||||
|
||||
translation := NewPrinter("en_US").Print("")
|
||||
if translation != "empty key translation" {
|
||||
t.Errorf(`Wrong translation for empty key, got %q`, translation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithEmptyTranslation(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"empty.value": "",
|
||||
},
|
||||
}
|
||||
|
||||
translation := NewPrinter("en_US").Print("empty.value")
|
||||
if translation != "" {
|
||||
t.Errorf(`Wrong translation for empty value, got %q`, translation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralWithDefaultRule(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
|
||||
@@ -112,7 +239,7 @@ func TestTranslatePluralWithDefaultRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslatePluralWithRussianRule(t *testing.T) {
|
||||
func TestPluralWithRussianRule(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"time_elapsed.years": []string{"%d year", "%d years"},
|
||||
@@ -143,7 +270,7 @@ func TestTranslatePluralWithRussianRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslatePluralWithMissingTranslation(t *testing.T) {
|
||||
func TestPluralWithMissingTranslation(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
|
||||
@@ -157,7 +284,7 @@ func TestTranslatePluralWithMissingTranslation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslatePluralWithInvalidValues(t *testing.T) {
|
||||
func TestPluralWithInvalidValues(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
|
||||
@@ -172,3 +299,126 @@ func TestTranslatePluralWithInvalidValues(t *testing.T) {
|
||||
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralWithMissingLanguage(t *testing.T) {
|
||||
defaultCatalog = catalog{}
|
||||
translation := NewPrinter("invalid_language").Plural("test.key", 2)
|
||||
expected := "test.key"
|
||||
if translation != expected {
|
||||
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralWithAnySliceType(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"test.key": []any{"%d item", "%d items"},
|
||||
},
|
||||
}
|
||||
|
||||
printer := NewPrinter("en_US")
|
||||
|
||||
translation := printer.Plural("test.key", 1, 1)
|
||||
expected := "1 item"
|
||||
if translation != expected {
|
||||
t.Errorf(`Wrong translation for singular, got %q instead of %q`, translation, expected)
|
||||
}
|
||||
|
||||
translation = printer.Plural("test.key", 2, 2)
|
||||
expected = "2 items"
|
||||
if translation != expected {
|
||||
t.Errorf(`Wrong translation for plural, got %q instead of %q`, translation, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralWithMixedAnySliceTypes(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"en_US": translationDict{
|
||||
"mixed.key": []any{"single: %s", "multiple: %s", "many: %s"},
|
||||
},
|
||||
}
|
||||
|
||||
printer := NewPrinter("en_US")
|
||||
|
||||
// Test first element (should convert first any element to string)
|
||||
translation := printer.Plural("mixed.key", 0, "test") // n=0 uses index 0
|
||||
expected := "single: test"
|
||||
if translation != expected {
|
||||
t.Errorf(`Wrong translation for index 0, got %q instead of %q`, translation, expected)
|
||||
}
|
||||
|
||||
// Test second element (should use plural form)
|
||||
translation = printer.Plural("mixed.key", 2, "items") // plural form for default language
|
||||
expected = "multiple: items"
|
||||
if translation != expected {
|
||||
t.Errorf(`Wrong translation for index 1, got %q instead of %q`, translation, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralWithIndexOutOfBounds(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"test_lang": translationDict{
|
||||
"limited.key": []string{"only one form"},
|
||||
},
|
||||
}
|
||||
|
||||
// Force a scenario where getPluralForm might return an index >= len(plurals)
|
||||
// We'll create a scenario with Czech language rules
|
||||
defaultCatalog["cs_CZ"] = translationDict{
|
||||
"limited.key": []string{"one form only"}, // Only one form, but Czech has 3 plural forms
|
||||
}
|
||||
|
||||
printer := NewPrinter("cs_CZ")
|
||||
// n=5 should return index 2 for Czech, but we only have 1 form (index 0)
|
||||
translation := printer.Plural("limited.key", 5)
|
||||
expected := "limited.key"
|
||||
if translation != expected {
|
||||
t.Errorf(`Wrong translation for out of bounds index, got %q instead of %q`, translation, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralWithVariousLanguageRules(t *testing.T) {
|
||||
defaultCatalog = catalog{
|
||||
"ar_AR": translationDict{
|
||||
"items": []string{"no items", "one item", "two items", "few items", "many items", "other items"},
|
||||
},
|
||||
"pl_PL": translationDict{
|
||||
"files": []string{"one file", "few files", "many files"},
|
||||
},
|
||||
"ja_JP": translationDict{
|
||||
"photos": []string{"photos"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
language string
|
||||
key string
|
||||
n int
|
||||
expected string
|
||||
}{
|
||||
// Arabic tests
|
||||
{"ar_AR", "items", 0, "no items"},
|
||||
{"ar_AR", "items", 1, "one item"},
|
||||
{"ar_AR", "items", 2, "two items"},
|
||||
{"ar_AR", "items", 5, "few items"}, // n%100 >= 3 && n%100 <= 10
|
||||
{"ar_AR", "items", 15, "many items"}, // n%100 >= 11
|
||||
|
||||
// Polish tests
|
||||
{"pl_PL", "files", 1, "one file"},
|
||||
{"pl_PL", "files", 3, "few files"}, // n%10 >= 2 && n%10 <= 4
|
||||
{"pl_PL", "files", 5, "many files"}, // default case
|
||||
|
||||
// Japanese tests (always uses same form)
|
||||
{"ja_JP", "photos", 1, "photos"},
|
||||
{"ja_JP", "photos", 10, "photos"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
printer := NewPrinter(test.language)
|
||||
translation := printer.Plural(test.key, test.n)
|
||||
if translation != test.expected {
|
||||
t.Errorf(`Wrong translation for %s with n=%d, got %q instead of %q`,
|
||||
test.language, test.n, translation, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user