Compare commits
483 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 459b1bf1ee | |||
| fdc12bf18c | |||
| 1b1ceaf8b4 | |||
| cad40cc158 | |||
| d0e43f8682 | |||
| c105b14f58 | |||
| 49575c8902 | |||
| b25f9651fe | |||
| 953ea885e0 | |||
| 9536ce7dbc | |||
| f6532e16f2 | |||
| 84ae1d5dc0 | |||
| 5403ca09f6 | |||
| 905d652511 | |||
| a16ac51326 | |||
| 5c26e06780 | |||
| 9e722839b5 | |||
| 9e4248c7c1 | |||
| a654a5f710 | |||
| 6bf3b3c464 | |||
| a010544200 | |||
| c1af510ead | |||
| 88d9682f5f | |||
| ce6cadc176 | |||
| 40fa77851c | |||
| dd8011a4aa | |||
| 3955df43f5 | |||
| 6f2ec8b3e9 | |||
| 2fa813fe01 | |||
| 7c1602c2c0 | |||
| 5e3bba9ae1 | |||
| ab26a4e20f | |||
| 2c4f4e2ae6 | |||
| 261b72f149 | |||
| 6c60d61f36 | |||
| fa85a0eff4 | |||
| eb6f7f30bb | |||
| 93fc206f42 | |||
| 8bca777a6d | |||
| 1e6d227e40 | |||
| 37d539eb42 | |||
| f5188b1edc | |||
| 5d9d0b2652 | |||
| 50c5996280 | |||
| 884521a7dd | |||
| 3a01f8a691 | |||
| 68984da332 | |||
| 485baf9654 | |||
| 06cbf1b3b3 | |||
| b20a8c97b0 | |||
| e0ca92fca4 | |||
| 598d4d4f51 | |||
| a2229198ae | |||
| 34499b887b | |||
| a4f672b589 | |||
| 98da7b3f22 | |||
| b4c82a4c53 | |||
| 4d7c601f6d | |||
| f7e672452b | |||
| 6532435db9 | |||
| 14cd5e9c0d | |||
| 566670cc06 | |||
| a4d51b5586 | |||
| e6d01896e0 | |||
| b4a814a5b2 | |||
| 940d94e793 | |||
| 80f48c88c7 | |||
| 045f2f1747 | |||
| 2565ff930c | |||
| b3fce752d2 | |||
| 798bc4cd2d | |||
| b1cbaae71c | |||
| 924293ee5c | |||
| 826977bc8c | |||
| ed0e4a667d | |||
| 5affd78f4f | |||
| d3ad460c9d | |||
| 0f3c04a98a | |||
| e8e3a600b4 | |||
| a6ce5c92dc | |||
| 76ef8f3579 | |||
| a43d150a27 | |||
| 3bb965913d | |||
| b505a63f3b | |||
| e9d9256ae2 | |||
| 391792a424 | |||
| d2cfca589b | |||
| f2e34cf07f | |||
| 5c3be3e74f | |||
| 546fbcff8f | |||
| 2e28bf78bd | |||
| 52c1386450 | |||
| 4910f1f0f4 | |||
| bbe3c2ea71 | |||
| b116da85a9 | |||
| 07246e2b59 | |||
| 62410659d5 | |||
| 3e1a7e411c | |||
| bfbc1c88c3 | |||
| 50197c2be3 | |||
| 7a25cf5037 | |||
| 1ec90e34f5 | |||
| 5e07278e87 | |||
| cce0e7bd29 | |||
| 1f7843e313 | |||
| 181e1341e1 | |||
| f3052eb8ed | |||
| 078eb39db9 | |||
| 6aeb565edc | |||
| 0c3e251884 | |||
| bfd8cb3d22 | |||
| 9eea9873b5 | |||
| 240ec8bd0a | |||
| f3ac4dacf6 | |||
| 13986e9cc0 | |||
| 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)
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: stable
|
||||
check-latest: true
|
||||
- name: Compile binaries
|
||||
env:
|
||||
|
||||
@@ -33,11 +33,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: stable
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
@@ -6,14 +6,18 @@ 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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
@@ -34,7 +38,7 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
@@ -60,7 +64,7 @@ jobs:
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Javascript Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install linters
|
||||
run: |
|
||||
sudo npm install -g jshint@2.13.6 eslint@8.57.0
|
||||
@@ -25,18 +25,30 @@ jobs:
|
||||
name: Golang Linters
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
go-version: stable
|
||||
- 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@v5
|
||||
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,14 +6,18 @@ 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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
@@ -25,7 +29,7 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
@@ -42,7 +46,7 @@ jobs:
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
|
||||
@@ -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,14 +15,13 @@ jobs:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
go-version: ["1.23.x"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
go-version: stable
|
||||
- name: Run unit tests with coverage and race conditions checking
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: make test
|
||||
@@ -45,16 +44,15 @@ jobs:
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: stable
|
||||
- name: Install Postgres client
|
||||
run: sudo apt update && sudo apt install -y postgresql-client
|
||||
- name: Run integration tests
|
||||
run: make integration-test
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PGHOST: 127.0.0.1
|
||||
PGPASSWORD: postgres
|
||||
|
||||
+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,9 +1,7 @@
|
||||
APP := miniflux
|
||||
DOCKER_IMAGE := miniflux/miniflux
|
||||
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
|
||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
|
||||
BUILD_DATE := `date +%FT%T%z`
|
||||
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
|
||||
VERSION := $(shell git describe --tags --exact-match 2>/dev/null)
|
||||
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)'"
|
||||
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
|
||||
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
|
||||
DOCKER_PLATFORM := amd64
|
||||
@@ -22,16 +20,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 \
|
||||
@@ -44,71 +38,48 @@ export PGPASSWORD := postgres
|
||||
debian-packages
|
||||
|
||||
miniflux:
|
||||
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
|
||||
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP)
|
||||
|
||||
miniflux-no-pie:
|
||||
@ go build -ldflags=$(LD_FLAGS) -o $(APP) main.go
|
||||
@ go build -ldflags=$(LD_FLAGS) -o $(APP)
|
||||
|
||||
linux-amd64:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-arm64:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-armv7:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-armv6:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-armv5:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
darwin-amd64:
|
||||
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
darwin-arm64:
|
||||
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
freebsd-amd64:
|
||||
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
openbsd-amd64:
|
||||
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
|
||||
@ 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 +87,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 ./...
|
||||
|
||||
@@ -127,15 +106,14 @@ lint:
|
||||
integration-test:
|
||||
psql -U postgres -c 'drop database if exists miniflux_test;'
|
||||
psql -U postgres -c 'create database miniflux_test;'
|
||||
go build -o miniflux-test main.go
|
||||
|
||||
DATABASE_URL=$(DB_URL) \
|
||||
ADMIN_USERNAME=admin \
|
||||
ADMIN_PASSWORD=test123 \
|
||||
CREATE_ADMIN=1 \
|
||||
RUN_MIGRATIONS=1 \
|
||||
DEBUG=1 \
|
||||
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||
LOG_LEVEL=debug \
|
||||
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||
|
||||
while ! nc -z localhost 8080; do sleep 1; done
|
||||
|
||||
@@ -147,7 +125,6 @@ integration-test:
|
||||
clean-integration-test:
|
||||
@ kill -9 `cat /tmp/miniflux.pid`
|
||||
@ rm -f /tmp/miniflux.pid /tmp/miniflux.log
|
||||
@ rm miniflux-test
|
||||
@ psql -U postgres -c 'drop database if exists miniflux_test;'
|
||||
|
||||
docker-image:
|
||||
|
||||
@@ -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()
|
||||
|
||||
+91
-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)
|
||||
@@ -236,10 +276,26 @@ func (c *Client) Categories() (Categories, error) {
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// CategoriesWithCounters fetches the categories with their respective feed and unread counts.
|
||||
func (c *Client) CategoriesWithCounters() (Categories, error) {
|
||||
body, err := c.request.Get("/v1/categories?counts=true")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var categories Categories
|
||||
if err := json.NewDecoder(body).Decode(&categories); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// 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 +310,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 +343,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
|
||||
}
|
||||
|
||||
+5
-5
@@ -45,7 +45,7 @@ func (r *request) Get(path string) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodGet, path, nil)
|
||||
}
|
||||
|
||||
func (r *request) Post(path string, data interface{}) (io.ReadCloser, error) {
|
||||
func (r *request) Post(path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPost, path, data)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (r *request) PostFile(path string, f io.ReadCloser) (io.ReadCloser, error)
|
||||
return r.execute(http.MethodPost, path, f)
|
||||
}
|
||||
|
||||
func (r *request) Put(path string, data interface{}) (io.ReadCloser, error) {
|
||||
func (r *request) Put(path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPut, path, data)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func (r *request) Delete(path string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *request) execute(method, path string, data interface{}) (io.ReadCloser, error) {
|
||||
func (r *request) execute(method, path string, data any) (io.ReadCloser, error) {
|
||||
if r.endpoint == "" {
|
||||
return nil, ErrEmptyEndpoint
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func (r *request) buildHeaders() http.Header {
|
||||
return headers
|
||||
}
|
||||
|
||||
func (r *request) toJSON(v interface{}) []byte {
|
||||
func (r *request) toJSON(v any) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
log.Println("Unable to convert interface to JSON:", err)
|
||||
|
||||
@@ -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.15.0
|
||||
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.23.0
|
||||
github.com/tdewolff/minify/v2 v2.23.11
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/image v0.30.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/term v0.34.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/procfs v0.15.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.19 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 // 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.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
)
|
||||
|
||||
go 1.23
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
@@ -1,81 +1,81 @@
|
||||
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.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0/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_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/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/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
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.11 h1:cZqTVCtuVvPC8/GbCvYgIcdAQGmoxEObZzKeKIUixTE=
|
||||
github.com/tdewolff/minify/v2 v2.23.11/go.mod h1:vmkbfGQ5hp/eYB+TswNWKma67S0a+32HBL+mFWxjZ2Q=
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 h1:2qicgFovKg1XtX7Wf6GwexUdpb7q/jMIE2IgkYsVAvE=
|
||||
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781/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=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||
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 +90,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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
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 +112,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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.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 +123,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.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
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 +133,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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
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 +143,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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
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) {
|
||||
@@ -1021,6 +1262,14 @@ func TestGetCategoriesEndpoint(t *testing.T) {
|
||||
t.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, "All")
|
||||
}
|
||||
|
||||
if categories[0].FeedCount != nil {
|
||||
t.Errorf(`Expected FeedCount to be nil, got %d`, *categories[0].FeedCount)
|
||||
}
|
||||
|
||||
if categories[0].TotalUnread != nil {
|
||||
t.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[0].TotalUnread)
|
||||
}
|
||||
|
||||
if categories[1].ID != category.ID {
|
||||
t.Fatalf(`Invalid categoryID, got %d`, categories[0].ID)
|
||||
}
|
||||
@@ -1032,6 +1281,40 @@ func TestGetCategoriesEndpoint(t *testing.T) {
|
||||
if categories[1].Title != "My category" {
|
||||
t.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, "My category")
|
||||
}
|
||||
|
||||
if categories[1].FeedCount != nil {
|
||||
t.Errorf(`Expected FeedCount to be nil, got %d`, *categories[1].FeedCount)
|
||||
}
|
||||
|
||||
if categories[1].TotalUnread != nil {
|
||||
t.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[1].TotalUnread)
|
||||
}
|
||||
|
||||
categories, err = regularUserClient.CategoriesWithCounters()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(categories) != 2 {
|
||||
t.Fatalf(`Invalid number of categories, got %d instead of %d`, len(categories), 1)
|
||||
}
|
||||
|
||||
if categories[1].FeedCount == nil {
|
||||
t.Fatalf(`Expected FeedCount to be not nil`)
|
||||
}
|
||||
|
||||
if categories[1].TotalUnread == nil {
|
||||
t.Fatalf(`Expected TotalUnread to be not nil`)
|
||||
}
|
||||
|
||||
expectedCounterValue := 0
|
||||
if *categories[1].FeedCount != expectedCounterValue {
|
||||
t.Errorf(`Expected FeedCount to be %d, got %d`, expectedCounterValue, *categories[1].FeedCount)
|
||||
}
|
||||
|
||||
if *categories[1].TotalUnread != expectedCounterValue {
|
||||
t.Errorf(`Expected TotalUnread to be %d, got %d`, expectedCounterValue, *categories[1].TotalUnread)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkCategoryAsReadEndpoint(t *testing.T) {
|
||||
@@ -2348,6 +2631,64 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryRemovedStatusEndpoint(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)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
// First we set the entry as "removed"
|
||||
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err := regularUserClient.Entry(result.Entries[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if entry.Status != miniflux.EntryStatusRemoved {
|
||||
t.Fatalf(`Invalid status, got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
|
||||
}
|
||||
|
||||
// Then we try to set it to "unread"
|
||||
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusUnread); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry, err = regularUserClient.Entry(result.Entries[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// It should stay set to "removed"
|
||||
if entry.Status != miniflux.EntryStatusRemoved {
|
||||
t.Fatalf(`Modified immutable status: got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+11
-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
|
||||
}
|
||||
@@ -143,6 +143,7 @@ func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) {
|
||||
batchBuilder.WithUserID(userID)
|
||||
batchBuilder.WithCategoryID(categoryID)
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
if err != nil {
|
||||
|
||||
@@ -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]any{"content": mediaproxy.RewriteDocumentWithRelativeProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, map[string]string{"content": entry.Content})
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithUserID(userID)
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
if err != nil {
|
||||
@@ -140,13 +141,7 @@ func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
userID := request.UserID(r)
|
||||
|
||||
feed, err := h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.Debug("[API] Skipped API token authentication because no API Key has been provided",
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -59,6 +60,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
@@ -69,6 +71,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("username", user.Username),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
|
||||
m.store.SetLastLogin(user.ID)
|
||||
@@ -100,6 +103,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
@@ -110,6 +114,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
@@ -121,6 +126,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("username", username),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
@@ -138,6 +144,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("username", username),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
@@ -148,6 +155,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("username", username),
|
||||
slog.String("request_uri", r.RequestURI),
|
||||
)
|
||||
|
||||
m.store.SetLastLogin(user.ID)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,4 +46,18 @@ func runCleanupTasks(store *storage.Storage) {
|
||||
metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusUnread).Observe(time.Since(startTime).Seconds())
|
||||
}
|
||||
}
|
||||
|
||||
if enclosuresAffected, err := store.DeleteRemovedEntriesEnclosures(); err != nil {
|
||||
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Deleting enclosures from removed entries completed",
|
||||
slog.Int64("removed_entries_enclosures_deleted", enclosuresAffected))
|
||||
}
|
||||
|
||||
if contentAffected, err := store.ClearRemovedEntriesContent(config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
slog.Error("Unable to clear content from removed entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Clearing content from removed entries completed",
|
||||
slog.Int64("removed_entries_content_cleared", contentAffected))
|
||||
}
|
||||
}
|
||||
|
||||
+56
-35
@@ -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)
|
||||
@@ -152,16 +156,16 @@ func Parse() {
|
||||
slog.Info("The default value for DATABASE_URL is used")
|
||||
}
|
||||
|
||||
if err := static.CalculateBinaryFileChecksums(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to calculate binary file checksums: %v", err))
|
||||
if err := static.GenerateBinaryBundles(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate binary files bundle: %v", err))
|
||||
}
|
||||
|
||||
if err := static.GenerateStylesheetsBundles(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundles: %v", err))
|
||||
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err))
|
||||
}
|
||||
|
||||
if err := static.GenerateJavascriptBundles(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate javascript bundles: %v", err))
|
||||
if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
|
||||
}
|
||||
|
||||
db, err := database.NewConnectionPool(
|
||||
@@ -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))
|
||||
|
||||
@@ -25,6 +25,7 @@ func refreshFeeds(store *storage.Storage) {
|
||||
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
if err != nil {
|
||||
@@ -32,13 +33,9 @@ func refreshFeeds(store *storage.Storage) {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
|
||||
|
||||
nbJobs := len(jobs)
|
||||
|
||||
slog.Info("Created a batch of feeds",
|
||||
slog.Int("nb_jobs", nbJobs),
|
||||
slog.Int("batch_size", config.Opts.BatchSize()),
|
||||
)
|
||||
|
||||
var jobQueue = make(chan model.Job, nbJobs)
|
||||
|
||||
slog.Info("Starting a pool of workers",
|
||||
|
||||
@@ -21,6 +21,7 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
|
||||
config.Opts.PollingFrequency(),
|
||||
config.Opts.BatchSize(),
|
||||
config.Opts.PollingParsingErrorLimit(),
|
||||
config.Opts.PollingLimitPerHost(),
|
||||
)
|
||||
|
||||
go cleanupScheduler(
|
||||
@@ -29,7 +30,7 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
|
||||
)
|
||||
}
|
||||
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit int) {
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit, limitPerHost int) {
|
||||
for range time.Tick(time.Duration(frequency) * time.Minute) {
|
||||
// Generate a batch of feeds for any user that has feeds to refresh.
|
||||
batchBuilder := store.NewBatchBuilder()
|
||||
@@ -37,13 +38,12 @@ func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSi
|
||||
batchBuilder.WithErrorLimit(errorLimit)
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(limitPerHost)
|
||||
|
||||
if jobs, err := batchBuilder.FetchJobs(); err != nil {
|
||||
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
|
||||
} else if len(jobs) > 0 {
|
||||
slog.Info("Created a batch of feeds",
|
||||
slog.Int("nb_jobs", len(jobs)),
|
||||
)
|
||||
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
|
||||
pool.Push(jobs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
// Opts holds parsed configuration options.
|
||||
var Opts *Options
|
||||
+186
-234
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,7 +720,7 @@ func TestWorkerPoolSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautPollingFrequencyValue(t *testing.T) {
|
||||
func TestDefaultPollingFrequencyValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
@@ -783,7 +755,7 @@ func TestPollingFrequency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautForceRefreshInterval(t *testing.T) {
|
||||
func TestDefaultForceRefreshInterval(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
@@ -853,7 +825,7 @@ func TestBatchSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautPollingSchedulerValue(t *testing.T) {
|
||||
func TestDefaultPollingSchedulerValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
@@ -888,7 +860,7 @@ func TestPollingScheduler(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautSchedulerEntryFrequencyMaxIntervalValue(t *testing.T) {
|
||||
func TestDefaultSchedulerEntryFrequencyMaxIntervalValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
@@ -923,7 +895,7 @@ func TestSchedulerEntryFrequencyMaxInterval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautSchedulerEntryFrequencyMinIntervalValue(t *testing.T) {
|
||||
func TestDefaultSchedulerEntryFrequencyMinIntervalValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
@@ -958,7 +930,7 @@ func TestSchedulerEntryFrequencyMinInterval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefautSchedulerEntryFrequencyFactorValue(t *testing.T) {
|
||||
func TestDefaultSchedulerEntryFrequencyFactorValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
@@ -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")
|
||||
@@ -1629,7 +1601,8 @@ func TestMediaProxyCustomURL(t *testing.T) {
|
||||
}
|
||||
expected := "http://example.org/proxy"
|
||||
result := opts.MediaCustomProxyURL()
|
||||
if result != expected {
|
||||
|
||||
if result == nil || result.String() != expected {
|
||||
t.Fatalf(`Unexpected MEDIA_PROXY_CUSTOM_URL value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
@@ -1652,147 +1625,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 +1763,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 +1786,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 +1958,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 +1995,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 +2037,104 @@ 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`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPollingLimitPerHost(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := 0
|
||||
result := opts.PollingLimitPerHost()
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected default PollingLimitPerHost value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomPollingLimitPerHost(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("POLLING_LIMIT_PER_HOST", "10")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := 10
|
||||
result := opts.PollingLimitPerHost()
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected custom PollingLimitPerHost value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
+187
-144
@@ -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,17 @@ 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 {
|
||||
// Opts holds parsed configuration options.
|
||||
var Opts *options
|
||||
|
||||
// options contains configuration options.
|
||||
type options struct {
|
||||
HTTPS bool
|
||||
logFile string
|
||||
logDateTime bool
|
||||
@@ -110,7 +113,6 @@ type Options struct {
|
||||
hsts bool
|
||||
httpService bool
|
||||
schedulerService bool
|
||||
serverTimingHeader bool
|
||||
baseURL string
|
||||
rootURL string
|
||||
basePath string
|
||||
@@ -119,7 +121,7 @@ type Options struct {
|
||||
databaseMinConns int
|
||||
databaseConnectionLifetime int
|
||||
runMigrations bool
|
||||
listenAddr string
|
||||
listenAddr []string
|
||||
certFile string
|
||||
certDomain string
|
||||
certKeyFile string
|
||||
@@ -128,15 +130,17 @@ type Options struct {
|
||||
cleanupArchiveUnreadDays int
|
||||
cleanupArchiveBatchSize int
|
||||
cleanupRemoveSessionsDays int
|
||||
pollingFrequency int
|
||||
forceRefreshInterval int
|
||||
batchSize int
|
||||
pollingScheduler string
|
||||
schedulerEntryFrequencyMinInterval int
|
||||
schedulerEntryFrequencyMaxInterval int
|
||||
schedulerEntryFrequencyFactor int
|
||||
schedulerRoundRobinMinInterval int
|
||||
schedulerRoundRobinMaxInterval int
|
||||
pollingFrequency int
|
||||
pollingLimitPerHost int
|
||||
pollingParsingErrorLimit int
|
||||
pollingScheduler string
|
||||
workerPoolSize int
|
||||
createAdmin bool
|
||||
adminUsername string
|
||||
@@ -144,7 +148,7 @@ type Options struct {
|
||||
mediaProxyHTTPClientTimeout int
|
||||
mediaProxyMode string
|
||||
mediaProxyResourceTypes []string
|
||||
mediaProxyCustomURL string
|
||||
mediaProxyCustomURL *url.URL
|
||||
fetchBilibiliWatchTime bool
|
||||
fetchNebulaWatchTime bool
|
||||
fetchOdyseeWatchTime bool
|
||||
@@ -152,6 +156,7 @@ type Options struct {
|
||||
filterEntryMaxAgeDays int
|
||||
youTubeApiKey string
|
||||
youTubeEmbedUrlOverride string
|
||||
youTubeEmbedDomain string
|
||||
oauth2UserCreationAllowed bool
|
||||
oauth2ClientID string
|
||||
oauth2ClientSecret string
|
||||
@@ -160,10 +165,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 +187,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 +197,6 @@ func NewOptions() *Options {
|
||||
hsts: defaultHSTS,
|
||||
httpService: defaultHTTPService,
|
||||
schedulerService: defaultSchedulerService,
|
||||
serverTimingHeader: defaultTiming,
|
||||
baseURL: defaultBaseURL,
|
||||
rootURL: defaultRootURL,
|
||||
basePath: defaultBasePath,
|
||||
@@ -201,7 +205,7 @@ func NewOptions() *Options {
|
||||
databaseMinConns: defaultDatabaseMinConns,
|
||||
databaseConnectionLifetime: defaultDatabaseConnectionLifetime,
|
||||
runMigrations: defaultRunMigrations,
|
||||
listenAddr: defaultListenAddr,
|
||||
listenAddr: []string{defaultListenAddr},
|
||||
certFile: defaultCertFile,
|
||||
certDomain: defaultCertDomain,
|
||||
certKeyFile: defaultKeyFile,
|
||||
@@ -218,13 +222,14 @@ func NewOptions() *Options {
|
||||
schedulerEntryFrequencyMaxInterval: defaultSchedulerEntryFrequencyMaxInterval,
|
||||
schedulerEntryFrequencyFactor: defaultSchedulerEntryFrequencyFactor,
|
||||
schedulerRoundRobinMinInterval: defaultSchedulerRoundRobinMinInterval,
|
||||
schedulerRoundRobinMaxInterval: defaultSchedulerRoundRobinMaxInterval,
|
||||
pollingParsingErrorLimit: defaultPollingParsingErrorLimit,
|
||||
workerPoolSize: defaultWorkerPoolSize,
|
||||
createAdmin: defaultCreateAdmin,
|
||||
mediaProxyHTTPClientTimeout: defaultMediaProxyHTTPClientTimeout,
|
||||
mediaProxyMode: defaultMediaProxyMode,
|
||||
mediaProxyResourceTypes: []string{defaultMediaResourceTypes},
|
||||
mediaProxyCustomURL: defaultMediaProxyURL,
|
||||
mediaProxyCustomURL: nil,
|
||||
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
|
||||
fetchBilibiliWatchTime: defaultFetchBilibiliWatchTime,
|
||||
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
|
||||
@@ -240,10 +245,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,409 +267,451 @@ 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 {
|
||||
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
|
||||
}
|
||||
|
||||
// PollingFrequency returns the interval to refresh feeds in the background.
|
||||
func (o *options) PollingFrequency() int {
|
||||
return o.pollingFrequency
|
||||
}
|
||||
|
||||
// PollingLimitPerHost returns the limit of concurrent requests per host.
|
||||
// Set to zero to disable.
|
||||
func (o *options) PollingLimitPerHost() int {
|
||||
return o.pollingLimitPerHost
|
||||
}
|
||||
|
||||
// PollingParsingErrorLimit returns the limit of errors when to stop polling.
|
||||
func (o *options) PollingParsingErrorLimit() int {
|
||||
return o.pollingParsingErrorLimit
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// PollingParsingErrorLimit returns the limit of errors when to stop polling.
|
||||
func (o *Options) PollingParsingErrorLimit() int {
|
||||
return o.pollingParsingErrorLimit
|
||||
func (o *options) SchedulerRoundRobinMaxInterval() int {
|
||||
return o.schedulerRoundRobinMaxInterval
|
||||
}
|
||||
|
||||
// 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() *url.URL {
|
||||
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 {
|
||||
var keyValues = map[string]interface{}{
|
||||
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]any{
|
||||
"ADMIN_PASSWORD": redactSecretValue(o.adminPassword, redactSecret),
|
||||
"ADMIN_USERNAME": o.adminUsername,
|
||||
"AUTH_PROXY_HEADER": o.authProxyHeader,
|
||||
@@ -694,14 +741,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,15 +769,15 @@ 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_FREQUENCY": o.pollingFrequency,
|
||||
"POLLING_LIMIT_PER_HOST": o.pollingLimitPerHost,
|
||||
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
|
||||
"POLLING_SCHEDULER": o.pollingScheduler,
|
||||
"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 +785,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 +794,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) {
|
||||
|
||||
+50
-68
@@ -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":
|
||||
@@ -144,12 +137,16 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
p.opts.cleanupRemoveSessionsDays = parseInt(value, defaultCleanupRemoveSessionsDays)
|
||||
case "WORKER_POOL_SIZE":
|
||||
p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
|
||||
case "POLLING_FREQUENCY":
|
||||
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
|
||||
case "FORCE_REFRESH_INTERVAL":
|
||||
p.opts.forceRefreshInterval = parseInt(value, defaultForceRefreshInterval)
|
||||
case "BATCH_SIZE":
|
||||
p.opts.batchSize = parseInt(value, defaultBatchSize)
|
||||
case "POLLING_FREQUENCY":
|
||||
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
|
||||
case "POLLING_LIMIT_PER_HOST":
|
||||
p.opts.pollingLimitPerHost = parseInt(value, 0)
|
||||
case "POLLING_PARSING_ERROR_LIMIT":
|
||||
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
|
||||
case "POLLING_SCHEDULER":
|
||||
p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
|
||||
@@ -160,43 +157,23 @@ 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 "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 "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL":
|
||||
p.opts.schedulerRoundRobinMaxInterval = parseInt(value, defaultSchedulerRoundRobinMaxInterval)
|
||||
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)
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_CUSTOM_URL":
|
||||
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||
p.opts.mediaProxyCustomURL, err = url.Parse(parseString(value, defaultMediaProxyURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: invalid MEDIA_PROXY_CUSTOM_URL value: %w", err)
|
||||
}
|
||||
case "CREATE_ADMIN":
|
||||
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
|
||||
case "ADMIN_USERNAME":
|
||||
@@ -207,10 +184,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 +209,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 +263,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 +280,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 +336,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"
|
||||
}
|
||||
+225
-384
@@ -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
|
||||
@@ -498,7 +230,7 @@ func (h *handler) tokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token := request.GoolgeReaderToken(r)
|
||||
token := request.GoogleReaderToken(r)
|
||||
if token == "" {
|
||||
slog.Warn("[GoogleReader] User does not have token",
|
||||
slog.String("client_ip", clientIP),
|
||||
@@ -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,30 +731,18 @@ 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 := streamContentItemsResponse{
|
||||
Direction: "ltr",
|
||||
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: userName,
|
||||
Items: make([]contentItem, len(entries)),
|
||||
}
|
||||
|
||||
result := streamContentItems{
|
||||
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(),
|
||||
Self: []contentHREF{
|
||||
{
|
||||
HREF: config.Opts.RootURL() + route.Path(h.router, "StreamItemsContents"),
|
||||
},
|
||||
},
|
||||
Author: user.Username,
|
||||
}
|
||||
contentItems := make([]contentItem, len(entries))
|
||||
for i, entry := range entries {
|
||||
enclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))
|
||||
for _, enclosure := range entry.Enclosures {
|
||||
@@ -992,7 +751,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,15 +762,14 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router)
|
||||
|
||||
contentItems[i] = contentItem{
|
||||
ID: fmt.Sprintf(EntryIDLong, entry.ID),
|
||||
result.Items[i] = contentItem{
|
||||
ID: convertEntryIDToLongFormItemID(entry.ID),
|
||||
Title: entry.Title,
|
||||
Author: entry.Author,
|
||||
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
|
||||
CrawlTimeMsec: fmt.Sprintf("%d", entry.CreatedAt.UnixMilli()),
|
||||
TimestampUsec: strconv.FormatInt(entry.Date.UnixMicro(), 10),
|
||||
CrawlTimeMsec: strconv.FormatInt(entry.CreatedAt.UnixMilli(), 10),
|
||||
Published: entry.Date.Unix(),
|
||||
Updated: entry.ChangedAt.Unix(),
|
||||
Categories: categories,
|
||||
@@ -1042,7 +800,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
Enclosure: enclosures,
|
||||
}
|
||||
}
|
||||
result.Items = contentItems
|
||||
|
||||
json.OK(w, r, result)
|
||||
}
|
||||
|
||||
@@ -1063,9 +821,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 +842,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 +861,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 +892,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 +933,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 +968,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 +1014,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 +1034,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 +1090,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 +1261,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,26 +169,19 @@ 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)
|
||||
ctx = context.WithValue(ctx, request.GoogleReaderToken, token)
|
||||
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers.
|
||||
func FindClientIP(r *http.Request) string {
|
||||
headers := []string{"X-Forwarded-For", "X-Real-Ip"}
|
||||
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
|
||||
for _, header := range headers {
|
||||
value := r.Header.Get(header)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ type ContextKey int
|
||||
// List of context keys.
|
||||
const (
|
||||
UserIDContextKey ContextKey = iota
|
||||
UserNameContextKey
|
||||
UserTimezoneContextKey
|
||||
IsAdminUserContextKey
|
||||
IsAuthenticatedContextKey
|
||||
@@ -28,10 +29,9 @@ const (
|
||||
OAuth2CodeVerifierContextKey
|
||||
FlashMessageContextKey
|
||||
FlashErrorMessageContextKey
|
||||
PocketRequestTokenContextKey
|
||||
LastForceRefreshContextKey
|
||||
ClientIPContextKey
|
||||
GoogleReaderToken
|
||||
GoogleReaderTokenKey
|
||||
WebAuthnDataContextKey
|
||||
)
|
||||
|
||||
@@ -44,9 +44,9 @@ func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GoolgeReaderToken returns the google reader token if it exists.
|
||||
func GoolgeReaderToken(r *http.Request) string {
|
||||
return getContextStringValue(r, GoogleReaderToken)
|
||||
// GoogleReaderToken returns the google reader token if it exists.
|
||||
func GoogleReaderToken(r *http.Request) string {
|
||||
return getContextStringValue(r, GoogleReaderTokenKey)
|
||||
}
|
||||
|
||||
// IsAdminUser checks if the logged user is administrator.
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ package response // import "miniflux.app/v2/internal/http/response"
|
||||
import (
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -25,7 +24,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,14 +40,14 @@ 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
|
||||
}
|
||||
|
||||
// WithAttachment forces the document to be downloaded by the web browser.
|
||||
func (b *Builder) WithAttachment(filename string) *Builder {
|
||||
b.headers["Content-Disposition"] = fmt.Sprintf("attachment; filename=%s", filename)
|
||||
b.headers["Content-Disposition"] = "attachment; filename=" + filename
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package html // import "miniflux.app/v2/internal/http/response/html"
|
||||
|
||||
import (
|
||||
"html"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// OK creates a new HTML response with a 200 status code.
|
||||
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
@@ -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;`
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// OK writes a standard XML response with a status 200 OK.
|
||||
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
|
||||
builder.WithBody(body)
|
||||
@@ -18,7 +18,7 @@ func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
}
|
||||
|
||||
// Attachment forces the XML document to be downloaded by the web browser.
|
||||
func Attachment(w http.ResponseWriter, r *http.Request, filename string, body interface{}) {
|
||||
func Attachment[T []byte | string](w http.ResponseWriter, r *http.Request, filename string, body T) {
|
||||
builder := response.New(w, r)
|
||||
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
|
||||
builder.WithAttachment(filename)
|
||||
|
||||
+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),
|
||||
@@ -389,20 +372,27 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
|
||||
if userIntegrations.WebhookEnabled {
|
||||
var webhookURL string
|
||||
if entry.Feed != nil && entry.Feed.WebhookURL != "" {
|
||||
webhookURL = entry.Feed.WebhookURL
|
||||
} else {
|
||||
webhookURL = userIntegrations.WebhookURL
|
||||
}
|
||||
|
||||
slog.Debug("Sending entry to Webhook",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
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.SendSaveEntryWebhookEvent(entry); err != nil {
|
||||
slog.Error("Unable to send entry to Webhook",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("webhook_url", userIntegrations.WebhookURL),
|
||||
slog.String("webhook_url", webhookURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
@@ -426,6 +416,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 +481,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 +573,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"`
|
||||
}
|
||||
|
||||
@@ -74,10 +74,10 @@ func NewClient(apiToken string, apiEndpoint string) Client {
|
||||
}
|
||||
|
||||
func (c *client) SaveUrl(url string) error {
|
||||
var payload = map[string]interface{}{
|
||||
var payload = map[string]any{
|
||||
"query": mutation,
|
||||
"variables": map[string]interface{}{
|
||||
"input": map[string]interface{}{
|
||||
"variables": map[string]any{
|
||||
"input": map[string]any{
|
||||
"clientRequestId": crypto.GenerateUUID(),
|
||||
"source": "api",
|
||||
"url": url,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+44
-20
@@ -9,7 +9,10 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type translationDict map[string]interface{}
|
||||
type translationDict struct {
|
||||
singulars map[string]string
|
||||
plurals map[string][]string
|
||||
}
|
||||
type catalog map[string]translationDict
|
||||
|
||||
var defaultCatalog = make(catalog, len(AvailableLanguages))
|
||||
@@ -17,47 +20,68 @@ 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 {
|
||||
return nil, err
|
||||
return translationDict{}, err
|
||||
}
|
||||
}
|
||||
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
|
||||
return translationDict{}, err
|
||||
}
|
||||
|
||||
translationMessages, err := parseTranslationMessages(translationFileData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return translationDict{}, err
|
||||
}
|
||||
|
||||
return translationMessages, nil
|
||||
}
|
||||
|
||||
func (t *translationDict) UnmarshalJSON(data []byte) error {
|
||||
var tmpMap map[string]any
|
||||
err := json.Unmarshal(data, &tmpMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m := translationDict{
|
||||
singulars: make(map[string]string),
|
||||
plurals: make(map[string][]string),
|
||||
}
|
||||
|
||||
for key, value := range tmpMap {
|
||||
switch vtype := value.(type) {
|
||||
case string:
|
||||
m.singulars[key] = vtype
|
||||
case []any:
|
||||
for _, translation := range vtype {
|
||||
if translationStr, ok := translation.(string); ok {
|
||||
m.plurals[key] = append(m.plurals[key], translationStr)
|
||||
} else {
|
||||
return fmt.Errorf("invalid type for translation in an array: %v", translation)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid type (%T) for translation: %v", vtype, value)
|
||||
}
|
||||
}
|
||||
|
||||
*t = m
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTranslationMessages(data []byte) (translationDict, error) {
|
||||
var translationMessages translationDict
|
||||
if err := json.Unmarshal(data, &translationMessages); err != nil {
|
||||
return nil, fmt.Errorf(`invalid translation file: %w`, err)
|
||||
return translationDict{}, fmt.Errorf(`invalid translation file: %w`, err)
|
||||
}
|
||||
return translationMessages, nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParserWithInvalidData(t *testing.T) {
|
||||
_, err := parseTranslationMessages([]byte(`{`))
|
||||
@@ -18,23 +20,22 @@ func TestParser(t *testing.T) {
|
||||
t.Fatalf(`Unexpected parsing error: %v`, err)
|
||||
}
|
||||
|
||||
if translations == nil {
|
||||
t.Fatal(`Translations should not be nil`)
|
||||
}
|
||||
|
||||
value, found := translations["k"]
|
||||
value, found := translations.singulars["k"]
|
||||
if !found {
|
||||
t.Fatal(`The translation should contains the defined key`)
|
||||
t.Fatalf(`The translation %v should contains the defined key`, translations.singulars)
|
||||
}
|
||||
|
||||
if value.(string) != "v" {
|
||||
if value != "v" {
|
||||
t.Fatal(`The translation key should contains the defined value`)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,20 +46,22 @@ func TestAllKeysHaveValue(t *testing.T) {
|
||||
t.Fatalf(`Unable to load translation messages for language %q`, language)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
t.Fatalf(`The language %q doesn't have any messages`, language)
|
||||
if len(messages.singulars) == 0 {
|
||||
t.Fatalf(`The language %q doesn't have any messages for singulars`, language)
|
||||
}
|
||||
|
||||
for k, v := range messages {
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
if value == "" {
|
||||
t.Errorf(`The key %q for the language %q has an empty string as value`, k, language)
|
||||
}
|
||||
case []any:
|
||||
if len(value) == 0 {
|
||||
t.Errorf(`The key %q for the language %q has an empty list as value`, k, language)
|
||||
}
|
||||
if len(messages.plurals) == 0 {
|
||||
t.Fatalf(`The language %q doesn't have any messages for plurals`, language)
|
||||
}
|
||||
|
||||
for k, v := range messages.singulars {
|
||||
if len(v) == 0 {
|
||||
t.Errorf(`The key %q for singulars for the language %q has an empty list as value`, k, language)
|
||||
}
|
||||
}
|
||||
for k, v := range messages.plurals {
|
||||
if len(v) == 0 {
|
||||
t.Errorf(`The key %q for plurals for the language %q has an empty list as value`, k, language)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,9 +84,14 @@ func TestMissingTranslations(t *testing.T) {
|
||||
t.Fatalf(`Parsing error for language %q`, language)
|
||||
}
|
||||
|
||||
for key := range references {
|
||||
if _, found := messages[key]; !found {
|
||||
t.Fatalf(`Translation key %q not found in language %q`, key, language)
|
||||
for key := range references.singulars {
|
||||
if _, found := messages.singulars[key]; !found {
|
||||
t.Errorf(`Translation key %q not found in language %q singulars`, key, language)
|
||||
}
|
||||
}
|
||||
for key := range references.plurals {
|
||||
if _, found := messages.plurals[key]; !found {
|
||||
t.Errorf(`Translation key %q not found in language %q plurals`, key, language)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,24 +99,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)
|
||||
@@ -116,11 +126,9 @@ func TestTranslationFilePluralForms(t *testing.T) {
|
||||
t.Fatalf(`Unable to load translation messages for language %q`, language)
|
||||
}
|
||||
|
||||
for k, v := range messages {
|
||||
if value, ok := v.([]any); ok {
|
||||
if len(value) != numberOfPluralFormsPerLanguage[language] {
|
||||
t.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(value), numberOfPluralFormsPerLanguage[language])
|
||||
}
|
||||
for k, v := range messages.plurals {
|
||||
if len(v) != numberOfPluralFormsPerLanguage[language] {
|
||||
t.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(v), numberOfPluralFormsPerLanguage[language])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// 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{
|
||||
singulars: map[string]string{
|
||||
"error.test_key": "Error: %s (code: %d)",
|
||||
},
|
||||
},
|
||||
"fr_FR": translationDict{
|
||||
singulars: map[string]string{
|
||||
"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{
|
||||
singulars: map[string]string{
|
||||
"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{
|
||||
singulars: map[string]string{
|
||||
"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{
|
||||
singulars: map[string]string{
|
||||
"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{
|
||||
singulars: map[string]string{
|
||||
"error.permission": "Permission denied for %s",
|
||||
},
|
||||
},
|
||||
"es_ES": translationDict{
|
||||
singulars: map[string]string{
|
||||
"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{
|
||||
singulars: map[string]string{
|
||||
"error.generic": "An error occurred",
|
||||
},
|
||||
},
|
||||
"de_DE": translationDict{
|
||||
singulars: map[string]string{
|
||||
"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{
|
||||
singulars: map[string]string{
|
||||
"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)
|
||||
}
|
||||
|
||||
+16
-49
@@ -10,71 +10,38 @@ 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 str, ok := dict[key]; ok {
|
||||
if translation, ok := str.(string); ok {
|
||||
return translation
|
||||
}
|
||||
if dict, err := getTranslationDict(p.language); err == nil {
|
||||
if str, ok := dict.singulars[key]; ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Printf is like fmt.Printf, but using language-specific formatting.
|
||||
func (p *Printer) Printf(key string, args ...interface{}) 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 {
|
||||
translation = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(translation, args...)
|
||||
func (p *Printer) Printf(key string, args ...any) string {
|
||||
return fmt.Sprintf(p.Print(key), args...)
|
||||
}
|
||||
|
||||
// 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)
|
||||
func (p *Printer) Plural(key string, n int, args ...any) string {
|
||||
dict, err := getTranslationDict(p.language)
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
|
||||
if choices, found := dict[key]; found {
|
||||
var plurals []string
|
||||
|
||||
switch v := choices.(type) {
|
||||
case []interface{}:
|
||||
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)
|
||||
if len(plurals) > index {
|
||||
return fmt.Sprintf(plurals[index], args...)
|
||||
if choices, found := dict.plurals[key]; found {
|
||||
index := getPluralForm(p.language, n)
|
||||
if len(choices) > index {
|
||||
return fmt.Sprintf(choices[index], args...)
|
||||
}
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// NewPrinter creates a new Printer.
|
||||
func NewPrinter(language string) *Printer {
|
||||
return &Printer{language}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user