Compare commits
796 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20545d28e9 | |||
| 771977407c | |||
| 0642e8a3ea | |||
| 059ec55f52 | |||
| 9fd6f44311 | |||
| 018128e109 | |||
| 3f05747a78 | |||
| 7a5b4b109e | |||
| c2661ca1c4 | |||
| 30ad65e03b | |||
| 9a0e357842 | |||
| d4f362ad31 | |||
| 10bdbf82b9 | |||
| f467c2daa1 | |||
| 6b06c9f4f4 | |||
| 30ede1caa1 | |||
| 16902a2297 | |||
| 08de9546e3 | |||
| cf7474a2d3 | |||
| 7892cb8546 | |||
| fc68e33681 | |||
| 622d757a32 | |||
| f74870d796 | |||
| bad9411d63 | |||
| b7da3634e4 | |||
| 8fc7d1f3c2 | |||
| 7d80bbd070 | |||
| cd01413b9b | |||
| 0909323ae3 | |||
| 6543d652a6 | |||
| 64baebad6f | |||
| d5e68025d4 | |||
| e4338950a0 | |||
| c395924fa0 | |||
| 9467d5deb9 | |||
| 55821b9fba | |||
| 45e222632b | |||
| a684b6ac73 | |||
| 1e8ce07585 | |||
| 10f95e9f96 | |||
| 54259c6176 | |||
| cdf0632f08 | |||
| 08113e50bb | |||
| 56c80f3085 | |||
| bbd67302fa | |||
| 2916831cb1 | |||
| 9702c6269f | |||
| 42b9c7ea13 | |||
| f2f9fdd867 | |||
| ee6c390aac | |||
| 15a532b991 | |||
| 45f97c6907 | |||
| deef74e75b | |||
| 182a010ea7 | |||
| 18920385ff | |||
| 75753ce8b3 | |||
| 53095eb76c | |||
| 6569367531 | |||
| ecd663a094 | |||
| 68bc5a92e2 | |||
| 2e60923236 | |||
| 0986f73093 | |||
| 76143ec1a6 | |||
| ef47db4a36 | |||
| 8bbd60dac0 | |||
| 0172d31694 | |||
| 1b7af66fdc | |||
| de9cd88a06 | |||
| b2316442cf | |||
| e0b1ec24e3 | |||
| bca47d7dfb | |||
| 19f72e8ea2 | |||
| 26d9195d21 | |||
| 5f3049d1ce | |||
| c591ea335f | |||
| 8df6b823ee | |||
| fd75311621 | |||
| 4e8a0e0639 | |||
| 96989e2a6f | |||
| 4f504499fa | |||
| 382e179e19 | |||
| 47ced3e1f8 | |||
| e2f225fde9 | |||
| 932e013590 | |||
| d5c37d7d12 | |||
| b160f92ff8 | |||
| 31f077cccc | |||
| 062641330a | |||
| c61c20247d | |||
| 873416f4be | |||
| f8171fe69f | |||
| ba684cabfd | |||
| 9151dc5377 | |||
| ca7c38a680 | |||
| f5bfd5a70c | |||
| f6c5f2f740 | |||
| 2de3427dc7 | |||
| 21703c81f4 | |||
| b79f22a63c | |||
| 59bd092a02 | |||
| 1d4ca6a6bc | |||
| b4c9719000 | |||
| 5c9edf3c1b | |||
| 2b21269900 | |||
| d57774df00 | |||
| 07a9685ae5 | |||
| cc06154d85 | |||
| fcde20903c | |||
| 570227c263 | |||
| 2572809998 | |||
| 16ff071365 | |||
| 293209098a | |||
| 76452fab99 | |||
| 8c947e639b | |||
| 96897a8425 | |||
| fa9ab92b40 | |||
| 6ea078c1cb | |||
| 3105e1e55b | |||
| 9c28982f7c | |||
| 0162c467f2 | |||
| d33544e26a | |||
| f03285883b | |||
| cd3ea68024 | |||
| 925b05f912 | |||
| cc528b640b | |||
| 1e42cec0ee | |||
| 6276d7e69b | |||
| 50947318e1 | |||
| fa7184e41b | |||
| b1713dc626 | |||
| a392b0d53f | |||
| 6093819c9c | |||
| 4413c80a2a | |||
| f50c5dda7b | |||
| b965e45684 | |||
| 9660e8a84d | |||
| e9edda8ef6 | |||
| b68d1410e4 | |||
| ab0ea2da43 | |||
| 74b37e4b78 | |||
| 70f14e1413 | |||
| 5c414f586b | |||
| b18ecc0a78 | |||
| 30c9ba9f18 | |||
| 246b706945 | |||
| de61a3c64c | |||
| a89852a331 | |||
| 7516bdfcaf | |||
| 7e5219e5ac | |||
| 2d3a9aebb4 | |||
| 7d0ed5d252 | |||
| 9cd7629615 | |||
| 4cd9dd6af7 | |||
| c2cbf5370d | |||
| c166f80b58 | |||
| 075127c56f | |||
| 34fcf0e54f | |||
| 7f4a1094f4 | |||
| 7846ddb3d7 | |||
| 6ff4f5ac67 | |||
| bf738d4b7f | |||
| 7242890d96 | |||
| b624cfd165 | |||
| bce1697c95 | |||
| 14462c4b2e | |||
| 3ae595ea1c | |||
| 4d76de1722 | |||
| 171ed7d2f6 | |||
| d9a97e268d | |||
| 365f2e6ee7 | |||
| 210993e146 | |||
| fe2bfb27cf | |||
| f9b756ecf8 | |||
| e8b2785329 | |||
| df7fc1e853 | |||
| 26824211aa | |||
| 0e13773d25 | |||
| 8d01ca3637 | |||
| 8b84a86f0c | |||
| be49cba34b | |||
| d7e049f187 | |||
| 4d7e8b2796 | |||
| 7f7f23d33d | |||
| 01888d8e32 | |||
| 223d29e5d9 | |||
| d58cb5b72c | |||
| 313f23fd65 | |||
| 54df471d0e | |||
| 72784c4943 | |||
| 7fa38aafe7 | |||
| fb7f16ecf2 | |||
| 8898c42f24 | |||
| 0054314355 | |||
| 28441d1e27 | |||
| 8d19529948 | |||
| 6838f3a6e9 | |||
| 8483f06595 | |||
| da15700205 | |||
| cd6236b1cd | |||
| 7b07b8256b | |||
| d3a1e7dbec | |||
| 7edf42fe18 | |||
| 5513827cdd | |||
| 052ad27e38 | |||
| 96c45874f5 | |||
| 3e022fb86e | |||
| aa814491f6 | |||
| 90c2199f0f | |||
| d40e523d25 | |||
| 46e77eccb0 | |||
| f19fc2ff53 | |||
| 0201c80db1 | |||
| 0022d0646b | |||
| 253119ed5f | |||
| 2619eba43c | |||
| 31f4f366a9 | |||
| fc9cb9e5d5 | |||
| 9b859e7f3f | |||
| 6f3c92db74 | |||
| 0dcc0a852b | |||
| 7b65255757 | |||
| 6a9d7894d1 | |||
| f3d0e0378b | |||
| 4d27f66ff9 | |||
| 5a8431b144 | |||
| 9e760e14cd | |||
| 879e40670c | |||
| bf24fd1ec9 | |||
| b57ebac757 | |||
| acfb7dc213 | |||
| 62f316c7cf | |||
| 89638b448a | |||
| d8181b1f65 | |||
| ce6cd0b2b6 | |||
| ba05c96d88 | |||
| 58674b9277 | |||
| 6b1f208209 | |||
| 7c41128d88 | |||
| 3aea68e725 | |||
| a5fb91e3e8 | |||
| 8357e6decf | |||
| ad4ca71f68 | |||
| 660c79bbdd | |||
| 2fa8995a35 | |||
| ffed2b4fa1 | |||
| 9fc69fd2e0 | |||
| cddd2eb168 | |||
| 33b780d9c0 | |||
| 5d8540b324 | |||
| a2a5244387 | |||
| 3a232d0c8d | |||
| 5a8df8abf0 | |||
| a4883ca11e | |||
| 4cd66aef5f | |||
| 0ab3bb5efb | |||
| 71c551ffe0 | |||
| bb05b25530 | |||
| 7b7fd48e15 | |||
| fb0fcfb266 | |||
| 39840bb0d2 | |||
| 79a3369699 | |||
| 696b98bb16 | |||
| 85fa69ba38 | |||
| c5a9111d58 | |||
| a46afe6979 | |||
| 2f67a68e75 | |||
| 83ca211cfb | |||
| 55aa15dd52 | |||
| e54e6c097c | |||
| 56a11d1d45 | |||
| df28028b24 | |||
| 6439d352d2 | |||
| a33cfb2b7d | |||
| 590adb9967 | |||
| 9e817e646f | |||
| 5594586941 | |||
| 5db66fc279 | |||
| 561389da69 | |||
| cd7d61966c | |||
| 29f6dc8896 | |||
| 6c83e8c477 | |||
| 29015903ba | |||
| 709e671168 | |||
| 473c9f225e | |||
| 90a389ac25 | |||
| 6106546a32 | |||
| ad82191ce1 | |||
| 6080d56a5c | |||
| 854985bcfa | |||
| 1bf7c0bb51 | |||
| bc409480e7 | |||
| 3cb74e152b | |||
| fbc4d700d5 | |||
| 40d9965b28 | |||
| 28e670d989 | |||
| dc12713be1 | |||
| 7bf58e7ab0 | |||
| c502b80fa9 | |||
| c23a62184b | |||
| 327119c828 | |||
| 488a3bba5f | |||
| 7f3c6db4d8 | |||
| 884569fc76 | |||
| 0960624e85 | |||
| 4063ca39c9 | |||
| f6476db767 | |||
| 142d8d7679 | |||
| 5bd5993a24 | |||
| 18cd544fab | |||
| 88f9f279e6 | |||
| dc1653ba3a | |||
| 3ea4aee4d6 | |||
| f447307359 | |||
| 4892e04e9b | |||
| 579b1efc68 | |||
| a16aa10fed | |||
| ea9adc8978 | |||
| fd7c886997 | |||
| b3a63dbd86 | |||
| 7ac8eb96c4 | |||
| c43a90716b | |||
| 0a08f4832e | |||
| bc375e3c59 | |||
| d3ebfd67dd | |||
| c1cfd184ee | |||
| e580126b8d | |||
| 86fca5d060 | |||
| c365118cf7 | |||
| 6f2d10c436 | |||
| 093004b724 | |||
| d2c783b06c | |||
| 7e2dd3afe6 | |||
| 4ace959667 | |||
| 14f31954d1 | |||
| 907daf78bf | |||
| cf6386b471 | |||
| 76df99f3a3 | |||
| 57e3eaecd9 | |||
| 21d3a5a61c | |||
| 385d8bb969 | |||
| c171da1734 | |||
| 11ea137027 | |||
| 0a15075c00 | |||
| b1fda599ac | |||
| 509b7682ad | |||
| 06c2e50ffa | |||
| ec93656ef5 | |||
| 317aaeeec7 | |||
| d862f79123 | |||
| 1bea41b19c | |||
| 65a39096e7 | |||
| 131dc674e4 | |||
| b0dced42c0 | |||
| a34e33b5c5 | |||
| af12fe309e | |||
| dd44fbcc76 | |||
| 9c956d1b0d | |||
| bf7f55e28a | |||
| b8bc367a00 | |||
| 04a360a536 | |||
| fac18d5c57 | |||
| a3d1ecc58a | |||
| 8adcaed29e | |||
| 5a97bf8b5e | |||
| e279b955c4 | |||
| 1620f8d3f2 | |||
| 1a29c1568c | |||
| ff07f02716 | |||
| 79b0d0b9cc | |||
| 5fa0709663 | |||
| a7fa2ecc8c | |||
| 10b2b36895 | |||
| 87e65f800e | |||
| 907cf16af1 | |||
| 854a78a7d1 | |||
| 5e607be86a | |||
| 502e7108dd | |||
| 0b93d8abcc | |||
| 93a8629910 | |||
| b1742168e1 | |||
| 4eff9129ab | |||
| eb22d90b56 | |||
| 8f2dd02f3f | |||
| da8d4d86c3 | |||
| 7ada5d54be | |||
| 8129500296 | |||
| f1143151c5 | |||
| baf8e40152 | |||
| a98374b81f | |||
| 77e0b07f9f | |||
| 645800ce3c | |||
| a60a153003 | |||
| 7d40e2993b | |||
| 91a625c559 | |||
| dbb3855901 | |||
| 3051acf369 | |||
| 5f38054965 | |||
| fa361ab1ce | |||
| afe80e6bae | |||
| f2976bff5d | |||
| 84078c7c20 | |||
| d70817e441 | |||
| e8f5c2446c | |||
| 737f25f441 | |||
| f83d66769d | |||
| af149e46df | |||
| 4f252b33c9 | |||
| da9c3a4032 | |||
| 8e1d0bb693 | |||
| 60cd7ffe88 | |||
| 4d656d2739 | |||
| 983291c78b | |||
| 7060ecc163 | |||
| 4af12a4129 | |||
| c6536e8d90 | |||
| 30453ad7ec | |||
| 71af68becd | |||
| ed3bf59356 | |||
| 03021af53c | |||
| 83254a1f26 | |||
| 3acb888309 | |||
| 7c29166ef9 | |||
| 71105bec56 | |||
| 2a372674a2 | |||
| 9d32b23ab0 | |||
| da8bf3890c | |||
| b30eac4ebe | |||
| 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 |
@@ -9,7 +9,9 @@
|
||||
],
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
|
||||
"moby": false
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
@@ -28,4 +30,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/go:1.23
|
||||
image: mcr.microsoft.com/devcontainers/go:1-trixie # https://www.debian.org/releases/trixie/index.en.html
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
command: sleep infinity
|
||||
@@ -11,10 +10,10 @@ services:
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=test123
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- postgres-data:/var/lib/postgresql
|
||||
hostname: postgres
|
||||
environment:
|
||||
POSTGRES_DB: miniflux2
|
||||
|
||||
@@ -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
|
||||
@@ -63,3 +63,5 @@ body:
|
||||
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
|
||||
|
||||
@@ -84,3 +84,5 @@ body:
|
||||
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
|
||||
|
||||
+20
-43
@@ -3,53 +3,30 @@ updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
reviewers:
|
||||
- "fguillot"
|
||||
assignees:
|
||||
- "fguillot"
|
||||
interval: "weekly"
|
||||
groups:
|
||||
gomod:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/packaging/docker/alpine"
|
||||
directories:
|
||||
- "/packaging/docker/alpine"
|
||||
- "/packaging/docker/distroless"
|
||||
- "/packaging/debian"
|
||||
- "/packaging/rpm"
|
||||
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"
|
||||
interval: "monthly"
|
||||
groups:
|
||||
docker:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
reviewers:
|
||||
- "fguillot"
|
||||
assignees:
|
||||
- "fguillot"
|
||||
interval: "monthly"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -4,4 +4,4 @@ Have you followed these guidelines?
|
||||
- [ ] There are no breaking changes
|
||||
- [ ] 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 this document: https://miniflux.app/faq.html#pull-request
|
||||
- [ ] I have read and understood the [contribution guidelines](https://github.com/miniflux/v2/blob/main/CONTRIBUTING.md)
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
name: Build Binaries
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '.github/workflows/build_binaries.yml'
|
||||
- 'Makefile'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- '**.go'
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
if: github.repository_owner == 'miniflux'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
go-version: stable
|
||||
check-latest: true
|
||||
- name: Compile binaries
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
run: make build
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: binaries
|
||||
path: miniflux-*
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Mirror to Codeberg
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
delete:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
if: github.repository_owner == 'miniflux'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Mirror to Codeberg
|
||||
env:
|
||||
CODEBERG_USERNAME: ${{ secrets.CODEBERG_USERNAME }}
|
||||
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
|
||||
run: |
|
||||
git remote add codeberg https://${{ secrets.CODEBERG_USERNAME }}:${{ secrets.CODEBERG_TOKEN }}@codeberg.org/miniflux/v2.git
|
||||
git push --force --prune codeberg \
|
||||
"refs/heads/*:refs/heads/*" \
|
||||
"refs/tags/*:refs/tags/*"
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- '**.js'
|
||||
- '**.go'
|
||||
- '!**_test.go'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
@@ -16,12 +17,14 @@ on:
|
||||
- '**.js'
|
||||
- '**.go'
|
||||
- '!**_test.go'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
schedule:
|
||||
- cron: '45 22 * * 3'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -30,20 +33,27 @@ jobs:
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go', 'javascript' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
if: matrix.language == 'go'
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
go-version: stable
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -11,19 +11,21 @@ on:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/debian/**' # Only run on changes to the debian packaging files
|
||||
- '.github/workflows/debian_packages.yml'
|
||||
jobs:
|
||||
test-packages:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux')
|
||||
|| github.event_name == 'pull_request'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
@@ -38,13 +40,13 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
@@ -53,24 +55,24 @@ jobs:
|
||||
- name: Build Debian Packages
|
||||
run: make debian-packages
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packages
|
||||
path: "*.deb"
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
publish-packages:
|
||||
if: github.event_name == 'push'
|
||||
if: github.event_name == 'push' && github.repository_owner == 'miniflux'
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
|
||||
@@ -9,21 +9,23 @@ on:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/docker/**'
|
||||
- '.github/workflows/docker.yml'
|
||||
jobs:
|
||||
docker-images:
|
||||
name: Docker Images
|
||||
if: github.repository_owner == 'miniflux'
|
||||
permissions:
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate Alpine Docker tags
|
||||
id: docker_alpine_tags
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
@@ -36,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Generate Distroless Docker tags
|
||||
id: docker_distroless_tags
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
@@ -50,50 +52,48 @@ jobs:
|
||||
suffix=-distroless,onlatest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Quay Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_TOKEN }}
|
||||
|
||||
- name: Build and Push Alpine images
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/alpine/Dockerfile
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/riscv64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
|
||||
|
||||
- name: Build and Push Distroless images
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/distroless/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_distroless_tags.outputs.tags }}
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Javascript Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install linters
|
||||
run: |
|
||||
sudo npm install -g jshint@2.13.6 eslint@8.57.0
|
||||
@@ -25,33 +25,25 @@ jobs:
|
||||
name: Golang Linters
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
- uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
args: >
|
||||
--timeout 10m
|
||||
--disable errcheck
|
||||
--enable sqlclosecheck,misspell,whitespace,gocritic
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
- name: Run gofmt linter
|
||||
run: gofmt -d -e .
|
||||
|
||||
commitlint:
|
||||
if: github.event_name == 'pull_request'
|
||||
name: Commit Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
- name: Install commitlint
|
||||
run: |
|
||||
npm install --save-dev @commitlint/config-conventional @commitlint/cli
|
||||
echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js
|
||||
python-version: '3.13'
|
||||
- name: Validate PR commits
|
||||
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
||||
run: python3 .github/workflows/scripts/commit-checker.py --base ${{ github.event.pull_request.base.sha }} --head ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
@@ -11,17 +11,19 @@ on:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/rpm/**' # Only run on changes to the rpm packaging files
|
||||
- '.github/workflows/rpm_packages.yml'
|
||||
jobs:
|
||||
test-package:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
if: (github.event_name == 'schedule' && github.repository_owner == 'miniflux')
|
||||
|| github.event_name == 'pull_request'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
run: make rpm
|
||||
run: make rpm VERSION=2.2.x_dev
|
||||
- name: List generated files
|
||||
run: ls -l *.rpm
|
||||
build-package-manually:
|
||||
@@ -29,24 +31,24 @@ jobs:
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
run: make rpm
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packages
|
||||
path: "*.rpm"
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
publish-package:
|
||||
if: github.event_name == 'push'
|
||||
if: github.event_name == 'push' && github.repository_owner == 'miniflux'
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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|security|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()
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Close Stale Pull Requests
|
||||
permissions: read-all
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
days-before-pr-stale: 60
|
||||
days-before-pr-close: 14
|
||||
stale-pr-label: stale
|
||||
stale-pr-message: >
|
||||
This pull request has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs within 14 days.
|
||||
close-pr-message: >
|
||||
This pull request has been automatically closed due to inactivity.
|
||||
Please feel free to reopen it if you would like to continue working on it.
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
@@ -15,14 +15,13 @@ jobs:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
go-version: ["1.24.x"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: "1.24.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-*
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
version: "2"
|
||||
linters:
|
||||
default: standard
|
||||
disable:
|
||||
- errcheck
|
||||
enable:
|
||||
- errname
|
||||
- gocritic
|
||||
- goheader
|
||||
- loggercheck
|
||||
- misspell
|
||||
- perfsprint
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- whitespace
|
||||
settings:
|
||||
loggercheck:
|
||||
slog: true
|
||||
goheader:
|
||||
template: |-
|
||||
SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
+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.26**
|
||||
- **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, riscv64)
|
||||
- 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
|
||||
@@ -18,20 +16,15 @@ export PGPASSWORD := postgres
|
||||
linux-armv7 \
|
||||
linux-armv6 \
|
||||
linux-armv5 \
|
||||
linux-x86 \
|
||||
linux-riscv64 \
|
||||
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 +37,52 @@ 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
|
||||
|
||||
linux-riscv64:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 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 linux-riscv64 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,7 +90,6 @@ run:
|
||||
clean:
|
||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256
|
||||
|
||||
.PHONY: add-string
|
||||
add-string:
|
||||
cd internal/locale/translations && \
|
||||
for file in *.json; do \
|
||||
@@ -125,27 +98,27 @@ add-string:
|
||||
mv tmp "$$file"; \
|
||||
done
|
||||
|
||||
|
||||
test:
|
||||
go test -cover -race -count=1 ./...
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
staticcheck ./...
|
||||
golangci-lint run --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
|
||||
test -z "$$(gofmt -l .)"
|
||||
golangci-lint run
|
||||
|
||||
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 \
|
||||
FETCHER_ALLOW_PRIVATE_NETWORKS=1 \
|
||||
INTEGRATION_ALLOW_PRIVATE_NETWORKS=1 \
|
||||
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||
|
||||
while ! nc -z localhost 8080; do sleep 1; done
|
||||
|
||||
@@ -157,7 +130,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:
|
||||
@@ -168,7 +140,7 @@ docker-image-distroless:
|
||||
|
||||
docker-images:
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6,linux/riscv64 \
|
||||
--file packaging/docker/alpine/Dockerfile \
|
||||
--tag $(DOCKER_IMAGE):$(VERSION) \
|
||||
--push .
|
||||
@@ -195,3 +167,4 @@ debian-packages: clean
|
||||
$(MAKE) debian DOCKER_PLATFORM=amd64
|
||||
$(MAKE) debian DOCKER_PLATFORM=arm64
|
||||
$(MAKE) debian DOCKER_PLATFORM=arm/v7
|
||||
$(MAKE) debian DOCKER_PLATFORM=riscv64
|
||||
|
||||
@@ -22,7 +22,7 @@ Features
|
||||
- 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
|
||||
### Privacy and Security
|
||||
|
||||
- Removes pixel trackers.
|
||||
- Strips tracking parameters from URLs (e.g., `utm_source`, `utm_medium`, `utm_campaign`, `fbclid`, etc.).
|
||||
@@ -33,6 +33,8 @@ Features
|
||||
- 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
|
||||
|
||||
@@ -70,7 +72,7 @@ Features
|
||||
|
||||
### 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/), [Pocket](https://getpocket.com/), [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.
|
||||
- 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), [LinkTaco](https://linktaco.com), [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.
|
||||
@@ -97,13 +99,15 @@ Features
|
||||
- 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.
|
||||
- Sanitizes external content before rendering it.
|
||||
- Enforces a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) that permits only application JavaScript and blocks inline scripts and styles.
|
||||
- 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.
|
||||
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM and RISC-V 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
|
||||
-------------
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ Miniflux API Client
|
||||
|
||||
[](https://pkg.go.dev/miniflux.app/v2/client)
|
||||
|
||||
Client library for Miniflux REST API.
|
||||
Go client for the Miniflux REST API. It supports API tokens or basic authentication and mirrors the server endpoints closely.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
+562
-66
@@ -4,9 +4,11 @@
|
||||
package client // import "miniflux.app/v2/client"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -20,28 +22,53 @@ type Client struct {
|
||||
// New returns a new Miniflux client.
|
||||
//
|
||||
// Deprecated: use NewClient instead.
|
||||
//
|
||||
//go:fix inline
|
||||
func New(endpoint string, credentials ...string) *Client {
|
||||
return NewClient(endpoint, credentials...)
|
||||
}
|
||||
|
||||
// NewClient returns a new Miniflux client.
|
||||
func NewClient(endpoint string, credentials ...string) *Client {
|
||||
switch len(credentials) {
|
||||
case 2:
|
||||
return NewClientWithOptions(endpoint, WithCredentials(credentials[0], credentials[1]))
|
||||
case 1:
|
||||
return NewClientWithOptions(endpoint, WithAPIKey(credentials[0]))
|
||||
default:
|
||||
return NewClientWithOptions(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientWithOptions returns a new Miniflux client with options.
|
||||
func NewClientWithOptions(endpoint string, options ...Option) *Client {
|
||||
// Trim trailing slashes and /v1 from the endpoint.
|
||||
endpoint = strings.TrimSuffix(endpoint, "/")
|
||||
endpoint = strings.TrimSuffix(endpoint, "/v1")
|
||||
switch len(credentials) {
|
||||
case 2:
|
||||
return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
|
||||
case 1:
|
||||
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
|
||||
default:
|
||||
return &Client{request: &request{endpoint: endpoint}}
|
||||
request := &request{endpoint: endpoint, client: http.DefaultClient}
|
||||
|
||||
for _, option := range options {
|
||||
option(request)
|
||||
}
|
||||
|
||||
return &Client{request: request}
|
||||
}
|
||||
|
||||
func withDefaultTimeout() (context.Context, func()) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
return ctx, cancel
|
||||
}
|
||||
|
||||
// Healthcheck checks if the application is up and running.
|
||||
func (c *Client) Healthcheck() error {
|
||||
body, err := c.request.Get("/healthcheck")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.HealthcheckContext(ctx)
|
||||
}
|
||||
|
||||
// HealthcheckContext checks if the application is up and running.
|
||||
func (c *Client) HealthcheckContext(ctx context.Context) error {
|
||||
body, err := c.request.Get(ctx, "/healthcheck")
|
||||
if err != nil {
|
||||
return fmt.Errorf("miniflux: unable to perform healthcheck: %w", err)
|
||||
}
|
||||
@@ -61,7 +88,14 @@ func (c *Client) Healthcheck() error {
|
||||
|
||||
// Version returns the version of the Miniflux instance.
|
||||
func (c *Client) Version() (*VersionResponse, error) {
|
||||
body, err := c.request.Get("/v1/version")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.VersionContext(ctx)
|
||||
}
|
||||
|
||||
// VersionContext returns the version of the Miniflux instance.
|
||||
func (c *Client) VersionContext(ctx context.Context) (*VersionResponse, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -77,7 +111,14 @@ func (c *Client) Version() (*VersionResponse, error) {
|
||||
|
||||
// Me returns the logged user information.
|
||||
func (c *Client) Me() (*User, error) {
|
||||
body, err := c.request.Get("/v1/me")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.MeContext(ctx)
|
||||
}
|
||||
|
||||
// MeContext returns the logged user information.
|
||||
func (c *Client) MeContext(ctx context.Context) (*User, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/me")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -93,7 +134,14 @@ func (c *Client) Me() (*User, error) {
|
||||
|
||||
// Users returns all users.
|
||||
func (c *Client) Users() (Users, error) {
|
||||
body, err := c.request.Get("/v1/users")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UsersContext(ctx)
|
||||
}
|
||||
|
||||
// UsersContext returns all users.
|
||||
func (c *Client) UsersContext(ctx context.Context) (Users, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/users")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -109,7 +157,14 @@ func (c *Client) Users() (Users, error) {
|
||||
|
||||
// UserByID returns a single user.
|
||||
func (c *Client) UserByID(userID int64) (*User, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/users/%d", userID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UserByIDContext(ctx, userID)
|
||||
}
|
||||
|
||||
// UserByIDContext returns a single user.
|
||||
func (c *Client) UserByIDContext(ctx context.Context, userID int64) (*User, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/users/%d", userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -125,7 +180,14 @@ func (c *Client) UserByID(userID int64) (*User, error) {
|
||||
|
||||
// UserByUsername returns a single user.
|
||||
func (c *Client) UserByUsername(username string) (*User, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/users/%s", username))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UserByUsernameContext(ctx, username)
|
||||
}
|
||||
|
||||
// UserByUsernameContext returns a single user.
|
||||
func (c *Client) UserByUsernameContext(ctx context.Context, username string) (*User, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/users/"+username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -141,7 +203,14 @@ func (c *Client) UserByUsername(username string) (*User, error) {
|
||||
|
||||
// CreateUser creates a new user in the system.
|
||||
func (c *Client) CreateUser(username, password string, isAdmin bool) (*User, error) {
|
||||
body, err := c.request.Post("/v1/users", &UserCreationRequest{
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateUserContext(ctx, username, password, isAdmin)
|
||||
}
|
||||
|
||||
// CreateUserContext creates a new user in the system.
|
||||
func (c *Client) CreateUserContext(ctx context.Context, username, password string, isAdmin bool) (*User, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/users", &UserCreationRequest{
|
||||
Username: username,
|
||||
Password: password,
|
||||
IsAdmin: isAdmin,
|
||||
@@ -161,7 +230,14 @@ func (c *Client) CreateUser(username, password string, isAdmin bool) (*User, err
|
||||
|
||||
// UpdateUser updates a user in the system.
|
||||
func (c *Client) UpdateUser(userID int64, userChanges *UserModificationRequest) (*User, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/users/%d", userID), userChanges)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateUserContext(ctx, userID, userChanges)
|
||||
}
|
||||
|
||||
// UpdateUserContext updates a user in the system.
|
||||
func (c *Client) UpdateUserContext(ctx context.Context, userID int64, userChanges *UserModificationRequest) (*User, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/users/%d", userID), userChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -177,18 +253,99 @@ func (c *Client) UpdateUser(userID int64, userChanges *UserModificationRequest)
|
||||
|
||||
// DeleteUser removes a user from the system.
|
||||
func (c *Client) DeleteUser(userID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/users/%d", userID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DeleteUserContext(ctx, userID)
|
||||
}
|
||||
|
||||
// DeleteUserContext removes a user from the system.
|
||||
func (c *Client) DeleteUserContext(ctx context.Context, userID int64) error {
|
||||
return c.request.Delete(ctx, fmt.Sprintf("/v1/users/%d", userID))
|
||||
}
|
||||
|
||||
// APIKeys returns all API keys for the authenticated user.
|
||||
func (c *Client) APIKeys() (APIKeys, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.APIKeysContext(ctx)
|
||||
}
|
||||
|
||||
// APIKeysContext returns all API keys for the authenticated user.
|
||||
func (c *Client) APIKeysContext(ctx context.Context) (APIKeys, error) {
|
||||
body, err := c.request.Get(ctx, "/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) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateAPIKeyContext(ctx, description)
|
||||
}
|
||||
|
||||
// CreateAPIKeyContext creates a new API key for the authenticated user.
|
||||
func (c *Client) CreateAPIKeyContext(ctx context.Context, description string) (*APIKey, error) {
|
||||
body, err := c.request.Post(ctx, "/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 {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DeleteAPIKeyContext(ctx, apiKeyID)
|
||||
}
|
||||
|
||||
// DeleteAPIKeyContext removes an API key for the authenticated user.
|
||||
func (c *Client) DeleteAPIKeyContext(ctx context.Context, apiKeyID int64) error {
|
||||
return c.request.Delete(ctx, 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)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.MarkAllAsReadContext(ctx, userID)
|
||||
}
|
||||
|
||||
// MarkAllAsReadContext marks all unread entries as read for a given user.
|
||||
func (c *Client) MarkAllAsReadContext(ctx context.Context, userID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// IntegrationsStatus fetches the integrations status for the logged user.
|
||||
// IntegrationsStatus fetches the integrations status for the signed-in user.
|
||||
func (c *Client) IntegrationsStatus() (bool, error) {
|
||||
body, err := c.request.Get("/v1/integrations/status")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.IntegrationsStatusContext(ctx)
|
||||
}
|
||||
|
||||
// IntegrationsStatusContext fetches the integrations status for the signed-in user.
|
||||
func (c *Client) IntegrationsStatusContext(ctx context.Context) (bool, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/integrations/status")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -205,9 +362,16 @@ func (c *Client) IntegrationsStatus() (bool, error) {
|
||||
return response.HasIntegrations, nil
|
||||
}
|
||||
|
||||
// Discover try to find subscriptions from a website.
|
||||
// Discover tries to find subscriptions on a website.
|
||||
func (c *Client) Discover(url string) (Subscriptions, error) {
|
||||
body, err := c.request.Post("/v1/discover", map[string]string{"url": url})
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DiscoverContext(ctx, url)
|
||||
}
|
||||
|
||||
// DiscoverContext tries to find subscriptions from a website.
|
||||
func (c *Client) DiscoverContext(ctx context.Context, url string) (Subscriptions, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/discover", map[string]string{"url": url})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -221,9 +385,39 @@ func (c *Client) Discover(url string) (Subscriptions, error) {
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// Categories gets the list of categories.
|
||||
// Categories retrieves the list of categories.
|
||||
func (c *Client) Categories() (Categories, error) {
|
||||
body, err := c.request.Get("/v1/categories")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoriesContext(ctx)
|
||||
}
|
||||
|
||||
// CategoriesContext retrieves the list of categories.
|
||||
func (c *Client) CategoriesContext(ctx context.Context) (Categories, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/categories")
|
||||
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
|
||||
}
|
||||
|
||||
// CategoriesWithCounters fetches the categories with their respective feed and unread counts.
|
||||
func (c *Client) CategoriesWithCounters() (Categories, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoriesWithCountersContext(ctx)
|
||||
}
|
||||
|
||||
// CategoriesWithCountersContext fetches the categories with their respective feed and unread counts.
|
||||
func (c *Client) CategoriesWithCountersContext(ctx context.Context) (Categories, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/categories?counts=true")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -239,8 +433,15 @@ func (c *Client) Categories() (Categories, error) {
|
||||
|
||||
// CreateCategory creates a new category.
|
||||
func (c *Client) CreateCategory(title string) (*Category, error) {
|
||||
body, err := c.request.Post("/v1/categories", map[string]interface{}{
|
||||
"title": title,
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateCategoryContext(ctx, title)
|
||||
}
|
||||
|
||||
// CreateCategoryContext creates a new category.
|
||||
func (c *Client) CreateCategoryContext(ctx context.Context, title string) (*Category, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/categories", &CategoryCreationRequest{
|
||||
Title: title,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -255,10 +456,39 @@ 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) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateCategoryWithOptionsContext(ctx, createRequest)
|
||||
}
|
||||
|
||||
// CreateCategoryWithOptionsContext creates a new category with options.
|
||||
func (c *Client) CreateCategoryWithOptionsContext(ctx context.Context, createRequest *CategoryCreationRequest) (*Category, error) {
|
||||
body, err := c.request.Post(ctx, "/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,
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateCategoryContext(ctx, categoryID, title)
|
||||
}
|
||||
|
||||
// UpdateCategoryContext updates a category.
|
||||
func (c *Client) UpdateCategoryContext(ctx context.Context, categoryID int64, title string) (*Category, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d", categoryID), &CategoryModificationRequest{
|
||||
Title: new(title),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -273,15 +503,52 @@ 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) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateCategoryWithOptionsContext(ctx, categoryID, categoryChanges)
|
||||
}
|
||||
|
||||
// UpdateCategoryWithOptionsContext updates a category with options.
|
||||
func (c *Client) UpdateCategoryWithOptionsContext(ctx context.Context, categoryID int64, categoryChanges *CategoryModificationRequest) (*Category, error) {
|
||||
body, err := c.request.Put(ctx, 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)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.MarkCategoryAsReadContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// MarkCategoryAsReadContext marks all unread entries in a category as read.
|
||||
func (c *Client) MarkCategoryAsReadContext(ctx context.Context, categoryID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d/mark-all-as-read", categoryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// CategoryFeeds gets feeds of a category.
|
||||
// CategoryFeeds returns all feeds for a category.
|
||||
func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoryFeedsContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// CategoryFeedsContext returns all feeds for a category.
|
||||
func (c *Client) CategoryFeedsContext(ctx context.Context, categoryID int64) (Feeds, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/feeds", categoryID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -297,18 +564,39 @@ func (c *Client) CategoryFeeds(categoryID int64) (Feeds, error) {
|
||||
|
||||
// DeleteCategory removes a category.
|
||||
func (c *Client) DeleteCategory(categoryID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/categories/%d", categoryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DeleteCategoryContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// DeleteCategoryContext removes a category.
|
||||
func (c *Client) DeleteCategoryContext(ctx context.Context, categoryID int64) error {
|
||||
return c.request.Delete(ctx, fmt.Sprintf("/v1/categories/%d", categoryID))
|
||||
}
|
||||
|
||||
// RefreshCategory refreshes a category.
|
||||
func (c *Client) RefreshCategory(categoryID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/categories/%d/refresh", categoryID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.RefreshCategoryContext(ctx, categoryID)
|
||||
}
|
||||
|
||||
// RefreshCategoryContext refreshes a category.
|
||||
func (c *Client) RefreshCategoryContext(ctx context.Context, categoryID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/categories/%d/refresh", categoryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feeds gets all feeds.
|
||||
func (c *Client) Feeds() (Feeds, error) {
|
||||
body, err := c.request.Get("/v1/feeds")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedsContext(ctx)
|
||||
}
|
||||
|
||||
// FeedsContext gets all feeds.
|
||||
func (c *Client) FeedsContext(ctx context.Context) (Feeds, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/feeds")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -322,9 +610,16 @@ func (c *Client) Feeds() (Feeds, error) {
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
// Export creates OPML file.
|
||||
// Export exports subscriptions as an OPML document.
|
||||
func (c *Client) Export() ([]byte, error) {
|
||||
body, err := c.request.Get("/v1/export")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.ExportContext(ctx)
|
||||
}
|
||||
|
||||
// ExportContext exports subscriptions as an OPML document.
|
||||
func (c *Client) ExportContext(ctx context.Context) ([]byte, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/export")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -340,13 +635,27 @@ func (c *Client) Export() ([]byte, error) {
|
||||
|
||||
// Import imports an OPML file.
|
||||
func (c *Client) Import(f io.ReadCloser) error {
|
||||
_, err := c.request.PostFile("/v1/import", f)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.ImportContext(ctx, f)
|
||||
}
|
||||
|
||||
// ImportContext imports an OPML file.
|
||||
func (c *Client) ImportContext(ctx context.Context, f io.ReadCloser) error {
|
||||
_, err := c.request.PostFile(ctx, "/v1/import", f)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feed gets a feed.
|
||||
func (c *Client) Feed(feedID int64) (*Feed, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// FeedContext gets a feed.
|
||||
func (c *Client) FeedContext(ctx context.Context, feedID int64) (*Feed, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -362,7 +671,14 @@ func (c *Client) Feed(feedID int64) (*Feed, error) {
|
||||
|
||||
// CreateFeed creates a new feed.
|
||||
func (c *Client) CreateFeed(feedCreationRequest *FeedCreationRequest) (int64, error) {
|
||||
body, err := c.request.Post("/v1/feeds", feedCreationRequest)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CreateFeedContext(ctx, feedCreationRequest)
|
||||
}
|
||||
|
||||
// CreateFeedContext creates a new feed.
|
||||
func (c *Client) CreateFeedContext(ctx context.Context, feedCreationRequest *FeedCreationRequest) (int64, error) {
|
||||
body, err := c.request.Post(ctx, "/v1/feeds", feedCreationRequest)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -382,7 +698,14 @@ func (c *Client) CreateFeed(feedCreationRequest *FeedCreationRequest) (int64, er
|
||||
|
||||
// UpdateFeed updates a feed.
|
||||
func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d", feedID), feedChanges)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateFeedContext(ctx, feedID, feedChanges)
|
||||
}
|
||||
|
||||
// UpdateFeedContext updates a feed.
|
||||
func (c *Client) UpdateFeedContext(ctx context.Context, feedID int64, feedChanges *FeedModificationRequest) (*Feed, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d", feedID), feedChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -396,32 +719,93 @@ func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModificationRequest)
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// ImportFeedEntry imports a single entry into a feed.
|
||||
func (c *Client) ImportFeedEntry(feedID int64, payload any) (int64, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
|
||||
body, err := c.request.Post(
|
||||
ctx,
|
||||
fmt.Sprintf("/v1/feeds/%d/entries/import", feedID),
|
||||
payload,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var response struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(body).Decode(&response); err != nil {
|
||||
return 0, fmt.Errorf("miniflux: json error (%v)", err)
|
||||
}
|
||||
|
||||
return response.ID, nil
|
||||
}
|
||||
|
||||
// MarkFeedAsRead marks all unread entries of the feed as read.
|
||||
func (c *Client) MarkFeedAsRead(feedID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/mark-all-as-read", feedID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.MarkFeedAsReadContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// MarkFeedAsReadContext marks all unread entries of the feed as read.
|
||||
func (c *Client) MarkFeedAsReadContext(ctx context.Context, feedID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d/mark-all-as-read", feedID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshAllFeeds refreshes all feeds.
|
||||
func (c *Client) RefreshAllFeeds() error {
|
||||
_, err := c.request.Put("/v1/feeds/refresh", nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.RefreshAllFeedsContext(ctx)
|
||||
}
|
||||
|
||||
// RefreshAllFeedsContext refreshes all feeds.
|
||||
func (c *Client) RefreshAllFeedsContext(ctx context.Context) error {
|
||||
_, err := c.request.Put(ctx, "/v1/feeds/refresh", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshFeed refreshes a feed.
|
||||
func (c *Client) RefreshFeed(feedID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.RefreshFeedContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// RefreshFeedContext refreshes a feed.
|
||||
func (c *Client) RefreshFeedContext(ctx context.Context, feedID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/feeds/%d/refresh", feedID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteFeed removes a feed.
|
||||
func (c *Client) DeleteFeed(feedID int64) error {
|
||||
return c.request.Delete(fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.DeleteFeedContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// DeleteFeedContext removes a feed.
|
||||
func (c *Client) DeleteFeedContext(ctx context.Context, feedID int64) error {
|
||||
return c.request.Delete(ctx, fmt.Sprintf("/v1/feeds/%d", feedID))
|
||||
}
|
||||
|
||||
// FeedIcon gets a feed icon.
|
||||
func (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d/icon", feedID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedIconContext(ctx, feedID)
|
||||
}
|
||||
|
||||
// FeedIconContext gets a feed icon.
|
||||
func (c *Client) FeedIconContext(ctx context.Context, feedID int64) (*FeedIcon, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d/icon", feedID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -437,7 +821,14 @@ func (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {
|
||||
|
||||
// FeedEntry gets a single feed entry.
|
||||
func (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d/entries/%d", feedID, entryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedEntryContext(ctx, feedID, entryID)
|
||||
}
|
||||
|
||||
// FeedEntryContext gets a single feed entry.
|
||||
func (c *Client) FeedEntryContext(ctx context.Context, feedID, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/feeds/%d/entries/%d", feedID, entryID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -453,7 +844,14 @@ func (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {
|
||||
|
||||
// CategoryEntry gets a single category entry.
|
||||
func (c *Client) CategoryEntry(categoryID, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/categories/%d/entries/%d", categoryID, entryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoryEntryContext(ctx, categoryID, entryID)
|
||||
}
|
||||
|
||||
// CategoryEntryContext gets a single category entry.
|
||||
func (c *Client) CategoryEntryContext(ctx context.Context, categoryID, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/categories/%d/entries/%d", categoryID, entryID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -469,7 +867,14 @@ func (c *Client) CategoryEntry(categoryID, entryID int64) (*Entry, error) {
|
||||
|
||||
// Entry gets a single entry.
|
||||
func (c *Client) Entry(entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d", entryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.EntryContext(ctx, entryID)
|
||||
}
|
||||
|
||||
// EntryContext gets a single entry.
|
||||
func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/entries/%d", entryID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -483,11 +888,18 @@ func (c *Client) Entry(entryID int64) (*Entry, error) {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Entries fetch entries.
|
||||
// Entries fetches entries using the given filter.
|
||||
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.EntriesContext(ctx, filter)
|
||||
}
|
||||
|
||||
// EntriesContext fetches entries.
|
||||
func (c *Client) EntriesContext(ctx context.Context, filter *Filter) (*EntryResultSet, error) {
|
||||
path := buildFilterQueryString("/v1/entries", filter)
|
||||
|
||||
body, err := c.request.Get(path)
|
||||
body, err := c.request.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -501,11 +913,18 @@ func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FeedEntries fetch feed entries.
|
||||
// FeedEntries fetches entries for a feed using the given filter.
|
||||
func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FeedEntriesContext(ctx, feedID, filter)
|
||||
}
|
||||
|
||||
// FeedEntriesContext fetches feed entries.
|
||||
func (c *Client) FeedEntriesContext(ctx context.Context, feedID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
path := buildFilterQueryString(fmt.Sprintf("/v1/feeds/%d/entries", feedID), filter)
|
||||
|
||||
body, err := c.request.Get(path)
|
||||
body, err := c.request.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -519,11 +938,18 @@ func (c *Client) FeedEntries(feedID int64, filter *Filter) (*EntryResultSet, err
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CategoryEntries fetch entries of a category.
|
||||
// CategoryEntries fetches entries for a category using the given filter.
|
||||
func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.CategoryEntriesContext(ctx, categoryID, filter)
|
||||
}
|
||||
|
||||
// CategoryEntriesContext fetches category entries.
|
||||
func (c *Client) CategoryEntriesContext(ctx context.Context, categoryID int64, filter *Filter) (*EntryResultSet, error) {
|
||||
path := buildFilterQueryString(fmt.Sprintf("/v1/categories/%d/entries", categoryID), filter)
|
||||
|
||||
body, err := c.request.Get(path)
|
||||
body, err := c.request.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -539,18 +965,32 @@ func (c *Client) CategoryEntries(categoryID int64, filter *Filter) (*EntryResult
|
||||
|
||||
// UpdateEntries updates the status of a list of entries.
|
||||
func (c *Client) UpdateEntries(entryIDs []int64, status string) error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateEntriesContext(ctx, entryIDs, status)
|
||||
}
|
||||
|
||||
// UpdateEntriesContext updates the status of a list of entries.
|
||||
func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, status string) error {
|
||||
type payload struct {
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
_, err := c.request.Put("/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
|
||||
_, err := c.request.Put(ctx, "/v1/entries", &payload{EntryIDs: entryIDs, Status: status})
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateEntry updates an entry.
|
||||
func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
|
||||
body, err := c.request.Put(fmt.Sprintf("/v1/entries/%d", entryID), entryChanges)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateEntryContext(ctx, entryID, entryChanges)
|
||||
}
|
||||
|
||||
// UpdateEntryContext updates an entry.
|
||||
func (c *Client) UpdateEntryContext(ctx context.Context, entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
|
||||
body, err := c.request.Put(ctx, fmt.Sprintf("/v1/entries/%d", entryID), entryChanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -564,21 +1004,42 @@ func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationReque
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// ToggleBookmark toggles entry bookmark value.
|
||||
func (c *Client) ToggleBookmark(entryID int64) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/bookmark", entryID), nil)
|
||||
// ToggleStarred toggles the starred flag of an entry.
|
||||
func (c *Client) ToggleStarred(entryID int64) error {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.ToggleStarredContext(ctx, entryID)
|
||||
}
|
||||
|
||||
// ToggleStarredContext toggles entry starred value.
|
||||
func (c *Client) ToggleStarredContext(ctx context.Context, entryID int64) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/entries/%d/star", entryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveEntry sends an entry to a third-party service.
|
||||
func (c *Client) SaveEntry(entryID int64) error {
|
||||
_, err := c.request.Post(fmt.Sprintf("/v1/entries/%d/save", entryID), nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.SaveEntryContext(ctx, entryID)
|
||||
}
|
||||
|
||||
// SaveEntryContext sends an entry to a third-party service.
|
||||
func (c *Client) SaveEntryContext(ctx context.Context, entryID int64) error {
|
||||
_, err := c.request.Post(ctx, fmt.Sprintf("/v1/entries/%d/save", entryID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchEntryOriginalContent fetches the original content of an entry using the scraper.
|
||||
func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FetchEntryOriginalContentContext(ctx, entryID)
|
||||
}
|
||||
|
||||
// FetchEntryOriginalContentContext fetches the original content of an entry using the scraper.
|
||||
func (c *Client) FetchEntryOriginalContentContext(ctx context.Context, entryID int64) (string, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -597,7 +1058,14 @@ func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
|
||||
|
||||
// FetchCounters fetches feed counters.
|
||||
func (c *Client) FetchCounters() (*FeedCounters, error) {
|
||||
body, err := c.request.Get("/v1/feeds/counters")
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FetchCountersContext(ctx)
|
||||
}
|
||||
|
||||
// FetchCountersContext fetches feed counters.
|
||||
func (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error) {
|
||||
body, err := c.request.Get(ctx, "/v1/feeds/counters")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -611,15 +1079,29 @@ func (c *Client) FetchCounters() (*FeedCounters, error) {
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FlushHistory changes all entries with the status "read" to "removed".
|
||||
// FlushHistory deletes all entries with the status "read".
|
||||
func (c *Client) FlushHistory() error {
|
||||
_, err := c.request.Put("/v1/flush-history", nil)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.FlushHistoryContext(ctx)
|
||||
}
|
||||
|
||||
// FlushHistoryContext deletes all entries with the status "read".
|
||||
func (c *Client) FlushHistoryContext(ctx context.Context) error {
|
||||
_, err := c.request.Put(ctx, "/v1/flush-history", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Icon fetches a feed icon.
|
||||
func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/icons/%d", iconID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.IconContext(ctx, iconID)
|
||||
}
|
||||
|
||||
// IconContext fetches a feed icon.
|
||||
func (c *Client) IconContext(ctx context.Context, iconID int64) (*FeedIcon, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/icons/%d", iconID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -635,7 +1117,14 @@ func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
|
||||
|
||||
// Enclosure fetches a specific enclosure.
|
||||
func (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/enclosures/%d", enclosureID))
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.EnclosureContext(ctx, enclosureID)
|
||||
}
|
||||
|
||||
// EnclosureContext fetches a specific enclosure.
|
||||
func (c *Client) EnclosureContext(ctx context.Context, enclosureID int64) (*Enclosure, error) {
|
||||
body, err := c.request.Get(ctx, fmt.Sprintf("/v1/enclosures/%d", enclosureID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -651,7 +1140,14 @@ func (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {
|
||||
|
||||
// UpdateEnclosure updates an enclosure.
|
||||
func (c *Client) UpdateEnclosure(enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/enclosures/%d", enclosureID), enclosureUpdate)
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
defer cancel()
|
||||
return c.UpdateEnclosureContext(ctx, enclosureID, enclosureUpdate)
|
||||
}
|
||||
|
||||
// UpdateEnclosureContext updates an enclosure.
|
||||
func (c *Client) UpdateEnclosureContext(ctx context.Context, enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
|
||||
_, err := c.request.Put(ctx, fmt.Sprintf("/v1/enclosures/%d", enclosureID), enclosureUpdate)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -6,7 +6,7 @@ Package client implements a client library for the Miniflux REST API.
|
||||
|
||||
# Examples
|
||||
|
||||
This code snippet fetch the list of users:
|
||||
This example fetches the list of users:
|
||||
|
||||
import (
|
||||
miniflux "miniflux.app/v2/client"
|
||||
@@ -20,7 +20,7 @@ This code snippet fetch the list of users:
|
||||
}
|
||||
fmt.Println(users, err)
|
||||
|
||||
This one discover subscriptions on a website:
|
||||
This example discovers subscriptions on a website:
|
||||
|
||||
subscriptions, err := client.Discover("https://example.org/")
|
||||
if err != nil {
|
||||
|
||||
+119
-65
@@ -10,42 +10,43 @@ import (
|
||||
|
||||
// Entry statuses.
|
||||
const (
|
||||
EntryStatusUnread = "unread"
|
||||
EntryStatusRead = "read"
|
||||
EntryStatusRemoved = "removed"
|
||||
EntryStatusUnread = "unread"
|
||||
EntryStatusRead = "read"
|
||||
)
|
||||
|
||||
// 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 +64,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 +100,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 +115,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"`
|
||||
@@ -130,7 +148,7 @@ type Feed struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
Title string `json:"title"`
|
||||
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
EtagHeader string `json:"etag_header,omitempty"`
|
||||
LastModifiedHeader string `json:"last_modified_header,omitempty"`
|
||||
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
|
||||
@@ -141,9 +159,13 @@ 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"`
|
||||
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Cookie string `json:"cookie"`
|
||||
Username string `json:"username"`
|
||||
@@ -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.
|
||||
@@ -162,16 +185,21 @@ type FeedCreationRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Crawler bool `json:"crawler"`
|
||||
IgnoreEntryUpdates bool `json:"ignore_entry_updates"`
|
||||
Disabled bool `json:"disabled"`
|
||||
IgnoreHTTPCache bool `json:"ignore_http_cache"`
|
||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
||||
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,9 +209,13 @@ 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"`
|
||||
IgnoreEntryUpdates *bool `json:"ignore_entry_updates"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
Cookie *string `json:"cookie"`
|
||||
Username *string `json:"username"`
|
||||
@@ -195,6 +227,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 +340,27 @@ type VersionResponse struct {
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
||||
func SetOptionalField[T any](value T) *T {
|
||||
return &value
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// SetOptionalField returns a pointer to the given value so optional request fields can be marked as set.
|
||||
//
|
||||
//go:fix inline
|
||||
func SetOptionalField[T any](value T) *T {
|
||||
return new(value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client // import "miniflux.app/v2/client"
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Option func(*request)
|
||||
|
||||
// WithAPIKey sets the API key for the client.
|
||||
func WithAPIKey(apiKey string) Option {
|
||||
return func(r *request) {
|
||||
r.apiKey = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
// WithCredentials sets the username and password for the client.
|
||||
func WithCredentials(username, password string) Option {
|
||||
return func(r *request) {
|
||||
r.username = username
|
||||
r.password = password
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets the HTTP client for the client.
|
||||
func WithHTTPClient(client *http.Client) Option {
|
||||
return func(r *request) {
|
||||
r.client = client
|
||||
}
|
||||
}
|
||||
+26
-24
@@ -5,6 +5,7 @@ package client // import "miniflux.app/v2/client"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
|
||||
const (
|
||||
userAgent = "Miniflux Client Library"
|
||||
defaultTimeout = 80
|
||||
defaultTimeout = 80 * time.Second
|
||||
)
|
||||
|
||||
// List of exposed errors.
|
||||
@@ -39,30 +40,36 @@ type request struct {
|
||||
username string
|
||||
password string
|
||||
apiKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (r *request) Get(path string) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodGet, path, nil)
|
||||
func (r *request) Get(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
return r.execute(ctx, http.MethodGet, path, nil)
|
||||
}
|
||||
|
||||
func (r *request) Post(path string, data interface{}) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPost, path, data)
|
||||
func (r *request) Post(ctx context.Context, path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(ctx, http.MethodPost, path, data)
|
||||
}
|
||||
|
||||
func (r *request) PostFile(path string, f io.ReadCloser) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPost, path, f)
|
||||
func (r *request) PostFile(ctx context.Context, path string, f io.ReadCloser) (io.ReadCloser, error) {
|
||||
return r.execute(ctx, http.MethodPost, path, f)
|
||||
}
|
||||
|
||||
func (r *request) Put(path string, data interface{}) (io.ReadCloser, error) {
|
||||
return r.execute(http.MethodPut, path, data)
|
||||
func (r *request) Put(ctx context.Context, path string, data any) (io.ReadCloser, error) {
|
||||
return r.execute(ctx, http.MethodPut, path, data)
|
||||
}
|
||||
|
||||
func (r *request) Delete(path string) error {
|
||||
_, err := r.execute(http.MethodDelete, path, nil)
|
||||
func (r *request) Delete(ctx context.Context, path string) error {
|
||||
_, err := r.execute(ctx, http.MethodDelete, path, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *request) execute(method, path string, data interface{}) (io.ReadCloser, error) {
|
||||
func (r *request) execute(
|
||||
ctx context.Context,
|
||||
method string,
|
||||
path string,
|
||||
data any,
|
||||
) (io.ReadCloser, error) {
|
||||
if r.endpoint == "" {
|
||||
return nil, ErrEmptyEndpoint
|
||||
}
|
||||
@@ -75,12 +82,13 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request := &http.Request{
|
||||
URL: u,
|
||||
Method: method,
|
||||
Header: r.buildHeaders(),
|
||||
request, err := http.NewRequestWithContext(ctx, method, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header = r.buildHeaders()
|
||||
|
||||
if r.username != "" && r.password != "" {
|
||||
request.SetBasicAuth(r.username, r.password)
|
||||
}
|
||||
@@ -94,7 +102,7 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
|
||||
}
|
||||
}
|
||||
|
||||
client := r.buildClient()
|
||||
client := r.client
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -143,12 +151,6 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
|
||||
return response.Body, nil
|
||||
}
|
||||
|
||||
func (r *request) buildClient() http.Client {
|
||||
return http.Client{
|
||||
Timeout: defaultTimeout * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *request) buildHeaders() http.Header {
|
||||
headers := make(http.Header)
|
||||
headers.Add("User-Agent", userAgent)
|
||||
@@ -160,7 +162,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,14 +19,15 @@ services:
|
||||
# healthcheck:
|
||||
# test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
- POSTGRES_PASSWORD=secret
|
||||
- POSTGRES_DB=miniflux
|
||||
volumes:
|
||||
- miniflux-db:/var/lib/postgresql/data
|
||||
- miniflux-db:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
|
||||
@@ -25,13 +25,13 @@ services:
|
||||
- ADMIN_PASSWORD=test123
|
||||
- BASE_URL=https://miniflux.example.org
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=miniflux
|
||||
- POSTGRES_PASSWORD=secret
|
||||
volumes:
|
||||
- miniflux-db:/var/lib/postgresql/data
|
||||
- miniflux-db:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
|
||||
@@ -37,13 +37,13 @@ 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
|
||||
- POSTGRES_PASSWORD=secret
|
||||
volumes:
|
||||
- miniflux-db:/var/lib/postgresql/data
|
||||
- miniflux-db:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
module miniflux.app/v2
|
||||
|
||||
// +heroku goVersion go1.23
|
||||
// When changing version here don't forget to also upgrade CONTRIBUTING.md
|
||||
|
||||
// +heroku goVersion go1.26
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.2
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/coreos/go-oidc/v3 v3.13.0
|
||||
github.com/go-webauthn/webauthn v0.12.2
|
||||
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.21.1
|
||||
github.com/tdewolff/minify/v2 v2.22.4
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/image v0.25.0
|
||||
golang.org/x/net v0.38.0
|
||||
golang.org/x/oauth2 v0.28.0
|
||||
golang.org/x/term v0.30.0
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/andybalholm/brotli v1.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/go-webauthn/webauthn v0.17.3
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tdewolff/minify/v2 v2.24.13
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/image v0.40.0
|
||||
golang.org/x/net v0.54.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.1.19 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/google/go-tpm v0.9.3 // indirect
|
||||
github.com/go-webauthn/x v0.2.5 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // 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.5 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/kr/text v0.2.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.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.21 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.12 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/protobuf v1.36.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
@@ -1,80 +1,97 @@
|
||||
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
|
||||
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
|
||||
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.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/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.13.0 h1:M66zd0pcc5VxvBNM4pB331Wrsanby+QomQYjN8HamW8=
|
||||
github.com/coreos/go-oidc/v3 v3.13.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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.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.12.2 h1:yLaNPgBUEXDQtWnOjhsGhMMCEWbXwjg/aNkC8riJQI8=
|
||||
github.com/go-webauthn/webauthn v0.12.2/go.mod h1:Q8SZPPj4sZ469fNTcQXxRpzJOdb30jQrn/36FX8jilA=
|
||||
github.com/go-webauthn/x v0.1.19 h1:IUfdHiBRoTdujpBA/14qbrMXQ3LGzYe/PRGWdZcmudg=
|
||||
github.com/go-webauthn/x v0.1.19/go.mod h1:C5arLuTQ3pVHKPw89v7CDGnqAZSZJj+4Jnr40dsn7tk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
|
||||
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
|
||||
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
|
||||
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
|
||||
github.com/google/go-tpm v0.9.3/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.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
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.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
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/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
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.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||
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.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/stretchr/testify v1.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.22.4 h1:0/8K2fheOuYr5B4e5oCE1hGBVX6DQHLP0EGzdsDlYeg=
|
||||
github.com/tdewolff/minify/v2 v2.22.4/go.mod h1:K/R8TT7aivpcU8QCNUU1UdR6etfnFPr7L11TO/X7shk=
|
||||
github.com/tdewolff/parse/v2 v2.7.21 h1:OCuPFtGr4mXdnfKikQlUb0n654ROJANhBqCk+wioJ/A=
|
||||
github.com/tdewolff/parse/v2 v2.7.21/go.mod h1:I7TXO37t3aSG9SlPUBefAhgIF8nt7yYUwVGgETIoBcA=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
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.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
|
||||
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
|
||||
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
|
||||
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
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=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
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=
|
||||
@@ -89,10 +106,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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
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=
|
||||
@@ -111,8 +128,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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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=
|
||||
@@ -122,8 +139,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.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
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 +150,8 @@ 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/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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 +159,10 @@ 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.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+59
-73
@@ -5,87 +5,73 @@ package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/version"
|
||||
"miniflux.app/v2/internal/worker"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
store *storage.Storage
|
||||
pool *worker.Pool
|
||||
router *mux.Router
|
||||
store *storage.Storage
|
||||
pool *worker.Pool
|
||||
}
|
||||
|
||||
// Serve declares API routes for the application.
|
||||
func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
||||
handler := &handler{store, pool, router}
|
||||
|
||||
sr := router.PathPrefix("/v1").Subrouter()
|
||||
// NewHandler returns an http.Handler that handles API v1 calls.
|
||||
// The returned handler expects the base path to be stripped from the request URL.
|
||||
func NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {
|
||||
handler := &handler{store: store, pool: pool}
|
||||
middleware := newMiddleware(store)
|
||||
sr.Use(middleware.handleCORS)
|
||||
sr.Use(middleware.apiKeyAuth)
|
||||
sr.Use(middleware.basicAuth)
|
||||
sr.Methods(http.MethodOptions)
|
||||
sr.HandleFunc("/users", handler.createUser).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/users", handler.users).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/users/{userID:[0-9]+}", handler.userByID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/users/{userID:[0-9]+}", handler.updateUser).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/users/{userID:[0-9]+}", handler.removeUser).Methods(http.MethodDelete)
|
||||
sr.HandleFunc("/users/{userID:[0-9]+}/mark-all-as-read", handler.markUserAsRead).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/users/{username}", handler.userByUsername).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/me", handler.currentUser).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/categories", handler.createCategory).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/categories", handler.getCategories).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/categories/{categoryID}", handler.updateCategory).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/categories/{categoryID}", handler.removeCategory).Methods(http.MethodDelete)
|
||||
sr.HandleFunc("/categories/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/categories/{categoryID}/feeds", handler.getCategoryFeeds).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/categories/{categoryID}/refresh", handler.refreshCategory).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/categories/{categoryID}/entries", handler.getCategoryEntries).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/categories/{categoryID}/entries/{entryID}", handler.getCategoryEntry).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/discover", handler.discoverSubscriptions).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/feeds", handler.createFeed).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/feeds", handler.getFeeds).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/feeds/counters", handler.fetchCounters).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/feeds/refresh", handler.refreshAllFeeds).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/feeds/{feedID}/refresh", handler.refreshFeed).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/feeds/{feedID}", handler.getFeed).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/feeds/{feedID}", handler.updateFeed).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/feeds/{feedID}", handler.removeFeed).Methods(http.MethodDelete)
|
||||
sr.HandleFunc("/feeds/{feedID}/icon", handler.getIconByFeedID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/feeds/{feedID}/mark-all-as-read", handler.markFeedAsRead).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/export", handler.exportFeeds).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/import", handler.importFeeds).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/feeds/{feedID}/entries", handler.getFeedEntries).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/feeds/{feedID}/entries/{entryID}", handler.getFeedEntry).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/entries", handler.getEntries).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/entries", handler.setEntryStatus).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}", handler.getEntry).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/entries/{entryID}", handler.updateEntry).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleBookmark).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/entries/{entryID}/save", handler.saveEntry).Methods(http.MethodPost)
|
||||
sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/flush-history", handler.flushHistory).Methods(http.MethodPut, http.MethodDelete)
|
||||
sr.HandleFunc("/icons/{iconID}", handler.getIconByIconID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/enclosures/{enclosureID}", handler.getEnclosureByID).Methods(http.MethodGet)
|
||||
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)
|
||||
}
|
||||
|
||||
func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
json.OK(w, r, &versionResponse{
|
||||
Version: version.Version,
|
||||
Commit: version.Commit,
|
||||
BuildDate: version.BuildDate,
|
||||
GoVersion: runtime.Version(),
|
||||
Compiler: runtime.Compiler,
|
||||
Arch: runtime.GOARCH,
|
||||
OS: runtime.GOOS,
|
||||
})
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/users", handler.createUserHandler)
|
||||
mux.HandleFunc("GET /v1/users", handler.usersHandler)
|
||||
mux.HandleFunc("GET /v1/users/{identifier}", handler.dispatchUserLookupHandler)
|
||||
mux.HandleFunc("PUT /v1/users/{userID}", handler.updateUserHandler)
|
||||
mux.HandleFunc("DELETE /v1/users/{userID}", handler.removeUserHandler)
|
||||
mux.HandleFunc("PUT /v1/users/{userID}/mark-all-as-read", handler.markUserAsReadHandler)
|
||||
mux.HandleFunc("GET /v1/me", handler.currentUserHandler)
|
||||
mux.HandleFunc("POST /v1/categories", handler.createCategoryHandler)
|
||||
mux.HandleFunc("GET /v1/categories", handler.getCategoriesHandler)
|
||||
mux.HandleFunc("PUT /v1/categories/{categoryID}", handler.updateCategoryHandler)
|
||||
mux.HandleFunc("DELETE /v1/categories/{categoryID}", handler.removeCategoryHandler)
|
||||
mux.HandleFunc("PUT /v1/categories/{categoryID}/mark-all-as-read", handler.markCategoryAsReadHandler)
|
||||
mux.HandleFunc("GET /v1/categories/{categoryID}/feeds", handler.getCategoryFeedsHandler)
|
||||
mux.HandleFunc("PUT /v1/categories/{categoryID}/refresh", handler.refreshCategoryHandler)
|
||||
mux.HandleFunc("GET /v1/categories/{categoryID}/entries", handler.getCategoryEntriesHandler)
|
||||
mux.HandleFunc("GET /v1/categories/{categoryID}/entries/{entryID}", handler.getCategoryEntryHandler)
|
||||
mux.HandleFunc("POST /v1/discover", handler.discoverSubscriptionsHandler)
|
||||
mux.HandleFunc("POST /v1/feeds", handler.createFeedHandler)
|
||||
mux.HandleFunc("GET /v1/feeds", handler.getFeedsHandler)
|
||||
mux.HandleFunc("GET /v1/feeds/counters", handler.fetchCountersHandler)
|
||||
mux.HandleFunc("PUT /v1/feeds/refresh", handler.refreshAllFeedsHandler)
|
||||
mux.HandleFunc("PUT /v1/feeds/{feedID}/refresh", handler.refreshFeedHandler)
|
||||
mux.HandleFunc("GET /v1/feeds/{feedID}", handler.getFeedHandler)
|
||||
mux.HandleFunc("PUT /v1/feeds/{feedID}", handler.updateFeedHandler)
|
||||
mux.HandleFunc("DELETE /v1/feeds/{feedID}", handler.removeFeedHandler)
|
||||
mux.HandleFunc("GET /v1/feeds/{feedID}/icon", handler.getIconByFeedIDHandler)
|
||||
mux.HandleFunc("PUT /v1/feeds/{feedID}/mark-all-as-read", handler.markFeedAsReadHandler)
|
||||
mux.HandleFunc("GET /v1/export", handler.exportFeedsHandler)
|
||||
mux.HandleFunc("POST /v1/import", handler.importFeedsHandler)
|
||||
mux.HandleFunc("GET /v1/feeds/{feedID}/entries", handler.getFeedEntriesHandler)
|
||||
mux.HandleFunc("POST /v1/feeds/{feedID}/entries/import", handler.importFeedEntryHandler)
|
||||
mux.HandleFunc("GET /v1/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler)
|
||||
mux.HandleFunc("GET /v1/entries", handler.getEntriesHandler)
|
||||
mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusHandler)
|
||||
mux.HandleFunc("GET /v1/entries/{entryID}", handler.getEntryHandler)
|
||||
mux.HandleFunc("PUT /v1/entries/{entryID}", handler.updateEntryHandler)
|
||||
mux.HandleFunc("PUT /v1/entries/{entryID}/bookmark", handler.toggleStarredHandler)
|
||||
mux.HandleFunc("PUT /v1/entries/{entryID}/star", handler.toggleStarredHandler)
|
||||
mux.HandleFunc("POST /v1/entries/{entryID}/save", handler.saveEntryHandler)
|
||||
mux.HandleFunc("GET /v1/entries/{entryID}/fetch-content", handler.fetchContentHandler)
|
||||
mux.HandleFunc("PUT /v1/flush-history", handler.flushHistoryHandler)
|
||||
mux.HandleFunc("DELETE /v1/flush-history", handler.flushHistoryHandler)
|
||||
mux.HandleFunc("GET /v1/icons/{iconID}", handler.getIconByIconIDHandler)
|
||||
mux.HandleFunc("GET /v1/enclosures/{enclosureID}", handler.getEnclosureByIDHandler)
|
||||
mux.HandleFunc("PUT /v1/enclosures/{enclosureID}", handler.updateEnclosureByIDHandler)
|
||||
mux.HandleFunc("GET /v1/integrations/status", handler.getIntegrationsStatusHandler)
|
||||
mux.HandleFunc("GET /v1/version", handler.versionHandler)
|
||||
mux.HandleFunc("POST /v1/api-keys", handler.createAPIKeyHandler)
|
||||
mux.HandleFunc("GET /v1/api-keys", handler.getAPIKeysHandler)
|
||||
mux.HandleFunc("DELETE /v1/api-keys/{apiKeyID}", handler.deleteAPIKeyHandler)
|
||||
|
||||
return middleware.withCORSHeaders(middleware.validateAPIKeyAuth(middleware.validateBasicAuth(mux)))
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
miniflux "miniflux.app/v2/client"
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
const skipIntegrationTestsMessage = `Set TEST_MINIFLUX_* environment variables to run the API integration tests`
|
||||
@@ -579,7 +580,7 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
Theme: miniflux.SetOptionalField("dark_serif"),
|
||||
Theme: new("dark_serif"),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -608,7 +609,7 @@ func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
|
||||
ExternalFontHosts: new(" fonts.example.org "),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -637,7 +638,7 @@ func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T)
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
|
||||
ExternalFontHosts: new("'self' *"),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
|
||||
@@ -661,7 +662,7 @@ func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
|
||||
CustomJS: new("alert('Hello, World!');"),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -690,7 +691,7 @@ func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
Theme: miniflux.SetOptionalField("invalid_theme"),
|
||||
Theme: new("invalid_theme"),
|
||||
}
|
||||
|
||||
_, err = regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
@@ -720,7 +721,7 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
Theme: miniflux.SetOptionalField("dark_serif"),
|
||||
Theme: new("dark_serif"),
|
||||
}
|
||||
|
||||
_, err = regularUserClient.UpdateUser(adminUser.ID, userUpdateRequest)
|
||||
@@ -729,6 +730,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 +935,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 +980,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 +1060,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: new("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: new(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: new(false),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedCategory.ID != newCategory.ID {
|
||||
t.Errorf(`Invalid categoryID, got %d`, 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 +1263,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 +1282,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) {
|
||||
@@ -1328,7 +1612,7 @@ func TestUpdateFeedEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
feedUpdateRequest := &miniflux.FeedModificationRequest{
|
||||
FeedURL: miniflux.SetOptionalField("https://example.org/feed.xml"),
|
||||
FeedURL: new("https://example.org/feed.xml"),
|
||||
}
|
||||
|
||||
updatedFeed, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest)
|
||||
@@ -1369,7 +1653,7 @@ func TestCannotHaveDuplicateFeedWhenUpdatingFeed(t *testing.T) {
|
||||
}
|
||||
|
||||
feedUpdateRequest := &miniflux.FeedModificationRequest{
|
||||
FeedURL: miniflux.SetOptionalField(testConfig.testFeedURL),
|
||||
FeedURL: new(testConfig.testFeedURL),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
|
||||
@@ -1401,7 +1685,7 @@ func TestUpdateFeedWithInvalidCategory(t *testing.T) {
|
||||
}
|
||||
|
||||
feedUpdateRequest := &miniflux.FeedModificationRequest{
|
||||
CategoryID: miniflux.SetOptionalField(int64(123456789)),
|
||||
CategoryID: new(int64(123456789)),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil {
|
||||
@@ -2098,7 +2382,6 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
feedIDEntry, err := regularUserClient.Feed(feedID)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -2377,8 +2660,8 @@ func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
entryUpdateRequest := &miniflux.EntryModificationRequest{
|
||||
Title: miniflux.SetOptionalField("New title"),
|
||||
Content: miniflux.SetOptionalField("New content"),
|
||||
Title: new("New title"),
|
||||
Content: new("New content"),
|
||||
}
|
||||
|
||||
updatedEntry, err := regularUserClient.UpdateEntry(result.Entries[0].ID, entryUpdateRequest)
|
||||
@@ -2408,7 +2691,7 @@ func TestUpdateEntryEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
func TestToggleStarredEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
@@ -2436,7 +2719,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
if err := regularUserClient.ToggleBookmark(result.Entries[0].ID); err != nil {
|
||||
if err := regularUserClient.ToggleStarred(result.Entries[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2446,7 +2729,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
if !entry.Starred {
|
||||
t.Fatalf(`The entry should be bookmarked`)
|
||||
t.Fatalf(`The entry should be starred`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2591,13 +2874,56 @@ func TestFlushHistoryEndpoint(t *testing.T) {
|
||||
if readEntries.Total != 0 {
|
||||
t.Fatalf(`Invalid total, got %d`, readEntries.Total)
|
||||
}
|
||||
}
|
||||
|
||||
removedEntries, err := regularUserClient.Entries(&miniflux.Filter{Status: miniflux.EntryStatusRemoved})
|
||||
func TestImportFeedEntryEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
client := miniflux.NewClient(
|
||||
testConfig.testBaseURL,
|
||||
testConfig.testAdminUsername,
|
||||
testConfig.testAdminPassword,
|
||||
)
|
||||
|
||||
// Create a feed
|
||||
feedID, err := client.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.DeleteFeed(feedID)
|
||||
|
||||
payload := map[string]any{
|
||||
"title": "Imported Entry",
|
||||
"url": "https://example.org/imported-entry",
|
||||
"content": "Hello world",
|
||||
"external_id": "integration-test-entry-1",
|
||||
"status": model.EntryStatusUnread,
|
||||
"starred": false,
|
||||
"published_at": 0,
|
||||
}
|
||||
|
||||
// First import
|
||||
firstID, err := client.ImportFeedEntry(feedID, payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if removedEntries.Total != 2 {
|
||||
t.Fatalf(`Invalid total, got %d`, removedEntries.Total)
|
||||
if firstID == 0 {
|
||||
t.Fatal("expected non-zero entry ID on first import")
|
||||
}
|
||||
|
||||
// Second import (same payload)
|
||||
secondID, err := client.ImportFeedEntry(feedID, payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if secondID != firstID {
|
||||
t.Fatalf("expected same entry ID on re-import, got %d and %d", firstID, secondID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) createAPIKeyHandler(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 {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateAPIKeyCreation(h.store, userID, &apiKeyCreationRequest); validationErr != nil {
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := h.store.CreateAPIKey(userID, apiKeyCreationRequest.Description)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSONCreated(w, r, apiKey)
|
||||
}
|
||||
|
||||
func (h *handler) getAPIKeysHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
apiKeys, err := h.store.APIKeys(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, r, apiKeys)
|
||||
}
|
||||
|
||||
func (h *handler) deleteAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
apiKeyID := request.RouteInt64Param(r, "apiKeyID")
|
||||
if apiKeyID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid API key ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteAPIKey(userID, apiKeyID); err != nil {
|
||||
if errors.Is(err, storage.ErrAPIKeyNotFound) {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
func TestNewHandlerHandlesOptionsRequests(t *testing.T) {
|
||||
handler := NewHandler(nil, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodOptions, "/v1/users", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
if got := w.Code; got != http.StatusNoContent {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusNoContent)
|
||||
}
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||
t.Fatalf(`Unexpected Access-Control-Allow-Origin header, got %q`, got)
|
||||
}
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "GET, POST, PUT, DELETE, OPTIONS" {
|
||||
t.Fatalf(`Unexpected Access-Control-Allow-Methods header, got %q`, got)
|
||||
}
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Headers"); got != "X-Auth-Token, Authorization, Content-Type, Accept" {
|
||||
t.Fatalf(`Unexpected Access-Control-Allow-Headers header, got %q`, got)
|
||||
}
|
||||
|
||||
if got := w.Header().Get("Access-Control-Max-Age"); got != "3600" {
|
||||
t.Fatalf(`Unexpected Access-Control-Max-Age header, got %q`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionHandler(t *testing.T) {
|
||||
h := &handler{}
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/version", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.versionHandler(w, r)
|
||||
|
||||
if got := w.Code; got != http.StatusOK {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusOK)
|
||||
}
|
||||
|
||||
if got := w.Header().Get("Content-Type"); got != "application/json" {
|
||||
t.Fatalf(`Unexpected Content-Type header, got %q`, got)
|
||||
}
|
||||
|
||||
var responseBody versionResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&responseBody); err != nil {
|
||||
t.Fatalf("Unexpected JSON decoding error: %v", err)
|
||||
}
|
||||
|
||||
if responseBody.Version != version.Version {
|
||||
t.Fatalf(`Unexpected version, got %q instead of %q`, responseBody.Version, version.Version)
|
||||
}
|
||||
|
||||
if responseBody.Commit != version.Commit {
|
||||
t.Fatalf(`Unexpected commit, got %q instead of %q`, responseBody.Commit, version.Commit)
|
||||
}
|
||||
|
||||
if responseBody.BuildDate != version.BuildDate {
|
||||
t.Fatalf(`Unexpected build date, got %q instead of %q`, responseBody.BuildDate, version.BuildDate)
|
||||
}
|
||||
|
||||
if responseBody.GoVersion != runtime.Version() {
|
||||
t.Fatalf(`Unexpected Go version, got %q instead of %q`, responseBody.GoVersion, runtime.Version())
|
||||
}
|
||||
|
||||
if responseBody.Compiler != runtime.Compiler {
|
||||
t.Fatalf(`Unexpected compiler, got %q instead of %q`, responseBody.Compiler, runtime.Compiler)
|
||||
}
|
||||
|
||||
if responseBody.Arch != runtime.GOARCH {
|
||||
t.Fatalf(`Unexpected architecture, got %q instead of %q`, responseBody.Arch, runtime.GOARCH)
|
||||
}
|
||||
|
||||
if responseBody.OS != runtime.GOOS {
|
||||
t.Fatalf(`Unexpected OS, got %q instead of %q`, responseBody.OS, runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHandlerSupportsBasePathStripping(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
prefix string
|
||||
path string
|
||||
}{
|
||||
{name: "empty base path", prefix: "", path: "/v1/users"},
|
||||
{name: "non empty base path", prefix: "/base", path: "/base/v1/users"},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
handler := http.StripPrefix(scenario.prefix, NewHandler(nil, nil))
|
||||
|
||||
r := httptest.NewRequest(http.MethodOptions, scenario.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
if got := w.Code; got != http.StatusNoContent {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusNoContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
// 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"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
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 {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.store.CreateCategory(userID, &categoryRequest)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, category)
|
||||
}
|
||||
|
||||
func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
|
||||
category, err := h.store.Category(userID, categoryID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var categoryRequest model.CategoryRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&categoryRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
categoryRequest.Patch(category)
|
||||
err = h.store.UpdateCategory(category)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, category)
|
||||
}
|
||||
|
||||
func (h *handler) markCategoryAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
|
||||
category, err := h.store.Category(userID, categoryID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err = h.store.MarkCategoryAsRead(userID, categoryID, time.Now()); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getCategories(w http.ResponseWriter, r *http.Request) {
|
||||
var categories model.Categories
|
||||
var err error
|
||||
includeCounts := request.QueryStringParam(r, "counts", "false")
|
||||
|
||||
if includeCounts == "true" {
|
||||
categories, err = h.store.CategoriesWithFeedCount(request.UserID(r))
|
||||
} else {
|
||||
categories, err = h.store.Categories(request.UserID(r))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
json.OK(w, r, categories)
|
||||
}
|
||||
|
||||
func (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
|
||||
if !h.store.CategoryIDExists(userID, categoryID) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.RemoveCategory(userID, categoryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
|
||||
batchBuilder := h.store.NewBatchBuilder()
|
||||
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithUserID(userID)
|
||||
batchBuilder.WithCategoryID(categoryID)
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info(
|
||||
"Triggered a manual refresh of all feeds for a given category from the API",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("category_id", categoryID),
|
||||
slog.Int("nb_jobs", len(jobs)),
|
||||
)
|
||||
|
||||
go h.pool.Push(jobs)
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// 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"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) createCategoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
var categoryCreationRequest model.CategoryCreationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&categoryCreationRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateCategoryCreation(h.store, userID, &categoryCreationRequest); validationErr != nil {
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.store.CreateCategory(userID, &categoryCreationRequest)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSONCreated(w, r, category)
|
||||
}
|
||||
|
||||
func (h *handler) updateCategoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.store.Category(userID, categoryID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var categoryModificationRequest model.CategoryModificationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&categoryModificationRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryModificationRequest); validationErr != nil {
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
categoryModificationRequest.Patch(category)
|
||||
|
||||
if err := h.store.UpdateCategory(category); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSONCreated(w, r, category)
|
||||
}
|
||||
|
||||
func (h *handler) markCategoryAsReadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.store.Category(userID, categoryID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err = h.store.MarkCategoryAsRead(userID, categoryID, time.Now()); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getCategoriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var categories model.Categories
|
||||
var err error
|
||||
includeCounts := request.QueryStringParam(r, "counts", "false")
|
||||
|
||||
if includeCounts == "true" {
|
||||
user, userErr := h.store.UserByID(request.UserID(r))
|
||||
if userErr != nil {
|
||||
response.JSONServerError(w, r, userErr)
|
||||
return
|
||||
}
|
||||
categories, err = h.store.CategoriesWithFeedCount(user.ID, user.CategoriesSortingOrder)
|
||||
} else {
|
||||
categories, err = h.store.Categories(request.UserID(r))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, r, categories)
|
||||
}
|
||||
|
||||
func (h *handler) removeCategoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.store.CategoryIDExists(userID, categoryID) {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.RemoveCategory(userID, categoryID); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) refreshCategoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
batchBuilder := h.store.NewBatchBuilder()
|
||||
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
|
||||
batchBuilder.WithoutDisabledFeeds()
|
||||
batchBuilder.WithUserID(userID)
|
||||
batchBuilder.WithCategoryID(categoryID)
|
||||
batchBuilder.WithNextCheckExpired()
|
||||
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
|
||||
|
||||
jobs, err := batchBuilder.FetchJobs()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info(
|
||||
"Triggered a manual refresh of all feeds for a given category from the API",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("category_id", categoryID),
|
||||
slog.Int("nb_jobs", len(jobs)),
|
||||
)
|
||||
|
||||
go h.pool.Push(jobs)
|
||||
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
@@ -5,75 +5,85 @@ package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) getEnclosureByID(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getEnclosureByIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
enclosureID := request.RouteInt64Param(r, "enclosureID")
|
||||
if enclosureID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid enclosure ID"))
|
||||
return
|
||||
}
|
||||
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if enclosure == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if enclosure.UserID != userID {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.ProxifyEnclosureURL(h.router)
|
||||
enclosure.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
json.OK(w, r, enclosure)
|
||||
response.JSON(w, r, enclosure)
|
||||
}
|
||||
|
||||
func (h *handler) updateEnclosureByID(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) updateEnclosureByIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
enclosureID := request.RouteInt64Param(r, "enclosureID")
|
||||
if enclosureID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid enclosure ID"))
|
||||
return
|
||||
}
|
||||
|
||||
var enclosureUpdateRequest model.EnclosureUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&enclosureUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEnclosureUpdateRequest(&enclosureUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if enclosure == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if enclosure.UserID != userID {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.MediaProgression = enclosureUpdateRequest.MediaProgression
|
||||
if err := h.store.UpdateEnclosure(enclosure); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
@@ -10,13 +10,16 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/integration"
|
||||
"miniflux.app/v2/internal/mediaproxy"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/processor"
|
||||
"miniflux.app/v2/internal/reader/readingtime"
|
||||
"miniflux.app/v2/internal/reader/sanitizer"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
@@ -24,25 +27,33 @@ import (
|
||||
func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b *storage.EntryQueryBuilder) {
|
||||
entry, err := b.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content)
|
||||
entry.Enclosures.ProxifyEnclosureURL(config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
|
||||
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router)
|
||||
|
||||
json.OK(w, r, entry)
|
||||
response.JSON(w, r, entry)
|
||||
}
|
||||
|
||||
func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithFeedID(feedID)
|
||||
@@ -51,9 +62,18 @@ func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithCategoryID(categoryID)
|
||||
@@ -62,25 +82,39 @@ func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
|
||||
h.getEntryFromBuilder(w, r, builder)
|
||||
}
|
||||
|
||||
func (h *handler) getFeedEntries(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getFeedEntriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
h.findEntries(w, r, feedID, 0)
|
||||
}
|
||||
|
||||
func (h *handler) getCategoryEntries(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getCategoryEntriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
h.findEntries(w, r, 0, categoryID)
|
||||
}
|
||||
|
||||
func (h *handler) getEntries(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getEntriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
h.findEntries(w, r, 0, 0)
|
||||
}
|
||||
|
||||
@@ -88,40 +122,40 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
statuses := request.QueryStringParamList(r, "status")
|
||||
for _, status := range statuses {
|
||||
if err := validator.ValidateEntryStatus(status); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
order := request.QueryStringParam(r, "order", model.DefaultSortingOrder)
|
||||
if err := validator.ValidateEntryOrder(order); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
direction := request.QueryStringParam(r, "direction", model.DefaultSortingDirection)
|
||||
if err := validator.ValidateDirection(direction); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
limit := request.QueryIntParam(r, "limit", 100)
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
if err := validator.ValidateRange(offset, limit); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
categoryID = request.QueryInt64Param(r, "category_id", categoryID)
|
||||
if categoryID > 0 && !h.store.CategoryIDExists(userID, categoryID) {
|
||||
json.BadRequest(w, r, errors.New("invalid category ID"))
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
feedID = request.QueryInt64Param(r, "feed_id", feedID)
|
||||
if feedID > 0 && !h.store.FeedExists(userID, feedID) {
|
||||
json.BadRequest(w, r, errors.New("invalid feed ID"))
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -147,169 +181,292 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
|
||||
configureFilters(builder, r)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
entries, count, err := builder.GetEntriesWithCount()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range entries {
|
||||
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entries[i].Content)
|
||||
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entries[i].Content)
|
||||
}
|
||||
|
||||
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
|
||||
response.JSON(w, r, &entriesResponse{Total: count, Entries: entries})
|
||||
}
|
||||
|
||||
func (h *handler) setEntryStatus(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) setEntryStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) toggleBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) toggleStarredHandler(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if err := h.store.ToggleBookmark(request.UserID(r), entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
if err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) saveEntry(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
if !h.store.HasSaveEntry(request.UserID(r)) {
|
||||
json.BadRequest(w, r, errors.New("no third-party integration enabled"))
|
||||
response.JSONBadRequest(w, r, errors.New("no third-party integration enabled"))
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.store.Integration(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
go integration.SendEntry(entry, settings)
|
||||
|
||||
json.Accepted(w, r)
|
||||
response.JSONAccepted(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var entryUpdateRequest model.EntryUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&entryUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntryModification(&entryUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
loggedUserID := request.UserID(r)
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
|
||||
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
|
||||
entryBuilder.WithEntryID(entryID)
|
||||
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := entryBuilder.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(loggedUserID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if entryUpdateRequest.Content != nil {
|
||||
sanitizedContent := sanitizer.SanitizeHTML(entry.URL, *entryUpdateRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
|
||||
entryUpdateRequest.Content = &sanitizedContent
|
||||
}
|
||||
|
||||
entryUpdateRequest.Patch(entry)
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
|
||||
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, entry)
|
||||
response.JSONCreated(w, r, entry)
|
||||
}
|
||||
|
||||
func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) importFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID <= 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
response.JSONBadRequest(w, r, errors.New("feed does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
var importRequest entryImportRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&importRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if importRequest.URL == "" {
|
||||
response.JSONBadRequest(w, r, errors.New("url is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if importRequest.Status == "" {
|
||||
importRequest.Status = model.EntryStatusRead
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntryStatus(importRequest.Status); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entry := model.NewEntry()
|
||||
entry.URL = importRequest.URL
|
||||
entry.CommentsURL = importRequest.CommentsURL
|
||||
entry.Author = importRequest.Author
|
||||
entry.Tags = importRequest.Tags
|
||||
|
||||
if importRequest.PublishedAt > 0 {
|
||||
entry.Date = time.Unix(importRequest.PublishedAt, 0).UTC()
|
||||
} else {
|
||||
entry.Date = time.Now().UTC()
|
||||
}
|
||||
|
||||
if importRequest.Title == "" {
|
||||
entry.Title = entry.URL
|
||||
} else {
|
||||
entry.Title = importRequest.Title
|
||||
}
|
||||
|
||||
hashInput := importRequest.ExternalID
|
||||
if hashInput == "" {
|
||||
hashInput = importRequest.URL
|
||||
}
|
||||
entry.Hash = crypto.HashFromBytes([]byte(hashInput))
|
||||
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if importRequest.Content != "" {
|
||||
entry.Content = sanitizer.SanitizeHTML(entry.URL, importRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
|
||||
}
|
||||
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
|
||||
created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
|
||||
if errors.Is(err, storage.ErrEntryTombstoned) {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, importRequest.Status); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
entry.Status = importRequest.Status
|
||||
|
||||
if importRequest.Starred {
|
||||
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
entry.Starred = true
|
||||
}
|
||||
|
||||
if created {
|
||||
response.JSONCreated(w, r, entryIDResponse{ID: entry.ID})
|
||||
} else {
|
||||
response.JSON(w, r, entryIDResponse{ID: entry.ID})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUserID := request.UserID(r)
|
||||
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
if entryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid entry ID"))
|
||||
return
|
||||
}
|
||||
|
||||
entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
|
||||
entryBuilder.WithEntryID(entryID)
|
||||
entryBuilder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := entryBuilder.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(loggedUserID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -317,27 +474,35 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
|
||||
feedBuilder.WithFeedID(entry.FeedID)
|
||||
feed, err := feedBuilder.GetFeed()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := processor.ProcessEntryWebPage(feed, entry, user); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, map[string]string{"content": entry.Content})
|
||||
shouldUpdateContent := request.QueryBoolParam(r, "update_content", false)
|
||||
if shouldUpdateContent {
|
||||
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.JSON(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content), ReadingTime: entry.ReadingTime})
|
||||
}
|
||||
|
||||
func (h *handler) flushHistory(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) flushHistoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUserID := request.UserID(r)
|
||||
go h.store.FlushHistory(loggedUserID)
|
||||
json.Accepted(w, r)
|
||||
response.JSONAccepted(w, r)
|
||||
}
|
||||
|
||||
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
|
||||
@@ -5,24 +5,25 @@ package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/model"
|
||||
feedHandler "miniflux.app/v2/internal/reader/handler"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) createFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) createFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
var feedCreationRequest model.FeedCreationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&feedCreationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -30,45 +31,49 @@ func (h *handler) createFeed(w http.ResponseWriter, r *http.Request) {
|
||||
if feedCreationRequest.CategoryID == 0 {
|
||||
category, err := h.store.FirstCategory(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
feedCreationRequest.CategoryID = category.ID
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateFeedCreation(h.store, userID, &feedCreationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
feed, localizedError := feedHandler.CreateFeed(h.store, userID, &feedCreationRequest)
|
||||
if localizedError != nil {
|
||||
json.ServerError(w, r, localizedError.Error())
|
||||
response.JSONServerError(w, r, localizedError.Error())
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, &feedCreationResponse{FeedID: feed.ID})
|
||||
response.JSONCreated(w, r, &feedCreationResponse{FeedID: feed.ID})
|
||||
}
|
||||
|
||||
func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) refreshFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
userID := request.UserID(r)
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
localizedError := feedHandler.RefreshFeed(h.store, userID, feedID, false)
|
||||
if localizedError != nil {
|
||||
json.ServerError(w, r, localizedError.Error())
|
||||
response.JSONServerError(w, r, localizedError.Error())
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) refreshAllFeedsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
batchBuilder := h.store.NewBatchBuilder()
|
||||
@@ -76,10 +81,11 @@ 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 {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -91,147 +97,164 @@ func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
go h.pool.Push(jobs)
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) updateFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
var feedModificationRequest model.FeedModificationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&feedModificationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
originalFeed, err := h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if originalFeed == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateFeedModification(h.store, userID, originalFeed.ID, &feedModificationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
feedModificationRequest.Patch(originalFeed)
|
||||
originalFeed.ResetErrorCounter()
|
||||
if err := h.store.UpdateFeed(originalFeed); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
originalFeed, err = h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, originalFeed)
|
||||
response.JSONCreated(w, r, originalFeed)
|
||||
}
|
||||
|
||||
func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
func (h *handler) markFeedAsReadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
feed, err := h.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
json.NotFound(w, r)
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
json.NotFound(w, r)
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.MarkFeedAsRead(userID, feedID, time.Now()); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getCategoryFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getCategoryFeedsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
if categoryID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid category ID"))
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.store.Category(userID, categoryID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
feeds, err := h.store.FeedsByCategoryWithCounters(userID, categoryID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, feeds)
|
||||
response.JSON(w, r, feeds)
|
||||
}
|
||||
|
||||
func (h *handler) getFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getFeedsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feeds, err := h.store.Feeds(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, feeds)
|
||||
response.JSON(w, r, feeds)
|
||||
}
|
||||
|
||||
func (h *handler) fetchCounters(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) fetchCountersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
counters, err := h.store.FetchCounters(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, counters)
|
||||
response.JSON(w, r, counters)
|
||||
}
|
||||
|
||||
func (h *handler) getFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := h.store.FeedByID(request.UserID(r), feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, feed)
|
||||
response.JSON(w, r, feed)
|
||||
}
|
||||
|
||||
func (h *handler) removeFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) removeFeedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
userID := request.UserID(r)
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if !h.store.FeedExists(userID, feedID) {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.RemoveFeed(userID, feedID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
@@ -4,53 +4,57 @@
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
)
|
||||
|
||||
func (h *handler) getIconByFeedID(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getIconByFeedIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
if !h.store.HasFeedIcon(feedID) {
|
||||
json.NotFound(w, r)
|
||||
if feedID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid feed ID"))
|
||||
return
|
||||
}
|
||||
|
||||
icon, err := h.store.IconByFeedID(request.UserID(r), feedID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, &feedIconResponse{
|
||||
response.JSON(w, r, &feedIconResponse{
|
||||
ID: icon.ID,
|
||||
MimeType: icon.MimeType,
|
||||
Data: icon.DataURL(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) getIconByIconID(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) getIconByIconIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
iconID := request.RouteInt64Param(r, "iconID")
|
||||
if iconID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid icon ID"))
|
||||
return
|
||||
}
|
||||
|
||||
icon, err := h.store.IconByID(iconID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, &feedIconResponse{
|
||||
response.JSON(w, r, &feedIconResponse{
|
||||
ID: icon.ID,
|
||||
MimeType: icon.MimeType,
|
||||
Data: icon.DataURL(),
|
||||
@@ -18,10 +18,40 @@ type entriesResponse struct {
|
||||
Entries model.Entries `json:"entries"`
|
||||
}
|
||||
|
||||
type integrationsStatusResponse struct {
|
||||
HasIntegrations bool `json:"has_integrations"`
|
||||
}
|
||||
|
||||
type entryIDResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type entryContentResponse struct {
|
||||
Content string `json:"content"`
|
||||
ReadingTime int `json:"reading_time"`
|
||||
}
|
||||
|
||||
type entryImportRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
CommentsURL string `json:"comments_url"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
Status string `json:"status"`
|
||||
Starred bool `json:"starred"`
|
||||
Tags []string `json:"tags"`
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
|
||||
type feedCreationResponse struct {
|
||||
FeedID int64 `json:"feed_id"`
|
||||
}
|
||||
|
||||
type importFeedsResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type versionResponse struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
+20
-12
@@ -9,7 +9,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
@@ -20,21 +20,21 @@ type middleware struct {
|
||||
func newMiddleware(s *storage.Storage) *middleware {
|
||||
return &middleware{s}
|
||||
}
|
||||
func (m *middleware) handleCORS(next http.Handler) http.Handler {
|
||||
func (m *middleware) withCORSHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "X-Auth-Token, Authorization, Content-Type, Accept")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Max-Age", "3600")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
response.NoContent(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
func (m *middleware) validateAPIKeyAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.ClientIP(r)
|
||||
token := r.Header.Get("X-Auth-Token")
|
||||
@@ -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
|
||||
@@ -50,7 +51,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
|
||||
user, err := m.store.UserByAPIKey(token)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,8 +60,9 @@ 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)
|
||||
response.JSONUnauthorized(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)
|
||||
@@ -84,7 +87,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (m *middleware) basicAuth(next http.Handler) http.Handler {
|
||||
func (m *middleware) validateBasicAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if request.IsAuthenticated(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -100,8 +103,9 @@ 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)
|
||||
response.JSONUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,8 +114,9 @@ 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)
|
||||
response.JSONUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -121,14 +126,15 @@ 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)
|
||||
response.JSONUnauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,8 +144,9 @@ 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)
|
||||
response.JSONUnauthorized(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)
|
||||
|
||||
@@ -7,30 +7,29 @@ import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response/xml"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/reader/opml"
|
||||
)
|
||||
|
||||
func (h *handler) exportFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) exportFeedsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
opmlHandler := opml.NewHandler(h.store)
|
||||
opmlExport, err := opmlHandler.Export(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
xml.OK(w, r, opmlExport)
|
||||
response.XML(w, r, opmlExport)
|
||||
}
|
||||
|
||||
func (h *handler) importFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) importFeedsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
opmlHandler := opml.NewHandler(h.store)
|
||||
err := opmlHandler.Import(request.UserID(r), r.Body)
|
||||
defer r.Body.Close()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, map[string]string{"message": "Feeds imported successfully"})
|
||||
response.JSONCreated(w, r, importFeedsResponse{Message: "Feeds imported successfully"})
|
||||
}
|
||||
@@ -9,55 +9,61 @@ import (
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"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"
|
||||
)
|
||||
|
||||
func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) discoverSubscriptionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var subscriptionDiscoveryRequest model.SubscriptionDiscoveryRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&subscriptionDiscoveryRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateSubscriptionDiscovery(&subscriptionDiscoveryRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
json.ServerError(w, r, localizedError.Error())
|
||||
response.JSONServerError(w, r, localizedError.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(subscriptions) == 0 {
|
||||
json.NotFound(w, r)
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, subscriptions)
|
||||
response.JSON(w, r, subscriptions)
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
// 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"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) currentUser(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var userCreationRequest model.UserCreationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&userCreationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserCreationWithPassword(h.store, &userCreationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.CreateUser(&userCreationRequest)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
|
||||
var userModificationRequest model.UserModificationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&userModificationRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
originalUser, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if originalUser == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !request.IsAdminUser(r) {
|
||||
if originalUser.ID != request.UserID(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin {
|
||||
json.BadRequest(w, r, errors.New("only administrators can change permissions of standard users"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
userModificationRequest.Patch(originalUser)
|
||||
if err = h.store.UpdateUser(originalUser); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, r, originalUser)
|
||||
}
|
||||
|
||||
func (h *handler) markUserAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
if userID != request.UserID(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.MarkAllAsRead(userID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getIntegrationsStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
hasIntegrations := h.store.HasSaveEntry(userID)
|
||||
|
||||
response := struct {
|
||||
HasIntegrations bool `json:"has_integrations"`
|
||||
}{
|
||||
HasIntegrations: hasIntegrations,
|
||||
}
|
||||
|
||||
json.OK(w, r, response)
|
||||
}
|
||||
|
||||
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.store.Users()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
users.UseTimezone(request.UserTimezone(r))
|
||||
json.OK(w, r, users)
|
||||
}
|
||||
|
||||
func (h *handler) userByID(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user.UseTimezone(request.UserTimezone(r))
|
||||
json.OK(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) userByUsername(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
username := request.RouteStringParam(r, "username")
|
||||
user, err := h.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user.ID == request.UserID(r) {
|
||||
json.BadRequest(w, r, errors.New("you cannot remove yourself"))
|
||||
return
|
||||
}
|
||||
|
||||
h.store.RemoveUserAsync(user.ID)
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// 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"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) currentUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) createUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var userCreationRequest model.UserCreationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&userCreationRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserCreationWithPassword(h.store, &userCreationRequest); validationErr != nil {
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.CreateUser(&userCreationRequest)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSONCreated(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) updateUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
if userID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
|
||||
return
|
||||
}
|
||||
|
||||
var userModificationRequest model.UserModificationRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&userModificationRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
originalUser, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if originalUser == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !request.IsAdminUser(r) {
|
||||
if originalUser.ID != request.UserID(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin {
|
||||
response.JSONBadRequest(w, r, errors.New("only administrators can change permissions of standard users"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
|
||||
response.JSONBadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
userModificationRequest.Patch(originalUser)
|
||||
if err = h.store.UpdateUser(originalUser); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSONCreated(w, r, originalUser)
|
||||
}
|
||||
|
||||
func (h *handler) markUserAsReadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
if userID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if userID != request.UserID(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.MarkAllAsRead(userID); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getIntegrationsStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
hasIntegrations := h.store.HasSaveEntry(userID)
|
||||
|
||||
response.JSON(w, r, integrationsStatusResponse{HasIntegrations: hasIntegrations})
|
||||
}
|
||||
|
||||
func (h *handler) usersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.store.Users()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
users.UseTimezone(request.UserTimezone(r))
|
||||
response.JSON(w, r, users)
|
||||
}
|
||||
|
||||
func (h *handler) dispatchUserLookupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
identifier := request.RouteStringParam(r, "identifier")
|
||||
userID := request.RouteInt64Param(r, "identifier")
|
||||
if userID > 0 {
|
||||
r.SetPathValue("userID", identifier)
|
||||
h.userByIDHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
r.SetPathValue("username", identifier)
|
||||
h.userByUsernameHandler(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) userByIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
if userID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user.UseTimezone(request.UserTimezone(r))
|
||||
response.JSON(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) userByUsernameHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
username := request.RouteStringParam(r, "username")
|
||||
user, err := h.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
response.JSONBadRequest(w, r, errors.New("unable to fetch this user from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, r, user)
|
||||
}
|
||||
|
||||
func (h *handler) removeUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
response.JSONForbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.RouteInt64Param(r, "userID")
|
||||
if userID == 0 {
|
||||
response.JSONBadRequest(w, r, errors.New("invalid user ID"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
response.JSONNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user.ID == request.UserID(r) {
|
||||
response.JSONBadRequest(w, r, errors.New("you cannot remove yourself"))
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := h.store.RemoveUser(user.ID); err != nil {
|
||||
slog.Error("Unable to delete user",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
response.NoContent(w, r)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, r, &versionResponse{
|
||||
Version: version.Version,
|
||||
Commit: version.Commit,
|
||||
BuildDate: version.BuildDate,
|
||||
GoVersion: runtime.Version(),
|
||||
Compiler: runtime.Compiler,
|
||||
Arch: runtime.GOARCH,
|
||||
OS: runtime.GOOS,
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -16,20 +17,34 @@ func askCredentials() (string, string) {
|
||||
fd := int(os.Stdin.Fd())
|
||||
|
||||
if !term.IsTerminal(fd) {
|
||||
printErrorAndExit(fmt.Errorf("this is not an interactive terminal, exiting"))
|
||||
printErrorAndExit(errors.New("this is not an interactive terminal, exiting"))
|
||||
}
|
||||
|
||||
fmt.Print("Enter Username: ")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
username, _ := reader.ReadString('\n')
|
||||
username, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to read username: %w", err))
|
||||
}
|
||||
|
||||
fmt.Print("Enter Password: ")
|
||||
|
||||
state, _ := term.GetState(fd)
|
||||
defer term.Restore(fd, state)
|
||||
bytePassword, _ := term.ReadPassword(fd)
|
||||
state, err := term.GetState(fd)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to get terminal state: %w", err))
|
||||
}
|
||||
defer func() {
|
||||
if restoreErr := term.Restore(fd, state); restoreErr != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to restore terminal state: %w", restoreErr))
|
||||
}
|
||||
}()
|
||||
|
||||
fmt.Printf("\n")
|
||||
bytePassword, err := term.ReadPassword(fd)
|
||||
if err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to read password: %w", err))
|
||||
}
|
||||
|
||||
fmt.Print("\n")
|
||||
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
|
||||
}
|
||||
|
||||
@@ -14,15 +14,16 @@ import (
|
||||
)
|
||||
|
||||
func runCleanupTasks(store *storage.Storage) {
|
||||
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsDays())
|
||||
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsDays())
|
||||
slog.Info("Sessions cleanup completed",
|
||||
slog.Int64("application_sessions_removed", nbSessions),
|
||||
slog.Int64("user_sessions_removed", nbUserSessions),
|
||||
)
|
||||
if nbWebSessions, err := store.CleanOldWebSessions(config.Opts.CleanupRemoveSessionsInterval()); err != nil {
|
||||
slog.Error("Unable to clean old web sessions", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Sessions cleanup completed",
|
||||
slog.Int64("web_sessions_removed", nbWebSessions),
|
||||
)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
slog.Error("Unable to archive read entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Archiving read entries completed",
|
||||
@@ -35,7 +36,7 @@ func runCleanupTasks(store *storage.Storage) {
|
||||
}
|
||||
|
||||
startTime = time.Now()
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
|
||||
slog.Error("Unable to archive unread entries", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Archiving unread entries completed",
|
||||
@@ -46,4 +47,12 @@ func runCleanupTasks(store *storage.Storage) {
|
||||
metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusUnread).Observe(time.Since(startTime).Seconds())
|
||||
}
|
||||
}
|
||||
|
||||
if nbIcons, err := store.CleanupOrphanIcons(); err != nil {
|
||||
slog.Error("Unable to clean orphan icons", slog.Any("error", err))
|
||||
} else {
|
||||
slog.Info("Orphan icons cleanup completed",
|
||||
slog.Int64("orphan_icons_removed", nbIcons),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+70
-63
@@ -4,7 +4,6 @@
|
||||
package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,46 +12,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" tries to guess the health check endpoint).`
|
||||
flagRefreshFeedsHelp = "Refresh a batch of feeds and exit"
|
||||
flagRunCleanupTasksHelp = "Run cleanup tasks (delete old sessions and archive 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 +66,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)
|
||||
@@ -74,7 +77,7 @@ func Parse() {
|
||||
flag.StringVar(&flagExportUserFeeds, "export-user-feeds", "", flagExportUserFeedsHelp)
|
||||
flag.Parse()
|
||||
|
||||
cfg := config.NewParser()
|
||||
cfg := config.NewConfigParser()
|
||||
|
||||
if flagConfigFile != "" {
|
||||
config.Opts, err = cfg.ParseFile(flagConfigFile)
|
||||
@@ -88,21 +91,8 @@ func Parse() {
|
||||
printErrorAndExit(err)
|
||||
}
|
||||
|
||||
if oauth2Provider := config.Opts.OAuth2Provider(); oauth2Provider != "" {
|
||||
if oauth2Provider != "oidc" && oauth2Provider != "google" {
|
||||
printErrorAndExit(fmt.Errorf(`unsupported OAuth2 provider: %q (Possible values are "google" or "oidc")`, oauth2Provider))
|
||||
}
|
||||
}
|
||||
|
||||
if config.Opts.DisableLocalAuth() {
|
||||
switch {
|
||||
case config.Opts.OAuth2Provider() == "" && config.Opts.AuthProxyHeader() == "":
|
||||
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled but neither OAUTH2_PROVIDER nor AUTH_PROXY_HEADER is not set. Please enable at least one authentication source"))
|
||||
case config.Opts.OAuth2Provider() != "" && !config.Opts.IsOAuth2UserCreationAllowed():
|
||||
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled and an OAUTH2_PROVIDER is configured, but OAUTH2_USER_CREATION is not enabled"))
|
||||
case config.Opts.AuthProxyHeader() != "" && !config.Opts.IsAuthProxyUserCreationAllowed():
|
||||
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled and an AUTH_PROXY_HEADER is configured, but AUTH_PROXY_USER_CREATION is not enabled"))
|
||||
}
|
||||
if err := config.Opts.Validate(); err != nil {
|
||||
printErrorAndExit(err)
|
||||
}
|
||||
|
||||
if flagConfigDump {
|
||||
@@ -110,6 +100,16 @@ func Parse() {
|
||||
return
|
||||
}
|
||||
|
||||
if flagInfo {
|
||||
info()
|
||||
return
|
||||
}
|
||||
|
||||
if flagVersion {
|
||||
fmt.Println(version.Version)
|
||||
return
|
||||
}
|
||||
|
||||
if flagDebugMode {
|
||||
config.Opts.SetLogLevel("debug")
|
||||
}
|
||||
@@ -138,30 +138,20 @@ func Parse() {
|
||||
return
|
||||
}
|
||||
|
||||
if flagInfo {
|
||||
info()
|
||||
return
|
||||
}
|
||||
|
||||
if flagVersion {
|
||||
fmt.Println(version.Version)
|
||||
return
|
||||
}
|
||||
|
||||
if config.Opts.IsDefaultDatabaseURL() {
|
||||
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 +179,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 +227,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
|
||||
@@ -242,6 +249,6 @@ func Parse() {
|
||||
}
|
||||
|
||||
func printErrorAndExit(err error) {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
+22
-6
@@ -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,14 +33,15 @@ 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)
|
||||
}
|
||||
|
||||
metricsCtx, cancelMetrics := context.WithCancel(context.Background())
|
||||
if config.Opts.HasMetricsCollector() {
|
||||
collector := metric.NewCollector(store, config.Opts.MetricsRefreshInterval())
|
||||
go collector.GatherStorageMetrics()
|
||||
go collector.GatherStorageMetrics(metricsCtx)
|
||||
}
|
||||
|
||||
if systemd.HasNotifySocket() {
|
||||
@@ -75,12 +76,27 @@ func startDaemon(store *storage.Storage) {
|
||||
|
||||
<-stop
|
||||
slog.Debug("Shutting down the process")
|
||||
cancelMetrics()
|
||||
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("Shutting down worker pool...")
|
||||
pool.Shutdown()
|
||||
slog.Debug("Worker pool 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",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
@@ -19,7 +20,7 @@ func resetPassword(store *storage.Storage) {
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
printErrorAndExit(fmt.Errorf("user not found"))
|
||||
printErrorAndExit(errors.New("user not found"))
|
||||
}
|
||||
|
||||
userModificationRequest := &model.UserModificationRequest{
|
||||
|
||||
@@ -21,36 +21,36 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
|
||||
config.Opts.PollingFrequency(),
|
||||
config.Opts.BatchSize(),
|
||||
config.Opts.PollingParsingErrorLimit(),
|
||||
config.Opts.PollingLimitPerHost(),
|
||||
)
|
||||
|
||||
go cleanupScheduler(
|
||||
store,
|
||||
config.Opts.CleanupFrequencyHours(),
|
||||
config.Opts.CleanupFrequency(),
|
||||
)
|
||||
}
|
||||
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit int) {
|
||||
for range time.Tick(time.Duration(frequency) * time.Minute) {
|
||||
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency time.Duration, batchSize, errorLimit, limitPerHost int) {
|
||||
for range time.Tick(frequency) {
|
||||
// Generate a batch of feeds for any user that has feeds to refresh.
|
||||
batchBuilder := store.NewBatchBuilder()
|
||||
batchBuilder.WithBatchSize(batchSize)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupScheduler(store *storage.Storage, frequency int) {
|
||||
for range time.Tick(time.Duration(frequency) * time.Hour) {
|
||||
func cleanupScheduler(store *storage.Storage, frequency time.Duration) {
|
||||
for range time.Tick(frequency) {
|
||||
runCleanupTasks(store)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,9 @@
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import "miniflux.app/v2/internal/version"
|
||||
|
||||
// Opts holds parsed configuration options.
|
||||
var Opts *Options
|
||||
var Opts *configOptions
|
||||
|
||||
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+904
-649
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+251
-286
@@ -15,323 +15,254 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Parser handles configuration parsing.
|
||||
type Parser struct {
|
||||
opts *Options
|
||||
type configParser struct {
|
||||
options *configOptions
|
||||
}
|
||||
|
||||
// NewParser returns a new Parser.
|
||||
func NewParser() *Parser {
|
||||
return &Parser{
|
||||
opts: NewOptions(),
|
||||
func NewConfigParser() *configParser {
|
||||
return &configParser{
|
||||
options: NewConfigOptions(),
|
||||
}
|
||||
}
|
||||
|
||||
// ParseEnvironmentVariables loads configuration values from environment variables.
|
||||
func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
|
||||
err := p.parseLines(os.Environ())
|
||||
if err != nil {
|
||||
func (cp *configParser) ParseEnvironmentVariables() (*configOptions, error) {
|
||||
if err := cp.parseLines(os.Environ()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.opts, nil
|
||||
|
||||
return cp.options, nil
|
||||
}
|
||||
|
||||
// ParseFile loads configuration values from a local file.
|
||||
func (p *Parser) ParseFile(filename string) (*Options, error) {
|
||||
func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
|
||||
fp, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fp.Close()
|
||||
|
||||
err = p.parseLines(p.parseFileContent(fp))
|
||||
if err != nil {
|
||||
if err := cp.parseLines(parseFileContent(fp)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.opts, nil
|
||||
|
||||
return cp.options, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
// Validate checks for invalid or incomplete option combinations.
|
||||
func (c *configOptions) Validate() error {
|
||||
if c.OAuth2Provider() == "oidc" && c.OAuth2OIDCDiscoveryEndpoint() == "" {
|
||||
return errors.New("OAUTH2_OIDC_DISCOVERY_ENDPOINT must be configured when using the OIDC provider")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
switch key {
|
||||
case "LOG_FILE":
|
||||
p.opts.logFile = parseString(value, defaultLogFile)
|
||||
case "LOG_DATE_TIME":
|
||||
p.opts.logDateTime = parseBool(value, defaultLogDateTime)
|
||||
case "LOG_LEVEL":
|
||||
parsedValue := parseString(value, defaultLogLevel)
|
||||
if parsedValue == "debug" || parsedValue == "info" || parsedValue == "warning" || parsedValue == "error" {
|
||||
p.opts.logLevel = parsedValue
|
||||
}
|
||||
case "LOG_FORMAT":
|
||||
parsedValue := parseString(value, defaultLogFormat)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
case "PORT":
|
||||
port = value
|
||||
case "LISTEN_ADDR":
|
||||
p.opts.listenAddr = parseString(value, defaultListenAddr)
|
||||
case "DATABASE_URL":
|
||||
p.opts.databaseURL = parseString(value, defaultDatabaseURL)
|
||||
case "DATABASE_URL_FILE":
|
||||
p.opts.databaseURL = readSecretFile(value, defaultDatabaseURL)
|
||||
case "DATABASE_MAX_CONNS":
|
||||
p.opts.databaseMaxConns = parseInt(value, defaultDatabaseMaxConns)
|
||||
case "DATABASE_MIN_CONNS":
|
||||
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
|
||||
case "DATABASE_CONNECTION_LIFETIME":
|
||||
p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
|
||||
case "FILTER_ENTRY_MAX_AGE_DAYS":
|
||||
p.opts.filterEntryMaxAgeDays = parseInt(value, defaultFilterEntryMaxAgeDays)
|
||||
case "RUN_MIGRATIONS":
|
||||
p.opts.runMigrations = parseBool(value, defaultRunMigrations)
|
||||
case "DISABLE_HSTS":
|
||||
p.opts.hsts = !parseBool(value, defaultHSTS)
|
||||
case "HTTPS":
|
||||
p.opts.HTTPS = parseBool(value, defaultHTTPS)
|
||||
case "DISABLE_SCHEDULER_SERVICE":
|
||||
p.opts.schedulerService = !parseBool(value, defaultSchedulerService)
|
||||
case "DISABLE_HTTP_SERVICE":
|
||||
p.opts.httpService = !parseBool(value, defaultHTTPService)
|
||||
case "CERT_FILE":
|
||||
p.opts.certFile = parseString(value, defaultCertFile)
|
||||
case "KEY_FILE":
|
||||
p.opts.certKeyFile = parseString(value, defaultKeyFile)
|
||||
case "CERT_DOMAIN":
|
||||
p.opts.certDomain = parseString(value, defaultCertDomain)
|
||||
case "CLEANUP_FREQUENCY_HOURS":
|
||||
p.opts.cleanupFrequencyHours = parseInt(value, defaultCleanupFrequencyHours)
|
||||
case "CLEANUP_ARCHIVE_READ_DAYS":
|
||||
p.opts.cleanupArchiveReadDays = parseInt(value, defaultCleanupArchiveReadDays)
|
||||
case "CLEANUP_ARCHIVE_UNREAD_DAYS":
|
||||
p.opts.cleanupArchiveUnreadDays = parseInt(value, defaultCleanupArchiveUnreadDays)
|
||||
case "CLEANUP_ARCHIVE_BATCH_SIZE":
|
||||
p.opts.cleanupArchiveBatchSize = parseInt(value, defaultCleanupArchiveBatchSize)
|
||||
case "CLEANUP_REMOVE_SESSIONS_DAYS":
|
||||
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_SCHEDULER":
|
||||
p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
|
||||
p.opts.schedulerEntryFrequencyMaxInterval = parseInt(value, defaultSchedulerEntryFrequencyMaxInterval)
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL":
|
||||
p.opts.schedulerEntryFrequencyMinInterval = parseInt(value, defaultSchedulerEntryFrequencyMinInterval)
|
||||
case "SCHEDULER_ENTRY_FREQUENCY_FACTOR":
|
||||
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 "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)
|
||||
if _, err := rand.Read(randomKey); err != nil {
|
||||
return fmt.Errorf("config: unable to generate random key: %w", err)
|
||||
}
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_PRIVATE_KEY":
|
||||
randomKey := make([]byte, 16)
|
||||
if _, err := rand.Read(randomKey); err != nil {
|
||||
return fmt.Errorf("config: unable to generate random key: %w", err)
|
||||
}
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_CUSTOM_URL":
|
||||
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||
case "CREATE_ADMIN":
|
||||
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
|
||||
case "ADMIN_USERNAME":
|
||||
p.opts.adminUsername = parseString(value, defaultAdminUsername)
|
||||
case "ADMIN_USERNAME_FILE":
|
||||
p.opts.adminUsername = readSecretFile(value, defaultAdminUsername)
|
||||
case "ADMIN_PASSWORD":
|
||||
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":
|
||||
p.opts.oauth2ClientID = parseString(value, defaultOAuth2ClientID)
|
||||
case "OAUTH2_CLIENT_ID_FILE":
|
||||
p.opts.oauth2ClientID = readSecretFile(value, defaultOAuth2ClientID)
|
||||
case "OAUTH2_CLIENT_SECRET":
|
||||
p.opts.oauth2ClientSecret = parseString(value, defaultOAuth2ClientSecret)
|
||||
case "OAUTH2_CLIENT_SECRET_FILE":
|
||||
p.opts.oauth2ClientSecret = readSecretFile(value, defaultOAuth2ClientSecret)
|
||||
case "OAUTH2_REDIRECT_URL":
|
||||
p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
|
||||
case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
|
||||
p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
|
||||
case "OAUTH2_OIDC_PROVIDER_NAME":
|
||||
p.opts.oidcProviderName = parseString(value, defaultOauth2OidcProviderName)
|
||||
case "OAUTH2_PROVIDER":
|
||||
p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
|
||||
case "DISABLE_LOCAL_AUTH":
|
||||
p.opts.disableLocalAuth = parseBool(value, defaultDisableLocalAuth)
|
||||
case "HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
|
||||
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)
|
||||
case "HTTP_CLIENT_USER_AGENT":
|
||||
p.opts.httpClientUserAgent = parseString(value, defaultHTTPClientUserAgent)
|
||||
case "HTTP_SERVER_TIMEOUT":
|
||||
p.opts.httpServerTimeout = parseInt(value, defaultHTTPServerTimeout)
|
||||
case "AUTH_PROXY_HEADER":
|
||||
p.opts.authProxyHeader = parseString(value, defaultAuthProxyHeader)
|
||||
case "AUTH_PROXY_USER_CREATION":
|
||||
p.opts.authProxyUserCreation = parseBool(value, defaultAuthProxyUserCreation)
|
||||
case "MAINTENANCE_MODE":
|
||||
p.opts.maintenanceMode = parseBool(value, defaultMaintenanceMode)
|
||||
case "MAINTENANCE_MESSAGE":
|
||||
p.opts.maintenanceMessage = parseString(value, defaultMaintenanceMessage)
|
||||
case "METRICS_COLLECTOR":
|
||||
p.opts.metricsCollector = parseBool(value, defaultMetricsCollector)
|
||||
case "METRICS_REFRESH_INTERVAL":
|
||||
p.opts.metricsRefreshInterval = parseInt(value, defaultMetricsRefreshInterval)
|
||||
case "METRICS_ALLOWED_NETWORKS":
|
||||
p.opts.metricsAllowedNetworks = parseStringList(value, []string{defaultMetricsAllowedNetworks})
|
||||
case "METRICS_USERNAME":
|
||||
p.opts.metricsUsername = parseString(value, defaultMetricsUsername)
|
||||
case "METRICS_USERNAME_FILE":
|
||||
p.opts.metricsUsername = readSecretFile(value, defaultMetricsUsername)
|
||||
case "METRICS_PASSWORD":
|
||||
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
|
||||
case "METRICS_PASSWORD_FILE":
|
||||
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
|
||||
case "FETCH_BILIBILI_WATCH_TIME":
|
||||
p.opts.fetchBilibiliWatchTime = parseBool(value, defaultFetchBilibiliWatchTime)
|
||||
case "FETCH_NEBULA_WATCH_TIME":
|
||||
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
|
||||
case "FETCH_ODYSEE_WATCH_TIME":
|
||||
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
|
||||
case "FETCH_YOUTUBE_WATCH_TIME":
|
||||
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
|
||||
case "YOUTUBE_API_KEY":
|
||||
p.opts.youTubeApiKey = parseString(value, defaultYouTubeApiKey)
|
||||
case "YOUTUBE_EMBED_URL_OVERRIDE":
|
||||
p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
|
||||
case "WATCHDOG":
|
||||
p.opts.watchdog = parseBool(value, defaultWatchdog)
|
||||
case "INVIDIOUS_INSTANCE":
|
||||
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
|
||||
case "WEBAUTHN":
|
||||
p.opts.webAuthn = parseBool(value, defaultWebAuthn)
|
||||
if c.DisableLocalAuth() {
|
||||
if c.OAuth2Provider() == "" && c.AuthProxyHeader() == "" {
|
||||
return errors.New("DISABLE_LOCAL_AUTH is enabled but neither OAUTH2_PROVIDER nor AUTH_PROXY_HEADER is set. Please enable at least one authentication source")
|
||||
}
|
||||
}
|
||||
|
||||
if port != "" {
|
||||
p.opts.listenAddr = ":" + port
|
||||
if c.AuthProxyHeader() != "" && len(c.TrustedReverseProxyNetworks()) == 0 {
|
||||
return errors.New("TRUSTED_REVERSE_PROXY_NETWORKS must be configured when AUTH_PROXY_HEADER is used")
|
||||
}
|
||||
|
||||
if (c.CertFile() != "") != (c.CertKeyFile() != "") {
|
||||
return errors.New("CERT_FILE and KEY_FILE must both be provided")
|
||||
}
|
||||
|
||||
if c.CertDomain() != "" && c.CertFile() != "" {
|
||||
return errors.New("CERT_DOMAIN and CERT_FILE/KEY_FILE are mutually exclusive")
|
||||
}
|
||||
|
||||
if (c.MetricsUsername() != "") != (c.MetricsPassword() != "") {
|
||||
return errors.New("METRICS_USERNAME and METRICS_PASSWORD must both be provided")
|
||||
}
|
||||
|
||||
if c.DatabaseMinConns() > c.DatabaseMaxConns() {
|
||||
return errors.New("DATABASE_MIN_CONNS must be less than or equal to DATABASE_MAX_CONNS")
|
||||
}
|
||||
|
||||
if c.SchedulerRoundRobinMinInterval() > c.SchedulerRoundRobinMaxInterval() {
|
||||
return errors.New("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL must be less than or equal to SCHEDULER_ROUND_ROBIN_MAX_INTERVAL")
|
||||
}
|
||||
|
||||
if c.SchedulerEntryFrequencyMinInterval() > c.SchedulerEntryFrequencyMaxInterval() {
|
||||
return errors.New("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL must be less than or equal to SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBaseURL(value string) (string, string, string, error) {
|
||||
if value == "" {
|
||||
return defaultBaseURL, defaultRootURL, "", nil
|
||||
}
|
||||
func (cp *configParser) postParsing() error {
|
||||
// Parse basePath and rootURL based on BASE_URL
|
||||
baseURL := cp.options.options["BASE_URL"].parsedStringValue
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
if value[len(value)-1:] == "/" {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(value)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err)
|
||||
return fmt.Errorf("invalid BASE_URL: %v", err)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsedURL.Scheme)
|
||||
if scheme != "https" && scheme != "http" {
|
||||
return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https")
|
||||
return errors.New("BASE_URL scheme must be http or https")
|
||||
}
|
||||
|
||||
basePath := parsedURL.Path
|
||||
cp.options.options["BASE_URL"].parsedStringValue = baseURL
|
||||
cp.options.basePath = parsedURL.Path
|
||||
|
||||
parsedURL.Path = ""
|
||||
return value, parsedURL.String(), basePath, nil
|
||||
cp.options.rootURL = parsedURL.String()
|
||||
|
||||
// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE
|
||||
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].parsedStringValue
|
||||
if youTubeEmbedURLOverride != "" {
|
||||
parsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid YOUTUBE_EMBED_URL_OVERRIDE: %v", err)
|
||||
}
|
||||
cp.options.youTubeEmbedDomain = parsedYouTubeEmbedURL.Hostname()
|
||||
}
|
||||
|
||||
// Generate a media proxy private key if not set
|
||||
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue) == 0 {
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].parsedBytesValue = randomKey
|
||||
}
|
||||
|
||||
// Override LISTEN_ADDR with PORT if set (for compatibility reasons)
|
||||
if cp.options.Port() != "" {
|
||||
cp.options.options["LISTEN_ADDR"].parsedStringList = []string{":" + cp.options.Port()}
|
||||
cp.options.options["LISTEN_ADDR"].rawValue = ":" + cp.options.Port()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBool(value string, fallback bool) bool {
|
||||
func (cp *configParser) parseLines(lines []string) error {
|
||||
for lineNum, line := range lines {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to parse configuration, invalid format on line %d", lineNum)
|
||||
}
|
||||
|
||||
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
|
||||
if err := cp.parseLine(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := cp.postParsing(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cp *configParser) parseLine(key, value string) error {
|
||||
field, exists := cp.options.options[key]
|
||||
if !exists {
|
||||
if key == "FILTER_ENTRY_MAX_AGE_DAYS" {
|
||||
slog.Warn("Configuration option FILTER_ENTRY_MAX_AGE_DAYS is deprecated; use user filter rule max-age:<duration> instead")
|
||||
}
|
||||
// Ignore unknown configuration keys to avoid parsing unrelated environment variables.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate the option if a validator is provided
|
||||
if field.validator != nil {
|
||||
if err := field.validator(value); err != nil {
|
||||
return fmt.Errorf("invalid value for key %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the raw value based on its type
|
||||
switch field.valueType {
|
||||
case stringType:
|
||||
field.parsedStringValue = parseStringValue(value, field.parsedStringValue)
|
||||
field.rawValue = value
|
||||
case stringListType:
|
||||
field.parsedStringList = parseStringListValue(value, field.parsedStringList)
|
||||
field.rawValue = value
|
||||
case boolType:
|
||||
parsedValue, err := parseBoolValue(value, field.parsedBoolValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid boolean value for key %s: %v", key, err)
|
||||
}
|
||||
field.parsedBoolValue = parsedValue
|
||||
field.rawValue = value
|
||||
case intType:
|
||||
field.parsedIntValue = parseIntValue(value, field.parsedIntValue)
|
||||
field.rawValue = value
|
||||
case int64Type:
|
||||
field.parsedInt64Value = ParsedInt64Value(value, field.parsedInt64Value)
|
||||
field.rawValue = value
|
||||
case secondType:
|
||||
field.parsedDuration = parseDurationValue(value, time.Second, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case minuteType:
|
||||
field.parsedDuration = parseDurationValue(value, time.Minute, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case hourType:
|
||||
field.parsedDuration = parseDurationValue(value, time.Hour, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case dayType:
|
||||
field.parsedDuration = parseDurationValue(value, time.Hour*24, field.parsedDuration)
|
||||
field.rawValue = value
|
||||
case urlType:
|
||||
parsedURL, err := parseURLValue(value, field.parsedURLValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL for key %s: %v", key, err)
|
||||
}
|
||||
field.parsedURLValue = parsedURL
|
||||
field.rawValue = value
|
||||
case secretFileType:
|
||||
secretValue, err := readSecretFileValue(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading secret file for key %s: %v", key, err)
|
||||
}
|
||||
if field.targetKey != "" {
|
||||
if targetField, ok := cp.options.options[field.targetKey]; ok {
|
||||
targetField.parsedStringValue = secretValue
|
||||
targetField.rawValue = secretValue
|
||||
}
|
||||
}
|
||||
field.rawValue = value
|
||||
case bytesType:
|
||||
if value != "" {
|
||||
field.parsedBytesValue = []byte(value)
|
||||
field.rawValue = value
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseStringValue(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseBoolValue(value string, fallback bool) (bool, error) {
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
value = strings.ToLower(value)
|
||||
if value == "1" || value == "yes" || value == "true" || value == "on" {
|
||||
return true
|
||||
return true, nil
|
||||
}
|
||||
if value == "0" || value == "no" || value == "false" || value == "off" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false
|
||||
return false, fmt.Errorf("invalid boolean value: %q", value)
|
||||
}
|
||||
|
||||
func parseInt(value string, fallback int) int {
|
||||
func parseIntValue(value string, fallback int) int {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
@@ -344,52 +275,86 @@ func parseInt(value string, fallback int) int {
|
||||
return v
|
||||
}
|
||||
|
||||
func parseString(value string, fallback string) string {
|
||||
func ParsedInt64Value(value string, fallback int64) int64 {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
|
||||
v, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func parseStringList(value string, fallback []string) []string {
|
||||
func parseStringListValue(value string, fallback []string) []string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strList
|
||||
}
|
||||
|
||||
func parseBytes(value string, fallback []byte) []byte {
|
||||
func parseDurationValue(value string, unit time.Duration, fallback time.Duration) time.Duration {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return []byte(value)
|
||||
}
|
||||
|
||||
func readSecretFile(filename, fallback string) string {
|
||||
data, err := os.ReadFile(filename)
|
||||
v, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
value := string(bytes.TrimSpace(data))
|
||||
return time.Duration(v) * unit
|
||||
}
|
||||
|
||||
func parseURLValue(value string, fallback *url.URL) (*url.URL, error) {
|
||||
if value == "" {
|
||||
return fallback
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
return value
|
||||
parsedURL, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return fallback, err
|
||||
}
|
||||
|
||||
return parsedURL, nil
|
||||
}
|
||||
|
||||
func readSecretFileValue(filename string) (string, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
value := string(bytes.TrimSpace(data))
|
||||
if value == "" {
|
||||
return "", errors.New("secret file is empty")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseFileContent(r io.Reader) (lines []string) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
+413
-38
@@ -4,57 +4,432 @@
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseBoolValue(t *testing.T) {
|
||||
scenarios := map[string]bool{
|
||||
"": true,
|
||||
"1": true,
|
||||
"Yes": true,
|
||||
"yes": true,
|
||||
"True": true,
|
||||
"true": true,
|
||||
"on": true,
|
||||
"false": false,
|
||||
"off": false,
|
||||
"invalid": false,
|
||||
func TestParseStringValue(t *testing.T) {
|
||||
// Test with non-empty value
|
||||
result := parseStringValue("test", "fallback")
|
||||
if result != "test" {
|
||||
t.Errorf("Expected 'test', got '%s'", result)
|
||||
}
|
||||
|
||||
for input, expected := range scenarios {
|
||||
result := parseBool(input, true)
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result for %q, got %v instead of %v`, input, result, expected)
|
||||
// Test with empty value
|
||||
result = parseStringValue("", "fallback")
|
||||
if result != "fallback" {
|
||||
t.Errorf("Expected 'fallback', got '%s'", result)
|
||||
}
|
||||
|
||||
// Test with empty value and empty fallback
|
||||
result = parseStringValue("", "")
|
||||
if result != "" {
|
||||
t.Errorf("Expected empty string, got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBoolValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
result, err := parseBoolValue("", true)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != true {
|
||||
t.Errorf("Expected true, got %v", result)
|
||||
}
|
||||
|
||||
// Test true values
|
||||
trueValues := []string{"1", "yes", "true", "on", "YES", "TRUE", "ON"}
|
||||
for _, value := range trueValues {
|
||||
result, err := parseBoolValue(value, false)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for value '%s': %v", value, err)
|
||||
}
|
||||
if result != true {
|
||||
t.Errorf("Expected true for '%s', got %v", value, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringValueWithUnsetVariable(t *testing.T) {
|
||||
if parseString("", "defaultValue") != "defaultValue" {
|
||||
t.Errorf(`Unset variables should returns the default value`)
|
||||
// Test false values
|
||||
falseValues := []string{"0", "no", "false", "off", "NO", "FALSE", "OFF"}
|
||||
for _, value := range falseValues {
|
||||
result, err := parseBoolValue(value, true)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for value '%s': %v", value, err)
|
||||
}
|
||||
if result != false {
|
||||
t.Errorf("Expected false for '%s', got %v", value, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringValue(t *testing.T) {
|
||||
if parseString("test", "defaultValue") != "test" {
|
||||
t.Errorf(`Defined variables should returns the specified value`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValueWithUnsetVariable(t *testing.T) {
|
||||
if parseInt("", 42) != 42 {
|
||||
t.Errorf(`Unset variables should returns the default value`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValueWithInvalidInput(t *testing.T) {
|
||||
if parseInt("invalid integer", 42) != 42 {
|
||||
t.Errorf(`Invalid integer should returns the default value`)
|
||||
// Test invalid value - should return error
|
||||
_, err = parseBoolValue("invalid", false)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid boolean value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntValue(t *testing.T) {
|
||||
if parseInt("2018", 42) != 2018 {
|
||||
t.Errorf(`Defined variables should returns the specified value`)
|
||||
// Test with empty value - should return fallback
|
||||
result := parseIntValue("", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
// Test with valid integer
|
||||
result = parseIntValue("123", 42)
|
||||
if result != 123 {
|
||||
t.Errorf("Expected 123, got %d", result)
|
||||
}
|
||||
|
||||
// Test with invalid integer - should return fallback
|
||||
result = parseIntValue("invalid", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
// Test with zero
|
||||
result = parseIntValue("0", 42)
|
||||
if result != 0 {
|
||||
t.Errorf("Expected 0, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedInt64Value(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
result := ParsedInt64Value("", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
|
||||
// Test with valid int64
|
||||
result = ParsedInt64Value("9223372036854775807", 42)
|
||||
if result != 9223372036854775807 {
|
||||
t.Errorf("Expected 9223372036854775807, got %d", result)
|
||||
}
|
||||
|
||||
// Test with invalid int64 - should return fallback
|
||||
result = ParsedInt64Value("invalid", 42)
|
||||
if result != 42 {
|
||||
t.Errorf("Expected 42, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringListValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallback := []string{"a", "b"}
|
||||
result := parseStringListValue("", fallback)
|
||||
if !reflect.DeepEqual(result, fallback) {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
|
||||
// Test with single value
|
||||
result = parseStringListValue("item1", nil)
|
||||
expected := []string{"item1"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with multiple values
|
||||
result = parseStringListValue("item1,item2,item3", nil)
|
||||
expected = []string{"item1", "item2", "item3"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with duplicates - should remove duplicates
|
||||
result = parseStringListValue("item1,item2,item1", nil)
|
||||
expected = []string{"item1", "item2"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with spaces
|
||||
result = parseStringListValue(" item1 , item2 , item3 ", nil)
|
||||
expected = []string{"item1", "item2", "item3"}
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallback := 5 * time.Second
|
||||
result := parseDurationValue("", time.Second, fallback)
|
||||
if result != fallback {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
|
||||
// Test with valid duration
|
||||
result = parseDurationValue("30", time.Second, fallback)
|
||||
expected := 30 * time.Second
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with minutes
|
||||
result = parseDurationValue("5", time.Minute, fallback)
|
||||
expected = 5 * time.Minute
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test with invalid value - should return fallback
|
||||
result = parseDurationValue("invalid", time.Second, fallback)
|
||||
if result != fallback {
|
||||
t.Errorf("Expected %v, got %v", fallback, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseURLValue(t *testing.T) {
|
||||
// Test with empty value - should return fallback
|
||||
fallbackURL, _ := url.Parse("https://fallback.com")
|
||||
result, err := parseURLValue("", fallbackURL)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != fallbackURL {
|
||||
t.Errorf("Expected %v, got %v", fallbackURL, result)
|
||||
}
|
||||
|
||||
// Test with valid URL
|
||||
result, err = parseURLValue("https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result.String() != "https://example.com" {
|
||||
t.Errorf("Expected https://example.com, got %s", result.String())
|
||||
}
|
||||
|
||||
// Test with invalid URL - should return fallback and error
|
||||
result, err = parseURLValue("://invalid", fallbackURL)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL")
|
||||
}
|
||||
if result != fallbackURL {
|
||||
t.Errorf("Expected fallback URL, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileParsing(t *testing.T) {
|
||||
fileContent := `
|
||||
# This is a comment
|
||||
LOG_FILE=miniflux.log
|
||||
LOG_DATE_TIME=1
|
||||
LOG_FORMAT=json
|
||||
LISTEN_ADDR=:8080,:8443
|
||||
`
|
||||
|
||||
// Write a temporary config file and parse it
|
||||
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
filename := tmpFile.Name()
|
||||
if _, err := tmpFile.WriteString(fileContent); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "miniflux.log" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != true {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
|
||||
if configOptions.LogFormat() != "json" {
|
||||
t.Fatalf("Unexpected log format, got %q", configOptions.LogFormat())
|
||||
}
|
||||
|
||||
if configOptions.LogLevel() != "info" {
|
||||
t.Fatalf("Unexpected log level, got %q", configOptions.LogLevel())
|
||||
}
|
||||
|
||||
if len(configOptions.ListenAddr()) != 2 || configOptions.ListenAddr()[0] != ":8080" || configOptions.ListenAddr()[1] != ":8443" {
|
||||
t.Fatalf("Unexpected listen addresses, got %v", configOptions.ListenAddr())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileParsingWithIncorrectKeyValuePair(t *testing.T) {
|
||||
fileContent := `
|
||||
LOG_FILE=miniflux.log
|
||||
INVALID_LINE
|
||||
`
|
||||
|
||||
// Write a temporary config file and parse it
|
||||
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
filename := tmpFile.Name()
|
||||
if _, err := tmpFile.WriteString(fileContent); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
configParser := NewConfigParser()
|
||||
_, err = configParser.ParseFile(filename)
|
||||
if err != nil {
|
||||
t.Fatal("Invalid lines should be ignored, but got error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdminPasswordFileOption(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "password-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
password := "supersecret"
|
||||
if _, err := tmpFile.WriteString(password); err != nil {
|
||||
t.Fatalf("Failed to write to temporary file: %v", err)
|
||||
}
|
||||
|
||||
os.Clearenv()
|
||||
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.AdminPassword() != password {
|
||||
t.Fatalf("Unexpected admin password, got %q", configOptions.AdminPassword())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdminPasswordFileOptionWithEmptyFile(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "empty-password-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
os.Clearenv()
|
||||
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
|
||||
|
||||
configParser := NewConfigParser()
|
||||
_, err = configParser.ParseEnvironmentVariables()
|
||||
if err == nil {
|
||||
t.Fatal("Expected error due to empty password file, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionDefaultValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "stderr" {
|
||||
t.Fatalf("Unexpected default log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionWithCustomFilename(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_FILE", "miniflux.log")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "miniflux.log" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogFileOptionWithEmptyValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_FILE", "")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogFile() != "stderr" {
|
||||
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionDefaultValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != false {
|
||||
t.Fatalf("Unexpected default log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithCustomValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "true")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != true {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithEmptyValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
configOptions, err := configParser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parsing error: %v", err)
|
||||
}
|
||||
|
||||
if configOptions.LogDateTime() != false {
|
||||
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogDateTimeOptionWithIncorrectValue(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("LOG_DATE_TIME", "invalid")
|
||||
|
||||
configParser := NewConfigParser()
|
||||
if _, err := configParser.ParseEnvironmentVariables(); err == nil {
|
||||
t.Fatal("Expected parsing error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateChoices(rawValue string, choices []string) error {
|
||||
if !slices.Contains(choices, rawValue) {
|
||||
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateListChoices(inputValues, choices []string) error {
|
||||
for _, value := range inputValues {
|
||||
if err := validateChoices(value, choices); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGreaterThan(rawValue string, min int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return errors.New("value must be an integer")
|
||||
}
|
||||
if intValue > min {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("value must be at least %d", min)
|
||||
}
|
||||
|
||||
func validateGreaterOrEqualThan(rawValue string, min int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return errors.New("value must be an integer")
|
||||
}
|
||||
if intValue >= min {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("value must be greater or equal than %d", min)
|
||||
}
|
||||
|
||||
func validateRange(rawValue string, min, max int) error {
|
||||
intValue, err := strconv.Atoi(rawValue)
|
||||
if err != nil {
|
||||
return errors.New("value must be an integer")
|
||||
}
|
||||
if intValue < min || intValue > max {
|
||||
return fmt.Errorf("value must be between %d and %d", min, max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateChoices(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawValue string
|
||||
choices []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid choice",
|
||||
rawValue: "option1",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid choice from middle",
|
||||
rawValue: "option2",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid choice from end",
|
||||
rawValue: "option3",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid choice",
|
||||
rawValue: "invalid",
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty value with non-empty choices",
|
||||
rawValue: "",
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "case sensitive - different case",
|
||||
rawValue: "OPTION1",
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "single choice valid",
|
||||
rawValue: "only",
|
||||
choices: []string{"only"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty choices list",
|
||||
rawValue: "anything",
|
||||
choices: []string{},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateChoices(tt.rawValue, tt.choices)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
} else {
|
||||
// Verify error message format
|
||||
expectedPrefix := "value must be one of:"
|
||||
if !strings.Contains(err.Error(), expectedPrefix) {
|
||||
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateListChoices(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputValues []string
|
||||
choices []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "all valid choices",
|
||||
inputValues: []string{"option1", "option2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "single valid choice",
|
||||
inputValues: []string{"option1"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty input list",
|
||||
inputValues: []string{},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "all choices from available list",
|
||||
inputValues: []string{"option1", "option2", "option3"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate valid choices",
|
||||
inputValues: []string{"option1", "option1", "option2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "one invalid choice",
|
||||
inputValues: []string{"option1", "invalid"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "all invalid choices",
|
||||
inputValues: []string{"invalid1", "invalid2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "case sensitive - different case",
|
||||
inputValues: []string{"OPTION1"},
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty string in input",
|
||||
inputValues: []string{""},
|
||||
choices: []string{"option1", "option2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty choices list with non-empty input",
|
||||
inputValues: []string{"anything"},
|
||||
choices: []string{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid choices",
|
||||
inputValues: []string{"option1", "invalid", "option2"},
|
||||
choices: []string{"option1", "option2", "option3"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateListChoices(tt.inputValues, tt.choices)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
} else {
|
||||
// Verify error message format
|
||||
expectedPrefix := "value must be one of:"
|
||||
if !strings.Contains(err.Error(), expectedPrefix) {
|
||||
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGreaterThan(t *testing.T) {
|
||||
if err := validateGreaterThan("10", 5); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if err := validateGreaterThan("5", 5); err == nil {
|
||||
t.Errorf("expected error, got none")
|
||||
}
|
||||
|
||||
if err := validateGreaterThan("abc", 5); err == nil {
|
||||
t.Errorf("expected error for non-integer input, got none")
|
||||
}
|
||||
|
||||
if err := validateGreaterThan("-1", 0); err == nil {
|
||||
t.Errorf("expected error for value below minimum, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGreaterOrEqualThan(t *testing.T) {
|
||||
if err := validateGreaterOrEqualThan("10", 5); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if err := validateGreaterOrEqualThan("5", 5); err != nil {
|
||||
t.Errorf("expected no error for equal value, got: %v", err)
|
||||
}
|
||||
|
||||
if err := validateGreaterOrEqualThan("abc", 5); err == nil {
|
||||
t.Errorf("expected error for non-integer input, got none")
|
||||
}
|
||||
|
||||
if err := validateGreaterOrEqualThan("-1", 0); err == nil {
|
||||
t.Errorf("expected error for value below minimum, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawValue string
|
||||
min int
|
||||
max int
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid integer within range",
|
||||
rawValue: "5",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid integer at minimum",
|
||||
rawValue: "1",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid integer at maximum",
|
||||
rawValue: "10",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid zero in range",
|
||||
rawValue: "0",
|
||||
min: -5,
|
||||
max: 5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid negative in range",
|
||||
rawValue: "-3",
|
||||
min: -5,
|
||||
max: 5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "integer below minimum",
|
||||
rawValue: "0",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "integer above maximum",
|
||||
rawValue: "11",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "integer far below minimum",
|
||||
rawValue: "-100",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "integer far above maximum",
|
||||
rawValue: "100",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "non-integer string",
|
||||
rawValue: "abc",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
rawValue: "",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "float string",
|
||||
rawValue: "5.5",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "string with spaces",
|
||||
rawValue: " 5 ",
|
||||
min: 1,
|
||||
max: 10,
|
||||
expectError: true,
|
||||
errorMsg: "value must be an integer",
|
||||
},
|
||||
{
|
||||
name: "single value range",
|
||||
rawValue: "5",
|
||||
min: 5,
|
||||
max: 5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "single value range - below",
|
||||
rawValue: "4",
|
||||
min: 5,
|
||||
max: 5,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 5 and 5",
|
||||
},
|
||||
{
|
||||
name: "single value range - above",
|
||||
rawValue: "6",
|
||||
min: 5,
|
||||
max: 5,
|
||||
expectError: true,
|
||||
errorMsg: "value must be between 5 and 5",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRange(tt.rawValue, tt.min, tt.max)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
|
||||
t.Errorf("expected error message '%s', got '%s'", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
|
||||
+709
-261
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"
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
# Miniflux Fever API
|
||||
|
||||
This document describes the Fever-compatible API implemented by the `internal/fever` package in this repository.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- Path: `BASE_URL/fever/`
|
||||
- Methods: not restricted by the router; read requests are typically sent as `GET`, write requests should be sent as `POST`
|
||||
- Response format: JSON only
|
||||
- Reported API version: `3`
|
||||
|
||||
## Authentication
|
||||
|
||||
Fever authentication is enabled per user from the Miniflux integrations page.
|
||||
|
||||
- `Fever Username` and `Fever Password` are configured in Miniflux
|
||||
- Miniflux stores the Fever token as the MD5 hash of `username:password`
|
||||
- Clients authenticate by sending that token as the `api_key` parameter
|
||||
- Token lookup is case-insensitive
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
api_key = md5("fever_username:fever_password")
|
||||
```
|
||||
|
||||
Example shell command:
|
||||
|
||||
```bash
|
||||
printf '%s' 'fever_username:fever_password' | md5sum
|
||||
```
|
||||
|
||||
Authentication failure does not return HTTP 401. The middleware returns HTTP 200 with:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": 3,
|
||||
"auth": 0
|
||||
}
|
||||
```
|
||||
|
||||
On successful authentication, every response includes:
|
||||
|
||||
- `api_version`: always `3`
|
||||
- `auth`: always `1`
|
||||
- `last_refreshed_on_time`: current server Unix timestamp at response time
|
||||
|
||||
## Dispatch Rules
|
||||
|
||||
The handler selects the first matching operation in this order:
|
||||
|
||||
1. `groups`
|
||||
2. `feeds`
|
||||
3. `favicons`
|
||||
4. `unread_item_ids`
|
||||
5. `saved_item_ids`
|
||||
6. `items`
|
||||
7. `mark=item`
|
||||
8. `mark=feed`
|
||||
9. `mark=group`
|
||||
|
||||
If no selector is provided, the server returns the base authenticated response only.
|
||||
|
||||
For read operations, the selector must be present in the query string. For write operations, `mark`, `as`, `id`, and `before` are read from request form values, so they may come from the query string or a form body.
|
||||
|
||||
## Read Operations
|
||||
|
||||
### `?groups`
|
||||
|
||||
Returns:
|
||||
|
||||
- `groups`: list of categories
|
||||
- `feeds_groups`: mapping of category IDs to feed IDs
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
"last_refreshed_on_time": 1710000000,
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "All"
|
||||
}
|
||||
],
|
||||
"feeds_groups": [
|
||||
{
|
||||
"group_id": 1,
|
||||
"feed_ids": "10,11"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `groups` are Miniflux categories
|
||||
- `feeds_groups.feed_ids` is a comma-separated string
|
||||
- categories with no feeds are returned in `groups` but have no `feeds_groups` entry
|
||||
|
||||
### `?feeds`
|
||||
|
||||
Returns:
|
||||
|
||||
- `feeds`: list of feeds
|
||||
- `feeds_groups`: mapping of category IDs to feed IDs
|
||||
|
||||
Feed fields:
|
||||
|
||||
- `id`
|
||||
- `favicon_id`
|
||||
- `title`
|
||||
- `url`
|
||||
- `site_url`
|
||||
- `is_spark`
|
||||
- `last_updated_on_time`
|
||||
|
||||
Notes:
|
||||
|
||||
- `favicon_id` is `0` when the feed has no icon
|
||||
- `is_spark` is always `0` in this implementation
|
||||
- `last_updated_on_time` is the feed check time as a Unix timestamp
|
||||
|
||||
### `?favicons`
|
||||
|
||||
Returns:
|
||||
|
||||
- `favicons`: list of favicon objects
|
||||
|
||||
Favicon fields:
|
||||
|
||||
- `id`
|
||||
- `data`
|
||||
|
||||
Notes:
|
||||
|
||||
- `data` is a data URL such as `image/png;base64,...`
|
||||
|
||||
### `?unread_item_ids`
|
||||
|
||||
Returns:
|
||||
|
||||
- `unread_item_ids`: comma-separated list of unread entry IDs
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
"last_refreshed_on_time": 1710000000,
|
||||
"unread_item_ids": "100,101,102"
|
||||
}
|
||||
```
|
||||
|
||||
### `?saved_item_ids`
|
||||
|
||||
Returns:
|
||||
|
||||
- `saved_item_ids`: comma-separated list of starred entry IDs
|
||||
|
||||
### `?items`
|
||||
|
||||
Returns:
|
||||
|
||||
- `items`: list of entries
|
||||
- `total_items`: total number of non-removed entries for the user
|
||||
|
||||
Item fields:
|
||||
|
||||
- `id`
|
||||
- `feed_id`
|
||||
- `title`
|
||||
- `author`
|
||||
- `html`
|
||||
- `url`
|
||||
- `is_saved`
|
||||
- `is_read`
|
||||
- `created_on_time`
|
||||
|
||||
The implementation always excludes entries whose status is `removed`.
|
||||
|
||||
#### Pagination and filtering
|
||||
|
||||
The handler applies a fixed limit of 50 items.
|
||||
|
||||
Supported parameters:
|
||||
|
||||
- `since_id`: when greater than `0`, returns entries with `id > since_id`, ordered by `id ASC`
|
||||
- `max_id`: when equal to `0`, returns the most recent entries ordered by `id DESC`; when greater than `0`, returns entries with `id < max_id`, ordered by `id DESC`
|
||||
- `with_ids`: comma-separated list of entry IDs to fetch
|
||||
|
||||
Selector precedence inside `?items` is:
|
||||
|
||||
1. `since_id`
|
||||
2. `max_id`
|
||||
3. `with_ids`
|
||||
4. no item filter
|
||||
|
||||
Notes:
|
||||
|
||||
- `with_ids` does not enforce the 50-ID maximum mentioned in older Fever documentation
|
||||
- invalid `with_ids` members are parsed as `0` and do not match normal entries
|
||||
- when `items` is requested without `since_id`, `max_id`, or `with_ids`, the code applies no explicit `ORDER BY`, so result ordering is not guaranteed by SQL
|
||||
- `html` is returned after Miniflux content rewriting and may include media-proxy-rewritten URLs
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
"last_refreshed_on_time": 1710000000,
|
||||
"total_items": 245,
|
||||
"items": [
|
||||
{
|
||||
"id": 100,
|
||||
"feed_id": 10,
|
||||
"title": "Example entry",
|
||||
"author": "Author",
|
||||
"html": "<p>Content</p>",
|
||||
"url": "https://example.org/post",
|
||||
"is_saved": 0,
|
||||
"is_read": 1,
|
||||
"created_on_time": 1709990000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Write Operations
|
||||
|
||||
Normal successful write operations return the base authenticated response:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
"last_refreshed_on_time": 1710000000
|
||||
}
|
||||
```
|
||||
|
||||
### `mark=item`
|
||||
|
||||
Parameters:
|
||||
|
||||
- `mark=item`
|
||||
- `id=<entry_id>`
|
||||
- `as=read|unread|saved|unsaved`
|
||||
|
||||
Behavior:
|
||||
|
||||
- `as=read`: marks the entry as read
|
||||
- `as=unread`: marks the entry as unread
|
||||
- `as=saved`: toggles the starred flag
|
||||
- `as=unsaved`: toggles the starred flag
|
||||
|
||||
Important:
|
||||
|
||||
- `saved` and `unsaved` both call the same toggle operation
|
||||
- sending `as=saved` twice will save, then unsave
|
||||
- sending `as=unsaved` twice will unsave, then save
|
||||
- if `id <= 0`, the handler returns without writing a response body
|
||||
- if the entry does not exist or is already removed, the server returns the base response without an error
|
||||
|
||||
### `mark=feed`
|
||||
|
||||
Parameters:
|
||||
|
||||
- `mark=feed`
|
||||
- `as=read`
|
||||
- `id=<feed_id>`
|
||||
- `before=<unix_timestamp>`
|
||||
|
||||
Behavior:
|
||||
|
||||
- marks unread entries in the feed as read when `published_at < before`
|
||||
- the update runs asynchronously in a goroutine after the response is returned
|
||||
|
||||
Notes:
|
||||
|
||||
- if `id <= 0`, the handler returns without writing a response body
|
||||
- if `before` is missing or invalid, it is treated as Unix time `0`, which usually means nothing is marked as read
|
||||
|
||||
### `mark=group`
|
||||
|
||||
Parameters:
|
||||
|
||||
- `mark=group`
|
||||
- `as=read`
|
||||
- `id=<group_id>`
|
||||
- `before=<unix_timestamp>`
|
||||
|
||||
Behavior:
|
||||
|
||||
- `id=0`: marks all unread entries as read, ignoring `before`
|
||||
- `id>0`: marks unread entries in the matching category as read when `published_at < before`
|
||||
- the update runs asynchronously in a goroutine after the response is returned
|
||||
|
||||
Notes:
|
||||
|
||||
- group IDs map to Miniflux category IDs
|
||||
- if `id < 0`, the handler returns without writing a response body
|
||||
- if `before` is missing or invalid for `id>0`, it is treated as Unix time `0`, which usually means nothing is marked as read
|
||||
|
||||
## Error Handling
|
||||
|
||||
Authentication failures:
|
||||
|
||||
- HTTP status: `200`
|
||||
- body: `{"api_version":3,"auth":0}`
|
||||
|
||||
Internal errors:
|
||||
|
||||
- HTTP status: `500`
|
||||
- body:
|
||||
|
||||
```json
|
||||
{
|
||||
"error_message": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## Differences From Generic Fever Documentation
|
||||
|
||||
This implementation is Fever-compatible, but it does not match every detail of historical Fever API docs.
|
||||
|
||||
- Responses are always JSON; `api=xml` is mentioned in code comments but is not implemented
|
||||
- `api_version` is `3`
|
||||
- `last_refreshed_on_time` is set to the current response time, not the timestamp of the most recently refreshed feed
|
||||
- the `Kindling` and `Sparks` super groups are not returned
|
||||
- `feeds[].is_spark` is always `0`
|
||||
- item ordering without explicit pagination parameters is unspecified
|
||||
- `as=saved` and `as=unsaved` toggle the saved flag instead of setting it absolutely
|
||||
|
||||
## Examples
|
||||
|
||||
Fetch groups:
|
||||
|
||||
```bash
|
||||
curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&groups'
|
||||
```
|
||||
|
||||
Fetch most recent items:
|
||||
|
||||
```bash
|
||||
curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&items&max_id=0'
|
||||
```
|
||||
|
||||
Fetch items after a known ID:
|
||||
|
||||
```bash
|
||||
curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&items&since_id=123'
|
||||
```
|
||||
|
||||
Mark an item as read:
|
||||
|
||||
```bash
|
||||
curl -s -X POST 'https://miniflux.example.com/fever/' \
|
||||
-d 'api_key=TOKEN' \
|
||||
-d 'mark=item' \
|
||||
-d 'as=read' \
|
||||
-d 'id=123'
|
||||
```
|
||||
|
||||
Mark a feed as read before a timestamp:
|
||||
|
||||
```bash
|
||||
curl -s -X POST 'https://miniflux.example.com/fever/' \
|
||||
-d 'api_key=TOKEN' \
|
||||
-d 'mark=feed' \
|
||||
-d 'as=read' \
|
||||
-d 'id=10' \
|
||||
-d 'before=1710000000'
|
||||
```
|
||||
|
||||
Mark all items as read through the group endpoint:
|
||||
|
||||
```bash
|
||||
curl -s -X POST 'https://miniflux.example.com/fever/' \
|
||||
-d 'api_key=TOKEN' \
|
||||
-d 'mark=group' \
|
||||
-d 'as=read' \
|
||||
-d 'id=0'
|
||||
```
|
||||
+79
-98
@@ -11,30 +11,24 @@ import (
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/integration"
|
||||
"miniflux.app/v2/internal/mediaproxy"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Serve handles Fever API calls.
|
||||
func Serve(router *mux.Router, store *storage.Storage) {
|
||||
handler := &handler{store, router}
|
||||
|
||||
sr := router.PathPrefix("/fever").Subrouter()
|
||||
sr.Use(newMiddleware(store).serve)
|
||||
sr.HandleFunc("/", handler.serve).Name("feverEndpoint")
|
||||
// NewHandler returns an http.Handler for Fever API calls.
|
||||
func NewHandler(store *storage.Storage) http.Handler {
|
||||
h := &feverHandler{store: store}
|
||||
return http.HandlerFunc(h.serve)
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
store *storage.Storage
|
||||
router *mux.Router
|
||||
type feverHandler struct {
|
||||
store *storage.Storage
|
||||
}
|
||||
|
||||
func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) serve(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case request.HasQueryParam(r, "groups"):
|
||||
h.handleGroups(w, r)
|
||||
@@ -55,7 +49,7 @@ func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
|
||||
case r.FormValue("mark") == "group":
|
||||
h.handleWriteGroups(w, r)
|
||||
default:
|
||||
json.OK(w, r, newBaseResponse())
|
||||
response.JSON(w, r, newBaseResponse())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +72,7 @@ an is_spark equal to 0.
|
||||
The “Sparks” super group is not included in this response and is composed of all feeds with an
|
||||
is_spark equal to 1.
|
||||
*/
|
||||
func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
slog.Debug("[Fever] Fetching groups",
|
||||
slog.Int64("user_id", userID),
|
||||
@@ -86,13 +80,13 @@ func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
categories, err := h.store.Categories(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
feeds, err := h.store.Feeds(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,9 +95,9 @@ func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||
result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
|
||||
}
|
||||
|
||||
result.FeedsGroups = h.buildFeedGroups(feeds)
|
||||
result.FeedsGroups = buildFeedGroups(feeds)
|
||||
result.SetCommonValues()
|
||||
json.OK(w, r, result)
|
||||
response.JSON(w, r, result)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -130,7 +124,7 @@ should be limited to feeds with an is_spark equal to 0.
|
||||
|
||||
For the “Sparks” super group the items should be limited to feeds with an is_spark equal to 1.
|
||||
*/
|
||||
func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
slog.Debug("[Fever] Fetching feeds",
|
||||
slog.Int64("user_id", userID),
|
||||
@@ -138,14 +132,14 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
feeds, err := h.store.Feeds(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var result feedsResponse
|
||||
result.Feeds = make([]feed, 0)
|
||||
result.Feeds = make([]feed, 0, len(feeds))
|
||||
for _, f := range feeds {
|
||||
subscripion := feed{
|
||||
subscription := feed{
|
||||
ID: f.ID,
|
||||
Title: f.Title,
|
||||
URL: f.FeedURL,
|
||||
@@ -155,15 +149,15 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if f.Icon != nil {
|
||||
subscripion.FaviconID = f.Icon.IconID
|
||||
subscription.FaviconID = f.Icon.IconID
|
||||
}
|
||||
|
||||
result.Feeds = append(result.Feeds, subscripion)
|
||||
result.Feeds = append(result.Feeds, subscription)
|
||||
}
|
||||
|
||||
result.FeedsGroups = h.buildFeedGroups(feeds)
|
||||
result.FeedsGroups = buildFeedGroups(feeds)
|
||||
result.SetCommonValues()
|
||||
json.OK(w, r, result)
|
||||
response.JSON(w, r, result)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -185,7 +179,7 @@ A PHP/HTML example:
|
||||
|
||||
echo '<img src="data:'.$favicon['data'].'">';
|
||||
*/
|
||||
func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleFavicons(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
slog.Debug("[Fever] Fetching favicons",
|
||||
slog.Int64("user_id", userID),
|
||||
@@ -193,7 +187,7 @@ func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
icons, err := h.store.Icons(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -206,7 +200,7 @@ func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
result.SetCommonValues()
|
||||
json.OK(w, r, result)
|
||||
response.JSON(w, r, result)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -239,13 +233,12 @@ Three optional arguments control determine the items included in the response.
|
||||
Use the with_ids argument with a comma-separated list of item ids to request (a maximum of 50) specific items.
|
||||
(added in API version 2)
|
||||
*/
|
||||
func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
var result itemsResponse
|
||||
|
||||
userID := request.UserID(r)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithLimit(50)
|
||||
|
||||
switch {
|
||||
@@ -279,7 +272,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
if csvItemIDs != "" {
|
||||
var itemIDs []int64
|
||||
|
||||
for _, strItemID := range strings.Split(csvItemIDs, ",") {
|
||||
for strItemID := range strings.SplitSeq(csvItemIDs, ",") {
|
||||
strItemID = strings.TrimSpace(strItemID)
|
||||
itemID, _ := strconv.ParseInt(strItemID, 10, 64)
|
||||
itemIDs = append(itemIDs, itemID)
|
||||
@@ -295,19 +288,18 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder = h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
result.Total, err = builder.CountEntries()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result.Items = make([]item, 0)
|
||||
result.Items = make([]item, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
isRead := 0
|
||||
if entry.Status == model.EntryStatusRead {
|
||||
@@ -324,7 +316,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
FeedID: entry.FeedID,
|
||||
Title: entry.Title,
|
||||
Author: entry.Author,
|
||||
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content),
|
||||
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content),
|
||||
URL: entry.URL,
|
||||
IsSaved: isSaved,
|
||||
IsRead: isRead,
|
||||
@@ -333,7 +325,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
result.SetCommonValues()
|
||||
json.OK(w, r, result)
|
||||
response.JSON(w, r, result)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -344,7 +336,7 @@ A request with the unread_item_ids argument will return one additional member:
|
||||
|
||||
unread_item_ids (string/comma-separated list of positive integers)
|
||||
*/
|
||||
func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
slog.Debug("[Fever] Fetching unread items",
|
||||
slog.Int64("user_id", userID),
|
||||
@@ -354,11 +346,11 @@ func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
rawEntryIDs, err := builder.GetEntryIDs()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var itemIDs []string
|
||||
itemIDs := make([]string, 0, len(rawEntryIDs))
|
||||
for _, entryID := range rawEntryIDs {
|
||||
itemIDs = append(itemIDs, strconv.FormatInt(entryID, 10))
|
||||
}
|
||||
@@ -366,7 +358,7 @@ func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
|
||||
var result unreadResponse
|
||||
result.ItemIDs = strings.Join(itemIDs, ",")
|
||||
result.SetCommonValues()
|
||||
json.OK(w, r, result)
|
||||
response.JSON(w, r, result)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -377,7 +369,7 @@ with the remote Fever installation.
|
||||
|
||||
saved_item_ids (string/comma-separated list of positive integers)
|
||||
*/
|
||||
func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
slog.Debug("[Fever] Fetching saved items",
|
||||
slog.Int64("user_id", userID),
|
||||
@@ -388,18 +380,18 @@ func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
entryIDs, err := builder.GetEntryIDs()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var itemsIDs []string
|
||||
itemsIDs := make([]string, 0, len(entryIDs))
|
||||
for _, entryID := range entryIDs {
|
||||
itemsIDs = append(itemsIDs, strconv.FormatInt(entryID, 10))
|
||||
}
|
||||
|
||||
result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")}
|
||||
result.SetCommonValues()
|
||||
json.OK(w, r, result)
|
||||
response.JSON(w, r, result)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -407,7 +399,7 @@ mark=item
|
||||
as=? where ? is replaced with read, saved or unsaved
|
||||
id=? where ? is replaced with the id of the item to modify
|
||||
*/
|
||||
func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
slog.Debug("[Fever] Receiving mark=item call",
|
||||
slog.Int64("user_id", userID),
|
||||
@@ -420,11 +412,10 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -433,7 +424,7 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
json.OK(w, r, newBaseResponse())
|
||||
response.JSON(w, r, newBaseResponse())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -455,14 +446,14 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
if err := h.store.ToggleStarred(userID, entryID); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.store.Integration(userID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -474,13 +465,13 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("entry_id", entryID),
|
||||
)
|
||||
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
if err := h.store.ToggleStarred(userID, entryID); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
json.OK(w, r, newBaseResponse())
|
||||
response.JSON(w, r, newBaseResponse())
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -489,7 +480,7 @@ as=read
|
||||
id=? where ? is replaced with the id of the feed or group to modify
|
||||
before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request
|
||||
*/
|
||||
func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
feedID := request.FormInt64Value(r, "id")
|
||||
before := time.Unix(request.FormInt64Value(r, "before"), 0)
|
||||
@@ -504,18 +495,12 @@ func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
|
||||
slog.Error("[Fever] Unable to mark feed as read",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.Time("before_ts", before),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, newBaseResponse())
|
||||
response.JSON(w, r, newBaseResponse())
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -524,41 +509,37 @@ as=read
|
||||
id=? where ? is replaced with the id of the feed or group to modify
|
||||
before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request
|
||||
*/
|
||||
func (h *handler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *feverHandler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
groupID := request.FormInt64Value(r, "id")
|
||||
before := time.Unix(request.FormInt64Value(r, "before"), 0)
|
||||
|
||||
slog.Debug("[Fever] Mark group as read before a given date",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("group_id", groupID),
|
||||
slog.Time("before_ts", before),
|
||||
)
|
||||
|
||||
if groupID < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
var err error
|
||||
var err error
|
||||
|
||||
if groupID == 0 {
|
||||
err = h.store.MarkAllAsRead(userID)
|
||||
} else {
|
||||
err = h.store.MarkCategoryAsRead(userID, groupID, before)
|
||||
}
|
||||
if groupID == 0 {
|
||||
err = h.store.MarkAllAsRead(userID)
|
||||
slog.Debug("[Fever] Mark all items as read",
|
||||
slog.Int64("user_id", userID),
|
||||
)
|
||||
} else {
|
||||
before := time.Unix(request.FormInt64Value(r, "before"), 0)
|
||||
err = h.store.MarkCategoryAsRead(userID, groupID, before)
|
||||
slog.Debug("[Fever] Mark group as read before a given date",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("group_id", groupID),
|
||||
slog.Time("before_ts", before),
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Error("[Fever] Unable to mark group as read",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("group_id", groupID),
|
||||
slog.Time("before_ts", before),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
response.JSONServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.OK(w, r, newBaseResponse())
|
||||
response.JSON(w, r, newBaseResponse())
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -567,13 +548,13 @@ A feeds_group object has the following members:
|
||||
group_id (positive integer)
|
||||
feed_ids (string/comma-separated list of positive integers)
|
||||
*/
|
||||
func (h *handler) buildFeedGroups(feeds model.Feeds) []feedsGroups {
|
||||
feedsGroupedByCategory := make(map[int64][]string)
|
||||
func buildFeedGroups(feeds model.Feeds) []feedsGroups {
|
||||
feedsGroupedByCategory := make(map[int64][]string, len(feeds))
|
||||
for _, feed := range feeds {
|
||||
feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
|
||||
}
|
||||
|
||||
result := make([]feedsGroups, 0)
|
||||
result := make([]feedsGroups, 0, len(feedsGroupedByCategory))
|
||||
for categoryID, feedIDs := range feedsGroupedByCategory {
|
||||
result = append(result, feedsGroups{
|
||||
GroupID: categoryID,
|
||||
|
||||
@@ -9,70 +9,65 @@ import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
type middleware struct {
|
||||
store *storage.Storage
|
||||
}
|
||||
// Middleware returns the Fever authentication middleware.
|
||||
func Middleware(store *storage.Storage) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.ClientIP(r)
|
||||
apiKey := r.FormValue("api_key")
|
||||
if apiKey == "" {
|
||||
slog.Warn("[Fever] No API key provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
response.JSON(w, r, newAuthFailureResponse())
|
||||
return
|
||||
}
|
||||
|
||||
func newMiddleware(s *storage.Storage) *middleware {
|
||||
return &middleware{s}
|
||||
}
|
||||
user, err := store.UserByFeverToken(apiKey)
|
||||
if err != nil {
|
||||
slog.Error("[Fever] Unable to fetch user by API key",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
response.JSON(w, r, newAuthFailureResponse())
|
||||
return
|
||||
}
|
||||
|
||||
func (m *middleware) serve(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.ClientIP(r)
|
||||
apiKey := r.FormValue("api_key")
|
||||
if apiKey == "" {
|
||||
slog.Warn("[Fever] No API key provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
if user == nil {
|
||||
slog.Warn("[Fever] No user found with the API key provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
response.JSON(w, r, newAuthFailureResponse())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("[Fever] 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),
|
||||
)
|
||||
json.OK(w, r, newAuthFailureResponse())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.store.UserByFeverToken(apiKey)
|
||||
if err != nil {
|
||||
slog.Error("[Fever] Unable to fetch user by API key",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
json.OK(w, r, newAuthFailureResponse())
|
||||
return
|
||||
}
|
||||
store.SetLastLogin(user.ID)
|
||||
|
||||
if user == nil {
|
||||
slog.Warn("[Fever] No user found with the API key provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
json.OK(w, r, newAuthFailureResponse())
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
|
||||
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
|
||||
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
|
||||
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
|
||||
|
||||
slog.Info("[Fever] 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(user.ID)
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
|
||||
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
|
||||
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
|
||||
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
# Miniflux Google Reader API
|
||||
|
||||
This document describes the Google Reader compatible API implemented by the `internal/googlereader` package in this repository.
|
||||
|
||||
Miniflux implements a compatibility subset intended for existing Google Reader clients. It is not a full reimplementation of the historical Google Reader API, and several behaviors are intentionally narrower or implementation-specific.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- Client login path: `BASE_URL/accounts/ClientLogin`
|
||||
- API prefix: `BASE_URL/reader/api/0`
|
||||
- `BASE_URL` includes the Miniflux root URL and any configured `BasePath`
|
||||
- Response format:
|
||||
- `ClientLogin`: plain text by default, JSON when `output=json`
|
||||
- most API reads: JSON
|
||||
- most API writes: plain text `OK`
|
||||
|
||||
## Enabling the API
|
||||
|
||||
Google Reader compatibility is configured per user from the Miniflux integrations page.
|
||||
|
||||
- `Google Reader API` must be enabled
|
||||
- `Google Reader Username` must be unique across all Miniflux users
|
||||
- `Google Reader Password` is stored as a bcrypt hash
|
||||
|
||||
The Google Reader username and password are separate integration credentials. They are not the Miniflux account password.
|
||||
|
||||
## Authentication
|
||||
|
||||
### `POST /accounts/ClientLogin`
|
||||
|
||||
This endpoint exchanges the configured Google Reader username and password for an auth token.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `Email`: Google Reader username
|
||||
- `Passwd`: Google Reader password
|
||||
- `output`: optional, set to `json` for a JSON response
|
||||
|
||||
Successful responses:
|
||||
|
||||
- default: plain text
|
||||
- with `output=json`: JSON
|
||||
|
||||
Example plain-text response:
|
||||
|
||||
```text
|
||||
SID=readeruser/0123456789abcdef...
|
||||
LSID=readeruser/0123456789abcdef...
|
||||
Auth=readeruser/0123456789abcdef...
|
||||
```
|
||||
|
||||
Example JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"SID": "readeruser/0123456789abcdef...",
|
||||
"LSID": "readeruser/0123456789abcdef...",
|
||||
"Auth": "readeruser/0123456789abcdef..."
|
||||
}
|
||||
```
|
||||
|
||||
On authentication failure, `ClientLogin` returns HTTP `401` with the normal JSON error body:
|
||||
|
||||
```json
|
||||
{
|
||||
"error_message": "access unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
### Auth token format
|
||||
|
||||
The token format is:
|
||||
|
||||
```text
|
||||
<googlereader_username>/<hex_digest>
|
||||
```
|
||||
|
||||
The digest is generated server-side from:
|
||||
|
||||
- the Google Reader username
|
||||
- the stored bcrypt hash of the Google Reader password
|
||||
|
||||
Specifically, the code computes an HMAC-SHA256 digest of an empty message using the key:
|
||||
|
||||
```text
|
||||
googlereader_username + bcrypt_hash
|
||||
```
|
||||
|
||||
Because the bcrypt hash is only known to the server, clients should not try to precompute the token. Use `ClientLogin` or `GET /reader/api/0/token`.
|
||||
|
||||
### Authenticating API calls
|
||||
|
||||
Miniflux uses different auth mechanisms for `GET` and `POST` requests:
|
||||
|
||||
- `GET` requests must send the header `Authorization: GoogleLogin auth=<token>`
|
||||
- `POST` requests are authenticated with `T=<token>` read from the parsed form values
|
||||
|
||||
Notes:
|
||||
|
||||
- the auth scheme must be exactly `GoogleLogin`
|
||||
- the auth field name must be exactly lowercase `auth`
|
||||
- for `POST`, `T` may come from the URL query or the form body because the server reads merged form values
|
||||
- `POST` requests do not accept the token from the `Authorization` header
|
||||
- `GET` requests do not accept the token from the query string
|
||||
|
||||
### `GET /reader/api/0/token`
|
||||
|
||||
This endpoint requires normal `GET` authentication and returns the same token as plain text.
|
||||
|
||||
Many Google Reader clients use this as the edit token for subsequent write requests. In Miniflux, the edit token and auth token are the same value.
|
||||
|
||||
### Authentication failure on `/reader/api/0/*`
|
||||
|
||||
When API authentication fails under `/reader/api/0`, Miniflux returns:
|
||||
|
||||
- HTTP `401`
|
||||
- header `X-Reader-Google-Bad-Token: true`
|
||||
- content type `text/plain; charset=utf-8`
|
||||
- body `Unauthorized`
|
||||
|
||||
This is different from `ClientLogin`, which returns a JSON `401`.
|
||||
|
||||
## Identifier formats
|
||||
|
||||
### Stream IDs
|
||||
|
||||
The implementation recognizes these stream forms:
|
||||
|
||||
- built-in streams:
|
||||
- `user/-/state/com.google/read`
|
||||
- `user/-/state/com.google/starred`
|
||||
- `user/-/state/com.google/reading-list`
|
||||
- `user/-/state/com.google/kept-unread`
|
||||
- `user/-/state/com.google/broadcast`
|
||||
- `user/-/state/com.google/broadcast-friends`
|
||||
- `user/-/state/com.google/like`
|
||||
- user-specific equivalents:
|
||||
- `user/<user_id>/state/com.google/...`
|
||||
- label streams:
|
||||
- `user/-/label/<name>`
|
||||
- `user/<user_id>/label/<name>`
|
||||
- feed streams:
|
||||
- `feed/<value>`
|
||||
|
||||
Important feed stream difference:
|
||||
|
||||
- read APIs usually emit `feed/<numeric_feed_id>`
|
||||
- `subscription/edit` with `ac=subscribe` expects `feed/<absolute_feed_url>`
|
||||
- `subscription/edit` with `ac=edit` or `ac=unsubscribe` expects `feed/<numeric_feed_id>`
|
||||
|
||||
So `feed/<...>` is not a single stable identifier format across all endpoints.
|
||||
|
||||
### Item IDs
|
||||
|
||||
`edit-tag` and `stream/items/contents` accept repeated `i` parameters in all of these formats:
|
||||
|
||||
- long Google Reader form: `tag:google.com,2005:reader/item/00000000148b9369`
|
||||
- short prefixed hexadecimal form: `tag:google.com,2005:reader/item/2f2`
|
||||
- bare 16-character hexadecimal form: `000000000000048c`
|
||||
- decimal entry ID: `12345`
|
||||
|
||||
Responses use different forms depending on endpoint:
|
||||
|
||||
- `stream/items/ids` returns decimal IDs as strings
|
||||
- `stream/items/contents` returns long-form Google Reader item IDs
|
||||
|
||||
## Common response conventions
|
||||
|
||||
JSON errors use this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"error_message": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Plain-text success responses from write endpoints are usually:
|
||||
|
||||
```text
|
||||
OK
|
||||
```
|
||||
|
||||
## POST parameter parsing
|
||||
|
||||
Most `POST` handlers call `ParseForm()` and read from `r.Form`, so parameters may be supplied either in the query string or in a standard form body.
|
||||
|
||||
Important exception:
|
||||
|
||||
- `POST /reader/api/0/edit-tag` reads `a` and `r` from `r.PostForm`, so those tag lists must come from the request body
|
||||
|
||||
Because `GET` auth comes only from the `Authorization` header, query parameters never authenticate `GET` requests even when other parameters are read from the query string.
|
||||
|
||||
## Endpoint reference
|
||||
|
||||
### `GET /reader/api/0/user-info`
|
||||
|
||||
Returns JSON only. No `output=json` parameter is required.
|
||||
|
||||
Response fields:
|
||||
|
||||
- `userId`: Miniflux user ID as a string
|
||||
- `userName`: Miniflux username
|
||||
- `userProfileId`: same value as `userId`
|
||||
- `userEmail`: same value as `userName`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": "1",
|
||||
"userName": "demo",
|
||||
"userProfileId": "1",
|
||||
"userEmail": "demo"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /reader/api/0/tag/list?output=json`
|
||||
|
||||
Returns the starred state and user labels.
|
||||
|
||||
Notes:
|
||||
|
||||
- `output=json` is required
|
||||
- only labels and the starred state are returned
|
||||
- built-in states such as `read` and `reading-list` are not listed here
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"tags": [
|
||||
{
|
||||
"id": "user/1/state/com.google/starred"
|
||||
},
|
||||
{
|
||||
"id": "user/1/label/Tech",
|
||||
"label": "Tech",
|
||||
"type": "folder"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /reader/api/0/subscription/list?output=json`
|
||||
|
||||
Returns the user's feeds.
|
||||
|
||||
Notes:
|
||||
|
||||
- `output=json` is required
|
||||
- each feed is reported with a numeric feed stream ID such as `feed/42`
|
||||
- `categories` always contains the Miniflux category as a Google Reader folder
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"subscriptions": [
|
||||
{
|
||||
"id": "feed/42",
|
||||
"title": "Example Feed",
|
||||
"categories": [
|
||||
{
|
||||
"id": "user/1/label/Tech",
|
||||
"label": "Tech",
|
||||
"type": "folder"
|
||||
}
|
||||
],
|
||||
"url": "https://example.org/feed.xml",
|
||||
"htmlUrl": "https://example.org/",
|
||||
"iconUrl": "https://miniflux.example.com/icon/..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /reader/api/0/subscription/quickadd`
|
||||
|
||||
Subscribes to the first discovered feed for the given absolute URL.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `quickadd`: absolute URL
|
||||
|
||||
Response shape when a feed is found:
|
||||
|
||||
```json
|
||||
{
|
||||
"numResults": 1,
|
||||
"query": "https://example.org/feed.xml",
|
||||
"streamId": "feed/42",
|
||||
"streamName": "Example Feed"
|
||||
}
|
||||
```
|
||||
|
||||
Response shape when no feed is found:
|
||||
|
||||
```json
|
||||
{
|
||||
"numResults": 0
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- the request URL must be absolute
|
||||
- the created subscription is assigned to the user's first category when no explicit category is provided
|
||||
|
||||
### `POST /reader/api/0/subscription/edit`
|
||||
|
||||
Edits subscriptions. Successful requests return plain text `OK`.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `ac`: action
|
||||
- `s`: repeated stream ID
|
||||
- `a`: optional destination label stream
|
||||
- `t`: optional title
|
||||
|
||||
Supported actions:
|
||||
|
||||
- `ac=subscribe`
|
||||
- `ac=unsubscribe`
|
||||
- `ac=edit`
|
||||
|
||||
Behavior by action:
|
||||
|
||||
- `subscribe`
|
||||
- only the first `s` value is used
|
||||
- `s` must be `feed/<absolute_feed_url>`
|
||||
- `a`, when present, must be a label stream
|
||||
- `t`, when present, becomes the feed title after creation
|
||||
- `unsubscribe`
|
||||
- every `s` must be `feed/<numeric_feed_id>`
|
||||
- `edit`
|
||||
- only the first `s` value is used
|
||||
- `s` must be `feed/<numeric_feed_id>`
|
||||
- `t` renames the feed
|
||||
- `a` moves the feed to a label, and must be a label stream
|
||||
|
||||
Notable limitations:
|
||||
|
||||
- removing a label is not implemented here
|
||||
- `subscribe`, `edit`, and `unsubscribe` do not share the same feed ID format
|
||||
|
||||
### `POST /reader/api/0/rename-tag`
|
||||
|
||||
Renames a label. Successful requests return plain text `OK`.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `s`: source label stream
|
||||
- `dest`: destination label stream
|
||||
|
||||
Rules:
|
||||
|
||||
- both `s` and `dest` must be label streams
|
||||
- the destination label name must not be empty
|
||||
- if the source label does not exist, the endpoint returns HTTP `404`
|
||||
|
||||
### `POST /reader/api/0/disable-tag`
|
||||
|
||||
Deletes one or more labels and reassigns affected feeds to the user's first remaining category.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `s`: repeated label stream
|
||||
|
||||
Rules:
|
||||
|
||||
- only label streams are supported
|
||||
- at least one category must remain after deletion, otherwise the operation fails
|
||||
|
||||
Successful requests return plain text `OK`.
|
||||
|
||||
### `POST /reader/api/0/edit-tag`
|
||||
|
||||
Marks entries read or unread and starred or unstarred.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `i`: repeated item ID
|
||||
- `a`: repeated tag stream to add
|
||||
- `r`: repeated tag stream to remove
|
||||
|
||||
Supported tag semantics:
|
||||
|
||||
- add `user/.../state/com.google/read`: mark read
|
||||
- remove `user/.../state/com.google/read`: mark unread
|
||||
- add `user/.../state/com.google/kept-unread`: mark unread
|
||||
- remove `user/.../state/com.google/kept-unread`: mark read
|
||||
- add `user/.../state/com.google/starred`: star
|
||||
- remove `user/.../state/com.google/starred`: unstar
|
||||
|
||||
Special cases:
|
||||
|
||||
- `read` and `kept-unread` cannot be combined in conflicting ways in the same request
|
||||
- `starred` cannot be present in both add and remove
|
||||
- `broadcast` and `like` are recognized but ignored
|
||||
- unsupported tag types cause an error
|
||||
|
||||
Successful requests return plain text `OK`.
|
||||
|
||||
### `GET /reader/api/0/stream/items/ids?output=json`
|
||||
|
||||
Returns item IDs for one stream.
|
||||
|
||||
Required query parameters:
|
||||
|
||||
- `output=json`
|
||||
- `s=<stream_id>`
|
||||
|
||||
Optional query parameters:
|
||||
|
||||
- `n`: maximum number of items to return
|
||||
- `c`: numeric offset continuation token
|
||||
- `r`: sort direction, `o` for ascending, anything else for descending
|
||||
- `ot`: only items published after this Unix timestamp in seconds
|
||||
- `nt`: only items published before this Unix timestamp in seconds
|
||||
- `xt`: repeated exclude target stream
|
||||
- `it`: repeated filter target stream, parsed but currently ignored
|
||||
|
||||
Supported `s` values:
|
||||
|
||||
- `user/.../state/com.google/reading-list`
|
||||
- `user/.../state/com.google/starred`
|
||||
- `user/.../state/com.google/read`
|
||||
- `feed/<numeric_feed_id>`
|
||||
|
||||
Notes:
|
||||
|
||||
- exactly one `s` value is expected
|
||||
- label streams are not supported here
|
||||
- when `xt` contains the `read` stream, `reading-list` and `feed/<id>` behave as unread-only queries
|
||||
- if `n` is omitted, the query is effectively unbounded
|
||||
- `continuation` is a numeric offset encoded as a JSON string, not an opaque token
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"itemRefs": [
|
||||
{
|
||||
"id": "12345"
|
||||
},
|
||||
{
|
||||
"id": "12344"
|
||||
}
|
||||
],
|
||||
"continuation": "2"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /reader/api/0/stream/items/contents`
|
||||
|
||||
Returns content for specific items.
|
||||
|
||||
Required parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `output=json`
|
||||
- `i`: repeated item ID
|
||||
|
||||
Optional query parameters:
|
||||
|
||||
- `r`: sort direction, `o` for ascending, anything else for descending
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- the route is `POST` only
|
||||
- `T`, `output`, and `i` are read from merged form values, so they may be supplied in the query string or the form body
|
||||
- the handler parses stream filter query parameters, but in practice only the sort direction affects the result
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"direction": "ltr",
|
||||
"id": "user/-/state/com.google/reading-list",
|
||||
"title": "Reading List",
|
||||
"self": [
|
||||
{
|
||||
"href": "https://miniflux.example.com/reader/api/0/stream/items/contents"
|
||||
}
|
||||
],
|
||||
"updated": 1710000000,
|
||||
"author": "demo",
|
||||
"items": [
|
||||
{
|
||||
"id": "tag:google.com,2005:reader/item/00000000148b9369",
|
||||
"categories": [
|
||||
"user/1/state/com.google/reading-list",
|
||||
"user/1/label/Tech",
|
||||
"user/1/state/com.google/starred"
|
||||
],
|
||||
"title": "Example entry",
|
||||
"crawlTimeMsec": "1710000000123",
|
||||
"timestampUsec": "1710000000123456",
|
||||
"published": 1710000000,
|
||||
"updated": 1710000300,
|
||||
"author": "Author",
|
||||
"alternate": [
|
||||
{
|
||||
"href": "https://example.org/post",
|
||||
"type": "text/html"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"direction": "ltr",
|
||||
"content": "<p>Content</p>"
|
||||
},
|
||||
"content": {
|
||||
"direction": "ltr",
|
||||
"content": "<p>Content</p>"
|
||||
},
|
||||
"origin": {
|
||||
"streamId": "feed/42",
|
||||
"title": "Example Feed",
|
||||
"htmlUrl": "https://example.org/"
|
||||
},
|
||||
"enclosure": [],
|
||||
"canonical": [
|
||||
{
|
||||
"href": "https://example.org/post"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- top-level `id` and `title` are hard-coded as the reading list
|
||||
- `summary.content` and `content.content` both contain the rewritten entry content
|
||||
- enclosure URLs and embedded media may be rewritten through the Miniflux media proxy
|
||||
|
||||
### `POST /reader/api/0/mark-all-as-read`
|
||||
|
||||
Marks items as read before a timestamp. Successful requests return plain text `OK`.
|
||||
|
||||
Form parameters:
|
||||
|
||||
- `T`: auth token
|
||||
- `s`: stream ID
|
||||
- `ts`: optional timestamp
|
||||
|
||||
Supported `s` values:
|
||||
|
||||
- `feed/<numeric_feed_id>`
|
||||
- `user/.../label/<name>`
|
||||
- `user/.../state/com.google/reading-list`
|
||||
|
||||
Timestamp handling:
|
||||
|
||||
- if `ts` has at least 16 digits, it is interpreted as microseconds since the Unix epoch
|
||||
- otherwise it is interpreted as seconds since the Unix epoch
|
||||
- if `ts` is omitted, Miniflux uses the current server time
|
||||
|
||||
Notes:
|
||||
|
||||
- only unread entries published before `ts` are marked as read
|
||||
- unsupported stream types are effectively a no-op and still return `OK`
|
||||
|
||||
### Catch-all unimplemented endpoints
|
||||
|
||||
Any other `GET` or `POST` path under `/reader/api/0/` is caught by the fallback handler and returns:
|
||||
|
||||
```json
|
||||
[]
|
||||
```
|
||||
|
||||
with HTTP `200`.
|
||||
|
||||
## Compatibility notes and deviations
|
||||
|
||||
These differences are important for client authors:
|
||||
|
||||
- only a subset of Google Reader endpoints is implemented
|
||||
- feed stream IDs are numeric in read responses, but `ac=subscribe` expects `feed/<absolute_feed_url>`
|
||||
- `stream/items/ids` returns decimal entry IDs, while `stream/items/contents` returns long-form Google Reader item IDs
|
||||
- pagination uses `c` as a numeric SQL offset, not an opaque continuation token
|
||||
- `it` filter targets are parsed but currently ignored
|
||||
- `tag/list` returns only `starred` and user labels
|
||||
- API auth failures under `/reader/api/0/*` return plain text `401 Unauthorized`, not JSON
|
||||
- unknown `/reader/api/0/*` endpoints return `[]` with `200`, not `404`
|
||||
+424
-645
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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, errors.New("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")
|
||||
}
|
||||
}
|
||||
+131
-146
@@ -6,196 +6,181 @@ package googlereader // import "miniflux.app/v2/internal/googlereader"
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
type middleware struct {
|
||||
type authMiddleware struct {
|
||||
store *storage.Storage
|
||||
}
|
||||
|
||||
func newMiddleware(s *storage.Storage) *middleware {
|
||||
return &middleware{s}
|
||||
func newAuthMiddleware(s *storage.Storage) *authMiddleware {
|
||||
return &authMiddleware{s}
|
||||
}
|
||||
|
||||
func (m *middleware) handleCORS(next http.Handler) http.Handler {
|
||||
func (m *authMiddleware) validateApiKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
m.serveValidated(w, r, next)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := request.ClientIP(r)
|
||||
func (m *authMiddleware) serveValidated(w http.ResponseWriter, r *http.Request, next http.Handler) {
|
||||
clientIP := request.ClientIP(r)
|
||||
|
||||
var token string
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
slog.Warn("[GoogleReader] Could not parse request form data",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token = r.Form.Get("T")
|
||||
if token == "" {
|
||||
slog.Warn("[GoogleReader] Post-Form T field is empty",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
|
||||
if authorization == "" {
|
||||
slog.Warn("[GoogleReader] No token provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(authorization)
|
||||
if len(fields) != 2 {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
if fields[0] != "GoogleLogin" {
|
||||
slog.Warn("[GoogleReader] Authorization header does not begin with GoogleLogin",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
auths := strings.Split(fields[1], "=")
|
||||
if len(auths) != 2 {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
if auths[0] != "auth" {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
token = auths[1]
|
||||
}
|
||||
|
||||
parts := strings.Split(token, "/")
|
||||
if len(parts) != 2 {
|
||||
slog.Warn("[GoogleReader] Auth token does not have the expected structure username/hash",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.String("token", token),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
var integration *model.Integration
|
||||
var user *model.User
|
||||
var err error
|
||||
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader username",
|
||||
var token string
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
slog.Warn("[GoogleReader] Could not parse request form data",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
|
||||
if expectedToken != token {
|
||||
slog.Warn("[GoogleReader] Token does not match",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
if user, err = m.store.UserByID(integration.UserID); err != nil {
|
||||
slog.Error("[GoogleReader] Unable to fetch user from database",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
|
||||
token = r.Form.Get("T")
|
||||
if token == "" {
|
||||
slog.Warn("[GoogleReader] Post-Form T field is empty",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
Unauthorized(w, r)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
|
||||
slog.Info("[GoogleReader] User authenticated successfully",
|
||||
slog.Bool("authentication_successful", true),
|
||||
if authorization == "" {
|
||||
slog.Warn("[GoogleReader] No token provided",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(authorization)
|
||||
if len(fields) != 2 {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if fields[0] != "GoogleLogin" {
|
||||
slog.Warn("[GoogleReader] Authorization header does not begin with GoogleLogin",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
auths := strings.Split(fields[1], "=")
|
||||
if len(auths) != 2 {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if auths[0] != "auth" {
|
||||
slog.Warn("[GoogleReader] Authorization header does not have the expected GoogleLogin format auth=xxxxxx",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
token = auths[1]
|
||||
}
|
||||
|
||||
parts := strings.Split(token, "/")
|
||||
if len(parts) != 2 {
|
||||
slog.Warn("[GoogleReader] Auth token does not have the expected structure username/hash",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.String("username", user.Username),
|
||||
slog.String("token", token),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
var integration *model.Integration
|
||||
var user *model.User
|
||||
var err error
|
||||
if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader username",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
|
||||
if !crypto.ConstantTimeCmp(expectedToken, token) {
|
||||
slog.Warn("[GoogleReader] Token does not match",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
if user, err = m.store.UserByID(integration.UserID); err != nil {
|
||||
slog.Error("[GoogleReader] Unable to fetch user from database",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
m.store.SetLastLogin(integration.UserID)
|
||||
if user == nil {
|
||||
slog.Warn("[GoogleReader] No user found with the given Google Reader credentials",
|
||||
slog.Bool("authentication_failed", true),
|
||||
slog.String("client_ip", clientIP),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
sendUnauthorizedResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
|
||||
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)
|
||||
m.store.SetLastLogin(integration.UserID)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
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.GoogleReaderTokenKey, token)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
func getAuthToken(username, password string) string {
|
||||
token := hex.EncodeToString(hmac.New(sha1.New, []byte(username+password)).Sum(nil))
|
||||
token := hex.EncodeToString(hmac.New(sha256.New, []byte(username+password)).Sum(nil))
|
||||
token = username + "/" + token
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
streamStr := make([]string, 0, len(r.Streams))
|
||||
for _, s := range r.Streams {
|
||||
streamStr = append(streamStr, s.String())
|
||||
}
|
||||
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
|
||||
|
||||
exclusions := make([]string, 0, len(r.ExcludeTargets))
|
||||
for _, s := range r.ExcludeTargets {
|
||||
exclusions = append(exclusions, s.String())
|
||||
}
|
||||
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
|
||||
|
||||
filters := make([]string, 0, len(r.FilterTargets))
|
||||
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, "Sort Direction: "+r.SortDirection)
|
||||
results = append(results, "Continuation Token: "+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
|
||||
}
|
||||
@@ -10,30 +10,34 @@ import (
|
||||
"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 +47,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 +65,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 +119,11 @@ 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)
|
||||
func sendUnauthorizedResponse(w http.ResponseWriter, r *http.Request) {
|
||||
builder := response.NewBuilder(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()
|
||||
}
|
||||
|
||||
// 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.WithHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
builder.WithBodyAsString("Unauthorized")
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client // import "miniflux.app/v2/internal/http/client"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
// ErrPrivateNetwork is returned when a connection to a private network is blocked.
|
||||
var ErrPrivateNetwork = errors.New("client: connection to private network is blocked")
|
||||
|
||||
// Options holds configuration for creating an HTTP client.
|
||||
type Options struct {
|
||||
Timeout time.Duration
|
||||
BlockPrivateNetworks bool
|
||||
}
|
||||
|
||||
// NewClientWithOptions creates a new HTTP client with the specified options.
|
||||
func NewClientWithOptions(opts Options) *http.Client {
|
||||
if !opts.BlockPrivateNetworks {
|
||||
return &http.Client{Timeout: opts.Timeout}
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{
|
||||
Timeout: opts.Timeout,
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
// The check is performed at connect time on the actual resolved IP, which eliminates TOCTOU / DNS-rebinding vulnerabilities.
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: unable to parse address %q: %w", addr, err)
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: unable to resolve host %q: %w", host, err)
|
||||
}
|
||||
|
||||
var safeIP net.IP
|
||||
for _, ip := range ips {
|
||||
if !urllib.IsNonPublicIP(ip) {
|
||||
safeIP = ip
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if safeIP == nil {
|
||||
return nil, fmt.Errorf("%w: host %q resolves to a non-public IP address", ErrPrivateNetwork, host)
|
||||
}
|
||||
|
||||
safeAddr := net.JoinHostPort(safeIP.String(), port)
|
||||
return dialer.DialContext(ctx, network, safeAddr)
|
||||
},
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: opts.Timeout,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClientWithoutBlockingPrivateNetworks(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksBlocksLoopback(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
|
||||
_, err := client.Get(server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("Expected an error when connecting to loopback address, got nil")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrPrivateNetwork) {
|
||||
t.Fatalf("Expected ErrPrivateNetwork, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksAllowsPublicIPs(t *testing.T) {
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
|
||||
if client == nil {
|
||||
t.Fatal("Expected non-nil client")
|
||||
}
|
||||
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatal("Expected custom http.Transport when blockPrivateNetworks is true")
|
||||
}
|
||||
if transport.DialContext == nil {
|
||||
t.Fatal("Expected custom DialContext when blockPrivateNetworks is true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCustomTransportWhenNotBlocking(t *testing.T) {
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
|
||||
if client.Transport != nil {
|
||||
t.Fatal("Expected nil transport when blockPrivateNetworks is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksBlocksPrivateIP(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create listener: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
server.Listener = listener
|
||||
server.Start()
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
|
||||
_, err = client.Get(server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when connecting to private IP")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrPrivateNetwork) {
|
||||
t.Fatalf("Expected ErrPrivateNetwork, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPrivateNetworksAllowsLoopbackWhenDisabled(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error when blockPrivateNetworks is false, got %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cookie // import "miniflux.app/v2/internal/http/cookie"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
// Cookie names.
|
||||
const (
|
||||
CookieAppSessionID = "MinifluxAppSessionID"
|
||||
CookieUserSessionID = "MinifluxUserSessionID"
|
||||
)
|
||||
|
||||
// New creates a new cookie.
|
||||
func New(name, value string, isHTTPS bool, path string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: basePath(path),
|
||||
Secure: isHTTPS,
|
||||
HttpOnly: true,
|
||||
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
// Expired returns an expired cookie.
|
||||
func Expired(name string, isHTTPS bool, path string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: basePath(path),
|
||||
Secure: isHTTPS,
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
func basePath(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -9,19 +9,46 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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"}
|
||||
for _, header := range headers {
|
||||
value := r.Header.Get(header)
|
||||
// IsTrustedIP reports whether the given remote IP address belongs to one of the trusted networks.
|
||||
func IsTrustedIP(remoteIP string, trustedNetworks []string) bool {
|
||||
if len(trustedNetworks) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if value != "" {
|
||||
addresses := strings.Split(value, ",")
|
||||
address := strings.TrimSpace(addresses[0])
|
||||
address = dropIPv6zone(address)
|
||||
ip := net.ParseIP(remoteIP)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if net.ParseIP(address) != nil {
|
||||
return address
|
||||
for _, cidr := range trustedNetworks {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// FindClientIP returns the real client IP address using trusted reverse-proxy headers when allowed.
|
||||
func FindClientIP(r *http.Request, isTrustedProxyClient bool) string {
|
||||
if isTrustedProxyClient {
|
||||
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
|
||||
for _, header := range headers {
|
||||
value := r.Header.Get(header)
|
||||
|
||||
if value != "" {
|
||||
addresses := strings.Split(value, ",")
|
||||
address := strings.TrimSpace(addresses[0])
|
||||
address = dropIPv6zone(address)
|
||||
|
||||
if net.ParseIP(address) != nil {
|
||||
return address
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,19 +57,35 @@ func FindClientIP(r *http.Request) string {
|
||||
return FindRemoteIP(r)
|
||||
}
|
||||
|
||||
// FindRemoteIP returns remote client IP address without considering HTTP headers.
|
||||
// FindRemoteIP returns the parsed remote IP address from the request,
|
||||
// falling back to 127.0.0.1 if the address is empty, a unix socket, or invalid.
|
||||
func FindRemoteIP(r *http.Request) string {
|
||||
remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
remoteIP = r.RemoteAddr
|
||||
if r.RemoteAddr == "@" || r.RemoteAddr == "" {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return dropIPv6zone(remoteIP)
|
||||
|
||||
// If it looks like it has a port (IPv4:port or [IPv6]:port), try to split it.
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
// No port — could be a bare IPv4, IPv6, or IPv6 with zone.
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
|
||||
// Strip IPv6 zone identifier if present (e.g., %eth0).
|
||||
ip = dropIPv6zone(ip)
|
||||
|
||||
// Validate the IP address.
|
||||
if net.ParseIP(ip) == nil {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
return ip
|
||||
}
|
||||
|
||||
func dropIPv6zone(address string) string {
|
||||
i := strings.IndexByte(address, '%')
|
||||
if i != -1 {
|
||||
address = address[:i]
|
||||
idx := strings.IndexByte(address, '%')
|
||||
if idx != -1 {
|
||||
address = address[:idx]
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
@@ -10,27 +10,37 @@ import (
|
||||
|
||||
func TestFindClientIPWithoutHeaders(t *testing.T) {
|
||||
r := &http.Request{RemoteAddr: "192.168.0.1:4242"}
|
||||
if ip := FindClientIP(r); ip != "192.168.0.1" {
|
||||
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "192.168.0.1"}
|
||||
if ip := FindClientIP(r); ip != "192.168.0.1" {
|
||||
if ip := FindClientIP(r, false); ip != "192.168.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7"}
|
||||
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "fe80::14c2:f039:edc7:edc7%eth0"}
|
||||
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "[fe80::14c2:f039:edc7:edc7%eth0]:4242"}
|
||||
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
if ip := FindClientIP(r, false); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: "@"}
|
||||
if ip := FindClientIP(r, false); ip != "127.0.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
r = &http.Request{RemoteAddr: ""}
|
||||
if ip := FindClientIP(r, false); ip != "127.0.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +51,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
|
||||
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "203.0.113.195" {
|
||||
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
@@ -50,7 +60,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
headers.Set("X-Forwarded-For", "2001:db8:85a3:8d3:1319:8a2e:370:7348")
|
||||
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
|
||||
if ip := FindClientIP(r, true); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
@@ -59,7 +69,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
headers.Set("X-Forwarded-For", "fe80::14c2:f039:edc7:edc7%eth0")
|
||||
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
if ip := FindClientIP(r, true); ip != "fe80::14c2:f039:edc7:edc7" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
@@ -68,7 +78,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
headers.Set("X-Forwarded-For", "70.41.3.18")
|
||||
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "70.41.3.18" {
|
||||
if ip := FindClientIP(r, true); ip != "70.41.3.18" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
|
||||
@@ -77,7 +87,7 @@ func TestFindClientIPWithXFFHeader(t *testing.T) {
|
||||
headers.Set("X-Forwarded-For", "fake IP")
|
||||
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "192.168.0.1" {
|
||||
if ip := FindClientIP(r, true); ip != "192.168.0.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +97,7 @@ func TestClientIPWithXRealIPHeader(t *testing.T) {
|
||||
headers.Set("X-Real-Ip", "192.168.122.1")
|
||||
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "192.168.122.1" {
|
||||
if ip := FindClientIP(r, true); ip != "192.168.122.1" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
@@ -99,15 +109,7 @@ func TestClientIPWithBothHeaders(t *testing.T) {
|
||||
|
||||
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "203.0.113.195" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPWithUnixSocketRemoteAddress(t *testing.T) {
|
||||
r := &http.Request{RemoteAddr: "@"}
|
||||
|
||||
if ip := FindClientIP(r); ip != "@" {
|
||||
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +121,62 @@ func TestClientIPWithUnixSocketRemoteAddrAndBothHeaders(t *testing.T) {
|
||||
|
||||
r := &http.Request{RemoteAddr: "@", Header: headers}
|
||||
|
||||
if ip := FindClientIP(r); ip != "203.0.113.195" {
|
||||
if ip := FindClientIP(r, true); ip != "203.0.113.195" {
|
||||
t.Fatalf(`Unexpected result, got: %q`, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrustedIP(t *testing.T) {
|
||||
trustedNetworks := []string{"127.0.0.1/8", "10.0.0.0/8", "::1/128", "invalid"}
|
||||
|
||||
scenarios := []struct {
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"127.0.0.1", true},
|
||||
{"10.0.0.1", true},
|
||||
{"::1", true},
|
||||
{"192.168.1.1", false},
|
||||
{"invalid", false},
|
||||
{"@", false},
|
||||
{"/tmp/miniflux.sock", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
result := IsTrustedIP(scenario.ip, trustedNetworks)
|
||||
if result != scenario.expected {
|
||||
t.Errorf("Expected %v for IP %s, got %v", scenario.expected, scenario.ip, result)
|
||||
}
|
||||
}
|
||||
|
||||
if IsTrustedIP("127.0.0.1", nil) {
|
||||
t.Error("Expected false when no trusted networks are defined")
|
||||
}
|
||||
|
||||
if IsTrustedIP("127.0.0.1", []string{}) {
|
||||
t.Error("Expected false when trusted networks list is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRemoteIP(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
ip string
|
||||
expected string
|
||||
}{
|
||||
{"192.168.0.1:4242", "192.168.0.1"},
|
||||
{"[2001:db8::1]:4242", "2001:db8::1"},
|
||||
{"fe80::14c2:f039:edc7:edc7%eth0", "fe80::14c2:f039:edc7:edc7"},
|
||||
{"", "127.0.0.1"},
|
||||
{"@", "127.0.0.1"},
|
||||
{"invalid", "127.0.0.1"},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
r := &http.Request{RemoteAddr: scenario.ip}
|
||||
result := FindRemoteIP(r)
|
||||
if result != scenario.expected {
|
||||
t.Errorf("Expected %q for RemoteAddr %q, got %q", scenario.expected, scenario.ip, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package request // import "miniflux.app/v2/internal/http/request"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
@@ -16,55 +15,73 @@ type ContextKey int
|
||||
// List of context keys.
|
||||
const (
|
||||
UserIDContextKey ContextKey = iota
|
||||
UserNameContextKey
|
||||
UserTimezoneContextKey
|
||||
IsAdminUserContextKey
|
||||
IsAuthenticatedContextKey
|
||||
UserSessionTokenContextKey
|
||||
UserLanguageContextKey
|
||||
UserThemeContextKey
|
||||
SessionIDContextKey
|
||||
CSRFContextKey
|
||||
OAuth2StateContextKey
|
||||
OAuth2CodeVerifierContextKey
|
||||
FlashMessageContextKey
|
||||
FlashErrorMessageContextKey
|
||||
PocketRequestTokenContextKey
|
||||
LastForceRefreshContextKey
|
||||
WebSessionContextKey
|
||||
ClientIPContextKey
|
||||
GoogleReaderToken
|
||||
WebAuthnDataContextKey
|
||||
GoogleReaderTokenKey
|
||||
)
|
||||
|
||||
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
|
||||
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
|
||||
if value, valid := v.(model.WebAuthnSession); valid {
|
||||
return &value
|
||||
// WebSession returns the current web session from the request context, if present.
|
||||
func WebSession(r *http.Request) *model.WebSession {
|
||||
if v := r.Context().Value(WebSessionContextKey); v != nil {
|
||||
if value, valid := v.(*model.WebSession); valid {
|
||||
return value
|
||||
}
|
||||
}
|
||||
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 from the request context, if present.
|
||||
func GoogleReaderToken(r *http.Request) string {
|
||||
return getContextStringValue(r, GoogleReaderTokenKey)
|
||||
}
|
||||
|
||||
// IsAdminUser checks if the logged user is administrator.
|
||||
// IsAdminUser reports whether the logged-in user is an administrator.
|
||||
func IsAdminUser(r *http.Request) bool {
|
||||
return getContextBoolValue(r, IsAdminUserContextKey)
|
||||
}
|
||||
|
||||
// IsAuthenticated returns a boolean if the user is authenticated.
|
||||
// IsAuthenticated reports whether the user is authenticated.
|
||||
func IsAuthenticated(r *http.Request) bool {
|
||||
return getContextBoolValue(r, IsAuthenticatedContextKey)
|
||||
if getContextBoolValue(r, IsAuthenticatedContextKey) {
|
||||
return true
|
||||
}
|
||||
|
||||
if session := WebSession(r); session != nil {
|
||||
return session.IsAuthenticated()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// UserID returns the UserID of the logged user.
|
||||
// UserID returns the logged-in user's ID from the request context.
|
||||
func UserID(r *http.Request) int64 {
|
||||
return getContextInt64Value(r, UserIDContextKey)
|
||||
if userID := getContextInt64Value(r, UserIDContextKey); userID != 0 {
|
||||
return userID
|
||||
}
|
||||
|
||||
if session := WebSession(r); session != nil {
|
||||
if id, ok := session.UserID(); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// UserTimezone returns the timezone used by the logged user.
|
||||
// UserName returns the logged-in user's username, or "unknown" when unset.
|
||||
func UserName(r *http.Request) string {
|
||||
value := getContextStringValue(r, UserNameContextKey)
|
||||
if value == "" {
|
||||
value = "unknown"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// UserTimezone returns the user's timezone, defaulting to "UTC" when unset.
|
||||
func UserTimezone(r *http.Request) string {
|
||||
value := getContextStringValue(r, UserTimezoneContextKey)
|
||||
if value == "" {
|
||||
@@ -73,74 +90,7 @@ func UserTimezone(r *http.Request) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// UserLanguage get the locale used by the current logged user.
|
||||
func UserLanguage(r *http.Request) string {
|
||||
language := getContextStringValue(r, UserLanguageContextKey)
|
||||
if language == "" {
|
||||
language = "en_US"
|
||||
}
|
||||
return language
|
||||
}
|
||||
|
||||
// UserTheme get the theme used by the current logged user.
|
||||
func UserTheme(r *http.Request) string {
|
||||
theme := getContextStringValue(r, UserThemeContextKey)
|
||||
if theme == "" {
|
||||
theme = "system_serif"
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
// CSRF returns the current CSRF token.
|
||||
func CSRF(r *http.Request) string {
|
||||
return getContextStringValue(r, CSRFContextKey)
|
||||
}
|
||||
|
||||
// SessionID returns the current session ID.
|
||||
func SessionID(r *http.Request) string {
|
||||
return getContextStringValue(r, SessionIDContextKey)
|
||||
}
|
||||
|
||||
// UserSessionToken returns the current user session token.
|
||||
func UserSessionToken(r *http.Request) string {
|
||||
return getContextStringValue(r, UserSessionTokenContextKey)
|
||||
}
|
||||
|
||||
// OAuth2State returns the current OAuth2 state.
|
||||
func OAuth2State(r *http.Request) string {
|
||||
return getContextStringValue(r, OAuth2StateContextKey)
|
||||
}
|
||||
|
||||
func OAuth2CodeVerifier(r *http.Request) string {
|
||||
return getContextStringValue(r, OAuth2CodeVerifierContextKey)
|
||||
}
|
||||
|
||||
// FlashMessage returns the message message if any.
|
||||
func FlashMessage(r *http.Request) string {
|
||||
return getContextStringValue(r, FlashMessageContextKey)
|
||||
}
|
||||
|
||||
// FlashErrorMessage returns the message error message if any.
|
||||
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)
|
||||
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return timestamp
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address stored in the context.
|
||||
// ClientIP returns the client IP address stored in the request context.
|
||||
func ClientIP(r *http.Request) string {
|
||||
return getContextStringValue(r, ClientIPContextKey)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,16 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func newRequestWithWebSession(session *model.WebSession) *http.Request {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
ctx := context.WithValue(r.Context(), WebSessionContextKey, session)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
func TestContextStringValue(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
ctx := r.Context()
|
||||
@@ -168,6 +176,15 @@ func TestIsAuthenticated(t *testing.T) {
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
session := &model.WebSession{}
|
||||
session.SetUser(&model.User{ID: 42})
|
||||
r = newRequestWithWebSession(session)
|
||||
|
||||
result = IsAuthenticated(r)
|
||||
if !result {
|
||||
t.Errorf("Unexpected context value, got %v instead of true", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserID(t *testing.T) {
|
||||
@@ -190,6 +207,39 @@ func TestUserID(t *testing.T) {
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
session := &model.WebSession{}
|
||||
session.SetUser(&model.User{ID: 456})
|
||||
r = newRequestWithWebSession(session)
|
||||
|
||||
result = UserID(r)
|
||||
expected = int64(456)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserName(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserName(r)
|
||||
expected := "unknown"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserNameContextKey, "jane")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserName(r)
|
||||
expected = "jane"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTimezone(t *testing.T) {
|
||||
@@ -214,201 +264,21 @@ func TestUserTimezone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserLanguage(t *testing.T) {
|
||||
func TestWebSession(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserLanguage(r)
|
||||
expected := "en_US"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
if result := WebSession(r); result != nil {
|
||||
t.Fatalf("Unexpected context value, got %v instead of nil", result)
|
||||
}
|
||||
|
||||
session := &model.WebSession{ID: "session-id"}
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserLanguageContextKey, "fr_FR")
|
||||
ctx = context.WithValue(ctx, WebSessionContextKey, session)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserLanguage(r)
|
||||
expected = "fr_FR"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTheme(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserTheme(r)
|
||||
expected := "system_serif"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserThemeContextKey, "dark_serif")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserTheme(r)
|
||||
expected = "dark_serif"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRF(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := CSRF(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, CSRFContextKey, "secret")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = CSRF(r)
|
||||
expected = "secret"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionID(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := SessionID(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, SessionIDContextKey, "id")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = SessionID(r)
|
||||
expected = "id"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserSessionToken(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := UserSessionToken(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserSessionTokenContextKey, "token")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = UserSessionToken(r)
|
||||
expected = "token"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuth2State(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := OAuth2State(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, OAuth2StateContextKey, "state")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = OAuth2State(r)
|
||||
expected = "state"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlashMessage(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := FlashMessage(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, FlashMessageContextKey, "message")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = FlashMessage(r)
|
||||
expected = "message"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlashErrorMessage(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := FlashErrorMessage(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, FlashErrorMessageContextKey, "error message")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = FlashErrorMessage(r)
|
||||
expected = "error message"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
result := WebSession(r)
|
||||
if result == nil || result.ID != "session-id" {
|
||||
t.Fatalf("Unexpected context value, got %#v instead of session-id", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,3 +303,25 @@ func TestClientIP(t *testing.T) {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleReaderToken(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://example.org", nil)
|
||||
|
||||
result := GoogleReaderToken(r)
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, GoogleReaderTokenKey, "token")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
result = GoogleReaderToken(r)
|
||||
expected = "token"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ package request // import "miniflux.app/v2/internal/http/request"
|
||||
|
||||
import "net/http"
|
||||
|
||||
// CookieValue returns the cookie value.
|
||||
// CookieValue returns the named cookie value, or an empty string if the cookie is missing.
|
||||
func CookieValue(r *http.Request, name string) string {
|
||||
cookie, err := r.Cookie(name)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// FormInt64Value returns a form value as integer.
|
||||
// FormInt64Value returns the named form value parsed as int64, or 0 on error.
|
||||
func FormInt64Value(r *http.Request, param string) int64 {
|
||||
value := r.FormValue(param)
|
||||
integer, err := strconv.ParseInt(value, 10, 64)
|
||||
@@ -22,10 +20,9 @@ func FormInt64Value(r *http.Request, param string) int64 {
|
||||
return integer
|
||||
}
|
||||
|
||||
// RouteInt64Param returns an URL route parameter as int64.
|
||||
// RouteInt64Param returns the named route parameter parsed as int64, or 0 when missing or invalid.
|
||||
func RouteInt64Param(r *http.Request, param string) int64 {
|
||||
vars := mux.Vars(r)
|
||||
value, err := strconv.ParseInt(vars[param], 10, 64)
|
||||
value, err := strconv.ParseInt(routeParam(r, param), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
@@ -37,13 +34,12 @@ func RouteInt64Param(r *http.Request, param string) int64 {
|
||||
return value
|
||||
}
|
||||
|
||||
// RouteStringParam returns a URL route parameter as string.
|
||||
// RouteStringParam returns the named route parameter as a string.
|
||||
func RouteStringParam(r *http.Request, param string) string {
|
||||
vars := mux.Vars(r)
|
||||
return vars[param]
|
||||
return routeParam(r, param)
|
||||
}
|
||||
|
||||
// QueryStringParam returns a query string parameter as string.
|
||||
// QueryStringParam returns the named query parameter, or defaultValue if it is empty.
|
||||
func QueryStringParam(r *http.Request, param, defaultValue string) string {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -52,7 +48,7 @@ func QueryStringParam(r *http.Request, param, defaultValue string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// QueryStringParamList returns all values associated to the parameter.
|
||||
// QueryStringParamList returns the non-empty, trimmed values for the named query parameter.
|
||||
func QueryStringParamList(r *http.Request, param string) []string {
|
||||
var results []string
|
||||
values := r.URL.Query()
|
||||
@@ -69,7 +65,7 @@ func QueryStringParamList(r *http.Request, param string) []string {
|
||||
return results
|
||||
}
|
||||
|
||||
// QueryIntParam returns a query string parameter as integer.
|
||||
// QueryIntParam returns the named query parameter parsed as int, or defaultValue when missing, invalid, or negative.
|
||||
func QueryIntParam(r *http.Request, param string, defaultValue int) int {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -88,7 +84,7 @@ func QueryIntParam(r *http.Request, param string, defaultValue int) int {
|
||||
return int(val)
|
||||
}
|
||||
|
||||
// QueryInt64Param returns a query string parameter as int64.
|
||||
// QueryInt64Param returns the named query parameter parsed as int64, or defaultValue when missing, invalid, or negative.
|
||||
func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -107,7 +103,7 @@ func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
|
||||
return val
|
||||
}
|
||||
|
||||
// QueryBoolParam returns a query string parameter as bool.
|
||||
// QueryBoolParam returns the named query parameter parsed as bool, or defaultValue when missing or invalid.
|
||||
func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
@@ -123,9 +119,13 @@ func QueryBoolParam(r *http.Request, param string, defaultValue bool) bool {
|
||||
return val
|
||||
}
|
||||
|
||||
// HasQueryParam checks if the query string contains the given parameter.
|
||||
// HasQueryParam reports whether the query string contains the named parameter.
|
||||
func HasQueryParam(r *http.Request, param string) bool {
|
||||
values := r.URL.Query()
|
||||
_, ok := values[param]
|
||||
return ok
|
||||
}
|
||||
|
||||
func routeParam(r *http.Request, param string) string {
|
||||
return r.PathValue(param)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func TestFormInt64Value(t *testing.T) {
|
||||
@@ -41,9 +40,9 @@ func TestFormInt64Value(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteStringParam(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/route/{variable}/index", func(w http.ResponseWriter, r *http.Request) {
|
||||
func TestRouteStringParamWithServerMux(t *testing.T) {
|
||||
router := http.NewServeMux()
|
||||
router.HandleFunc("GET /route/{variable}/index", func(w http.ResponseWriter, r *http.Request) {
|
||||
result := RouteStringParam(r, "variable")
|
||||
expected := "value"
|
||||
|
||||
@@ -59,7 +58,7 @@ func TestRouteStringParam(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
r, err := http.NewRequest("GET", "/route/value/index", nil)
|
||||
r, err := http.NewRequest(http.MethodGet, "/route/value/index", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -68,9 +67,9 @@ func TestRouteStringParam(t *testing.T) {
|
||||
router.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func TestRouteInt64Param(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/a/{variable1}/b/{variable2}/c/{variable3}", func(w http.ResponseWriter, r *http.Request) {
|
||||
func TestRouteInt64ParamWithServerMux(t *testing.T) {
|
||||
router := http.NewServeMux()
|
||||
router.HandleFunc("GET /a/{variable1}/b/{variable2}/c/{variable3}", func(w http.ResponseWriter, r *http.Request) {
|
||||
result := RouteInt64Param(r, "variable1")
|
||||
expected := int64(42)
|
||||
|
||||
@@ -100,7 +99,7 @@ func TestRouteInt64Param(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
r, err := http.NewRequest("GET", "/a/42/b/not-int/c/-10", nil)
|
||||
r, err := http.NewRequest(http.MethodGet, "/a/42/b/not-int/c/-10", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -179,7 +178,7 @@ func TestQueryInt64Param(t *testing.T) {
|
||||
t.Errorf(`Unexpected result, got %d instead of %d`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryInt64Param(r, "invalid", int64(69))
|
||||
result = QueryInt64Param(r, "negative", int64(69))
|
||||
expected = int64(69)
|
||||
|
||||
if result != expected {
|
||||
@@ -194,6 +193,58 @@ func TestQueryInt64Param(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBoolParam(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.org/?truthy=true&falsy=false&invalid=wat")
|
||||
r := &http.Request{URL: u}
|
||||
|
||||
result := QueryBoolParam(r, "truthy", false)
|
||||
expected := true
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryBoolParam(r, "falsy", true)
|
||||
expected = false
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryBoolParam(r, "missing", true)
|
||||
expected = true
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryBoolParam(r, "invalid", true)
|
||||
expected = true
|
||||
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryStringParamList(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.org/?tag=alpha&tag=beta&tag=+&tag=%20%20gamma%20%20&empty=")
|
||||
r := &http.Request{URL: u}
|
||||
|
||||
result := QueryStringParamList(r, "tag")
|
||||
expected := []string{"alpha", "beta", "gamma"}
|
||||
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
|
||||
result = QueryStringParamList(r, "missing")
|
||||
expected = nil
|
||||
|
||||
if !reflect.DeepEqual(result, expected) {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasQueryParam(t *testing.T) {
|
||||
u, _ := url.Parse("http://example.org/?key=42")
|
||||
r := &http.Request{URL: u}
|
||||
|
||||
@@ -6,9 +6,9 @@ package response // import "miniflux.app/v2/internal/http/response"
|
||||
import (
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -28,6 +28,11 @@ type Builder struct {
|
||||
body any
|
||||
}
|
||||
|
||||
// NewBuilder creates a new response builder.
|
||||
func NewBuilder(w http.ResponseWriter, r *http.Request) *Builder {
|
||||
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}
|
||||
}
|
||||
|
||||
// WithStatus uses the given status code to build the response.
|
||||
func (b *Builder) WithStatus(statusCode int) *Builder {
|
||||
b.statusCode = statusCode
|
||||
@@ -40,15 +45,33 @@ func (b *Builder) WithHeader(key, value string) *Builder {
|
||||
return b
|
||||
}
|
||||
|
||||
// WithBody uses the given body to build the response.
|
||||
func (b *Builder) WithBody(body any) *Builder {
|
||||
// WithBodyAsBytes uses the given bytes to build the response.
|
||||
func (b *Builder) WithBodyAsBytes(body []byte) *Builder {
|
||||
b.body = body
|
||||
return b
|
||||
}
|
||||
|
||||
// WithBodyAsString uses the given string to build the response.
|
||||
func (b *Builder) WithBodyAsString(body string) *Builder {
|
||||
b.body = body
|
||||
return b
|
||||
}
|
||||
|
||||
// WithBodyAsReader uses the given reader to build the response.
|
||||
func (b *Builder) WithBodyAsReader(body io.Reader) *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"] = formatContentDisposition("attachment", filename)
|
||||
return b
|
||||
}
|
||||
|
||||
// WithInline suggests an inline filename for the current response.
|
||||
func (b *Builder) WithInline(filename string) *Builder {
|
||||
b.headers["Content-Disposition"] = formatContentDisposition("inline", filename)
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -60,11 +83,12 @@ func (b *Builder) WithoutCompression() *Builder {
|
||||
|
||||
// WithCaching adds caching headers to the response.
|
||||
func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
|
||||
etag = normalizeETag(etag)
|
||||
b.headers["ETag"] = etag
|
||||
b.headers["Cache-Control"] = "public"
|
||||
b.headers["Cache-Control"] = "public, immutable"
|
||||
b.headers["Expires"] = time.Now().Add(duration).UTC().Format(http.TimeFormat)
|
||||
|
||||
if etag == b.r.Header.Get("If-None-Match") {
|
||||
if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
|
||||
b.statusCode = http.StatusNotModified
|
||||
b.body = nil
|
||||
b.Write()
|
||||
@@ -85,8 +109,6 @@ func (b *Builder) Write() {
|
||||
b.compress(v)
|
||||
case string:
|
||||
b.compress([]byte(v))
|
||||
case error:
|
||||
b.compress([]byte(v.Error()))
|
||||
case io.Reader:
|
||||
// Compression not implemented in this case
|
||||
b.writeHeaders()
|
||||
@@ -111,6 +133,7 @@ func (b *Builder) writeHeaders() {
|
||||
|
||||
func (b *Builder) compress(data []byte) {
|
||||
if b.enableCompression && len(data) > compressionThreshold {
|
||||
b.headers["Vary"] = "Accept-Encoding"
|
||||
acceptEncoding := b.r.Header.Get("Accept-Encoding")
|
||||
switch {
|
||||
case strings.Contains(acceptEncoding, "br"):
|
||||
@@ -118,24 +141,24 @@ func (b *Builder) compress(data []byte) {
|
||||
b.writeHeaders()
|
||||
|
||||
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
|
||||
defer brotliWriter.Close()
|
||||
brotliWriter.Write(data)
|
||||
brotliWriter.Close()
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "gzip"):
|
||||
b.headers["Content-Encoding"] = "gzip"
|
||||
b.writeHeaders()
|
||||
|
||||
gzipWriter := gzip.NewWriter(b.w)
|
||||
defer gzipWriter.Close()
|
||||
gzipWriter.Write(data)
|
||||
gzipWriter.Close()
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "deflate"):
|
||||
b.headers["Content-Encoding"] = "deflate"
|
||||
b.writeHeaders()
|
||||
|
||||
flateWriter, _ := flate.NewWriter(b.w, -1)
|
||||
defer flateWriter.Close()
|
||||
flateWriter.Write(data)
|
||||
flateWriter.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -144,7 +167,36 @@ func (b *Builder) compress(data []byte) {
|
||||
b.w.Write(data)
|
||||
}
|
||||
|
||||
// New creates a new response builder.
|
||||
func New(w http.ResponseWriter, r *http.Request) *Builder {
|
||||
return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}
|
||||
func normalizeETag(etag string) string {
|
||||
etag = strings.TrimSpace(etag)
|
||||
if etag == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/"`) {
|
||||
return etag
|
||||
}
|
||||
return `"` + etag + `"`
|
||||
}
|
||||
|
||||
func ifNoneMatch(headerValue, etag string) bool {
|
||||
if headerValue == "" || etag == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(headerValue) == "*" {
|
||||
return true
|
||||
}
|
||||
// Weak ETag comparison: the opaque-tag (quoted string without W/ prefix) must match.
|
||||
return strings.Contains(headerValue, strings.TrimPrefix(etag, `W/`))
|
||||
}
|
||||
|
||||
func formatContentDisposition(dispositionType, filename string) string {
|
||||
if filename == "" {
|
||||
return dispositionType
|
||||
}
|
||||
|
||||
if value := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); value != "" {
|
||||
return value
|
||||
}
|
||||
|
||||
return dispositionType
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"bytes"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -21,7 +22,7 @@ func TestResponseHasCommonHeaders(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).Write()
|
||||
NewBuilder(w, r).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -49,7 +50,7 @@ func TestBuildResponseWithCustomStatusCode(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithStatus(http.StatusNotAcceptable).Write()
|
||||
NewBuilder(w, r).WithStatus(http.StatusNotAcceptable).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -70,7 +71,7 @@ func TestBuildResponseWithCustomHeader(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithHeader("X-My-Header", "Value").Write()
|
||||
NewBuilder(w, r).WithHeader("X-My-Header", "Value").Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -92,7 +93,7 @@ func TestBuildResponseWithAttachment(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithAttachment("my_file.pdf").Write()
|
||||
NewBuilder(w, r).WithAttachment("my_file.pdf").Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -105,7 +106,7 @@ func TestBuildResponseWithAttachment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithError(t *testing.T) {
|
||||
func TestBuildResponseWithAttachmentEscapesFilename(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -114,15 +115,78 @@ func TestBuildResponseWithError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(errors.New("Some error")).Write()
|
||||
NewBuilder(w, r).WithAttachment(`a";filename="malware.exe`).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedBody := `Some error`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
actual := resp.Header.Get("Content-Disposition")
|
||||
mediaType, params, err := mime.ParseMediaType(actual)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parse error for %q: %v", actual, err)
|
||||
}
|
||||
|
||||
if mediaType != "attachment" {
|
||||
t.Fatalf(`Unexpected media type, got %q instead of %q`, mediaType, "attachment")
|
||||
}
|
||||
|
||||
if params["filename"] != `a";filename="malware.exe` {
|
||||
t.Fatalf(`Unexpected filename, got %q instead of %q`, params["filename"], `a";filename="malware.exe`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithInlineEscapesFilename(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NewBuilder(w, r).WithInline(`a";filename="malware.exe`).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
actual := resp.Header.Get("Content-Disposition")
|
||||
mediaType, params, err := mime.ParseMediaType(actual)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected parse error for %q: %v", actual, err)
|
||||
}
|
||||
|
||||
if mediaType != "inline" {
|
||||
t.Fatalf(`Unexpected media type, got %q instead of %q`, mediaType, "inline")
|
||||
}
|
||||
|
||||
if params["filename"] != `a";filename="malware.exe` {
|
||||
t.Fatalf(`Unexpected filename, got %q instead of %q`, params["filename"], `a";filename="malware.exe`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatContentDisposition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dispositionType string
|
||||
filename string
|
||||
expected string
|
||||
}{
|
||||
{"empty filename returns bare type", "inline", "", "inline"},
|
||||
{"simple filename", "attachment", "photo.jpg", `attachment; filename=photo.jpg`},
|
||||
{"filename with double quote", "inline", `a";filename="malware.exe`, `inline; filename="a\";filename=\"malware.exe"`},
|
||||
{"filename with spaces", "attachment", "my file.txt", `attachment; filename="my file.txt"`},
|
||||
{"non-ASCII filename", "attachment", "café.png", `attachment; filename*=utf-8''caf%C3%A9.png`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual := formatContentDisposition(tt.dispositionType, tt.filename)
|
||||
if actual != tt.expected {
|
||||
t.Fatalf(`formatContentDisposition(%q, %q) = %q, want %q`, tt.dispositionType, tt.filename, actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +199,7 @@ func TestBuildResponseWithByteBody(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody([]byte("body")).Write()
|
||||
NewBuilder(w, r).WithBodyAsBytes([]byte("body")).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -156,8 +220,8 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
|
||||
b.WithBody("cached body")
|
||||
NewBuilder(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
|
||||
b.WithBodyAsString("cached body")
|
||||
b.Write()
|
||||
})
|
||||
})
|
||||
@@ -176,55 +240,118 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedHeader := "public"
|
||||
expectedHeader := "public, immutable"
|
||||
actualHeader := resp.Header.Get("Cache-Control")
|
||||
if actualHeader != expectedHeader {
|
||||
t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
|
||||
}
|
||||
|
||||
if actualETag := resp.Header.Get("ETag"); actualETag != `"etag"` {
|
||||
t.Fatalf(`Unexpected etag header, got %q instead of %q`, actualETag, `"etag"`)
|
||||
}
|
||||
|
||||
if resp.Header.Get("Expires") == "" {
|
||||
t.Fatalf(`Expires header should not be empty`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithCachingAndEtag(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("If-None-Match", "etag")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
func TestBuildResponseWithCachingAndIfNoneMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ifNoneMatch string
|
||||
expectedStatus int
|
||||
expectedBody string
|
||||
}{
|
||||
{"matching strong etag", `"etag"`, http.StatusNotModified, ""},
|
||||
{"matching weak etag", `W/"etag"`, http.StatusNotModified, ""},
|
||||
{"multiple etags with match", `"other", W/"etag"`, http.StatusNotModified, ""},
|
||||
{"wildcard", `*`, http.StatusNotModified, ""},
|
||||
{"non-matching etag", `"different"`, http.StatusOK, "cached body"},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Header.Set("If-None-Match", tt.ifNoneMatch)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
|
||||
b.WithBody("cached body")
|
||||
b.Write()
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NewBuilder(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
|
||||
b.WithBodyAsString("cached body")
|
||||
b.Write()
|
||||
})
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
if resp.StatusCode != tt.expectedStatus {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, tt.expectedStatus)
|
||||
}
|
||||
|
||||
if actual := w.Body.String(); actual != tt.expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %q instead of %q`, actual, tt.expectedBody)
|
||||
}
|
||||
|
||||
if resp.Header.Get("Cache-Control") != "public, immutable" {
|
||||
t.Fatalf(`Unexpected Cache-Control header: %q`, resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
|
||||
if resp.Header.Get("Expires") == "" {
|
||||
t.Fatalf(`Expires header should not be empty`)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusNotModified
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
func TestNormalizeETag(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"abc", `"abc"`},
|
||||
{`"already-quoted"`, `"already-quoted"`},
|
||||
{`W/"weak"`, `W/"weak"`},
|
||||
{"", ""},
|
||||
{" spaced ", `"spaced"`},
|
||||
}
|
||||
|
||||
expectedBody := ``
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if actual := normalizeETag(tt.input); actual != tt.expected {
|
||||
t.Fatalf(`normalizeETag(%q) = %q, want %q`, tt.input, actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIfNoneMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headerValue string
|
||||
etag string
|
||||
expected bool
|
||||
}{
|
||||
{"empty header", "", `"etag"`, false},
|
||||
{"empty etag", `"etag"`, "", false},
|
||||
{"exact match", `"etag"`, `"etag"`, true},
|
||||
{"weak vs strong match", `W/"etag"`, `"etag"`, true},
|
||||
{"wildcard", `*`, `"etag"`, true},
|
||||
{"no match", `"other"`, `"etag"`, false},
|
||||
{"match in list", `"a", "etag", "b"`, `"etag"`, true},
|
||||
{"no match in list", `"a", "b", "c"`, `"etag"`, false},
|
||||
}
|
||||
|
||||
expectedHeader := "public"
|
||||
actualHeader := resp.Header.Get("Cache-Control")
|
||||
if actualHeader != expectedHeader {
|
||||
t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
|
||||
}
|
||||
|
||||
if resp.Header.Get("Expires") == "" {
|
||||
t.Fatalf(`Expires header should not be empty`)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if actual := ifNoneMatch(tt.headerValue, tt.etag); actual != tt.expected {
|
||||
t.Fatalf(`ifNoneMatch(%q, %q) = %v, want %v`, tt.headerValue, tt.etag, actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +366,7 @@ func TestBuildResponseWithBrotliCompression(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).Write()
|
||||
NewBuilder(w, r).WithBodyAsString(body).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -263,7 +390,7 @@ func TestBuildResponseWithGzipCompression(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).Write()
|
||||
NewBuilder(w, r).WithBodyAsString(body).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -287,7 +414,7 @@ func TestBuildResponseWithDeflateCompression(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).Write()
|
||||
NewBuilder(w, r).WithBodyAsString(body).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -298,6 +425,12 @@ func TestBuildResponseWithDeflateCompression(t *testing.T) {
|
||||
if actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
|
||||
expectedVary := "Accept-Encoding"
|
||||
actualVary := resp.Header.Get("Vary")
|
||||
if actualVary != expectedVary {
|
||||
t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithCompressionDisabled(t *testing.T) {
|
||||
@@ -311,7 +444,7 @@ func TestBuildResponseWithCompressionDisabled(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).WithoutCompression().Write()
|
||||
NewBuilder(w, r).WithBodyAsString(body).WithoutCompression().Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -322,6 +455,12 @@ func TestBuildResponseWithCompressionDisabled(t *testing.T) {
|
||||
if actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
|
||||
expectedVary := ""
|
||||
actualVary := resp.Header.Get("Vary")
|
||||
if actualVary != expectedVary {
|
||||
t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {
|
||||
@@ -335,7 +474,7 @@ func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).Write()
|
||||
NewBuilder(w, r).WithBodyAsString(body).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -346,6 +485,12 @@ func TestBuildResponseWithDeflateCompressionAndSmallPayload(t *testing.T) {
|
||||
if actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
|
||||
expectedVary := ""
|
||||
actualVary := resp.Header.Get("Vary")
|
||||
if actualVary != expectedVary {
|
||||
t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithoutCompressionHeader(t *testing.T) {
|
||||
@@ -358,7 +503,7 @@ func TestBuildResponseWithoutCompressionHeader(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).Write()
|
||||
NewBuilder(w, r).WithBodyAsString(body).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
@@ -369,4 +514,29 @@ func TestBuildResponseWithoutCompressionHeader(t *testing.T) {
|
||||
if actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
|
||||
expectedVary := "Accept-Encoding"
|
||||
actualVary := resp.Header.Get("Vary")
|
||||
if actualVary != expectedVary {
|
||||
t.Fatalf(`Unexpected vary header value, got %q instead of %q`, actualVary, expectedVary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithReaderBody(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NewBuilder(w, r).WithBodyAsReader(bytes.NewBufferString("body")).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
if actualBody := w.Body.String(); actualBody != "body" {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, "body")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package html // import "miniflux.app/v2/internal/http/response/html"
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
// OK creates a new HTML response with a 200 status code.
|
||||
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
builder := response.New(w, r)
|
||||
// HTML creates a new HTML response with a 200 status code.
|
||||
func HTML[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBody(body)
|
||||
switch v := any(body).(type) {
|
||||
case []byte:
|
||||
builder.WithBodyAsBytes(v)
|
||||
case string:
|
||||
builder.WithBodyAsString(v)
|
||||
}
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// ServerError sends an internal error to the client.
|
||||
func ServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
// HTMLServerError sends an internal error to the client.
|
||||
func HTMLServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Error(http.StatusText(http.StatusInternalServerError),
|
||||
slog.Any("error", err),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
@@ -35,17 +42,17 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusInternalServerError)
|
||||
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Content-Security-Policy", 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.WithBodyAsString(html.EscapeString(err.Error()))
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// BadRequest sends a bad request error to the client.
|
||||
func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
// HTMLBadRequest sends a bad request error to the client.
|
||||
func HTMLBadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Warn(http.StatusText(http.StatusBadRequest),
|
||||
slog.Any("error", err),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
@@ -59,17 +66,17 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusBadRequest)
|
||||
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Content-Security-Policy", 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.WithBodyAsString(html.EscapeString(err.Error()))
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// Forbidden sends a forbidden error to the client.
|
||||
func Forbidden(w http.ResponseWriter, r *http.Request) {
|
||||
// HTMLForbidden sends a forbidden error to the client.
|
||||
func HTMLForbidden(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn(http.StatusText(http.StatusForbidden),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
@@ -82,16 +89,16 @@ func Forbidden(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusForbidden)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBody("Access Forbidden")
|
||||
builder.WithBodyAsString("Access Forbidden")
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// NotFound sends a page not found error to the client.
|
||||
func NotFound(w http.ResponseWriter, r *http.Request) {
|
||||
// HTMLNotFound sends a page not found error to the client.
|
||||
func HTMLNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn(http.StatusText(http.StatusNotFound),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
@@ -104,21 +111,25 @@ func NotFound(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusNotFound)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBody("Page Not Found")
|
||||
builder.WithBodyAsString("Page Not Found")
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// Redirect redirects the user to another location.
|
||||
func Redirect(w http.ResponseWriter, r *http.Request, uri string) {
|
||||
// HTMLRedirect redirects the user to a relative path or an absolute http(s) URL.
|
||||
func HTMLRedirect(w http.ResponseWriter, r *http.Request, uri string) {
|
||||
if !urllib.IsRelativePath(uri) && !urllib.IsAbsoluteURL(uri) {
|
||||
HTMLBadRequest(w, r, fmt.Errorf("invalid redirect URL: %q", uri))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
}
|
||||
|
||||
// RequestedRangeNotSatisfiable sends a range not satisfiable error to the client.
|
||||
func RequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, contentRange string) {
|
||||
// HTMLRequestedRangeNotSatisfiable sends a range not satisfiable error to the client.
|
||||
func HTMLRequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, contentRange string) {
|
||||
slog.Warn(http.StatusText(http.StatusRequestedRangeNotSatisfiable),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.Group("request",
|
||||
@@ -131,11 +142,11 @@ func RequestedRangeNotSatisfiable(w http.ResponseWriter, r *http.Request, conten
|
||||
),
|
||||
)
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder := NewBuilder(w, r)
|
||||
builder.WithStatus(http.StatusRequestedRangeNotSatisfiable)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithHeader("Content-Range", contentRange)
|
||||
builder.WithBody("Range Not Satisfiable")
|
||||
builder.WithBodyAsString("Range Not Satisfiable")
|
||||
builder.Write()
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package html // import "miniflux.app/v2/internal/http/response/html"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOKResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
OK(w, r, "Some HTML")
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusOK
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `Some HTML`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-cache, max-age=0, must-revalidate, no-store",
|
||||
}
|
||||
|
||||
for header, expected := range headers {
|
||||
actual := resp.Header.Get(header)
|
||||
if actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerErrorResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ServerError(w, r, errors.New("Some error"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusInternalServerError
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `Some error`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := "text/html; charset=utf-8"
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadRequestResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
BadRequest(w, r, errors.New("Some error"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusBadRequest
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `Some error`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := "text/html; charset=utf-8"
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForbiddenResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Forbidden(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusForbidden
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `Access Forbidden`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := "text/html; charset=utf-8"
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFoundResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NotFound(w, r)
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusNotFound
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := `Page Not Found`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
}
|
||||
|
||||
expectedContentType := "text/html; charset=utf-8"
|
||||
actualContentType := resp.Header.Get("Content-Type")
|
||||
if actualContentType != expectedContentType {
|
||||
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectResponse(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Redirect(w, r, "/path")
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
expectedStatusCode := http.StatusFound
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedResult := "/path"
|
||||
actualResult := resp.Header.Get("Location")
|
||||
if actualResult != expectedResult {
|
||||
t.Fatalf(`Unexpected redirect location, got %q instead of %q`, actualResult, expectedResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestedRangeNotSatisfiable(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
RequestedRangeNotSatisfiable(w, r, "bytes */12777")
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
expectedStatusCode := http.StatusRequestedRangeNotSatisfiable
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedContentRangeHeader := "bytes */12777"
|
||||
actualContentRangeHeader := resp.Header.Get("Content-Range")
|
||||
if actualContentRangeHeader != expectedContentRangeHeader {
|
||||
t.Fatalf(`Unexpected content range header, got %q instead of %q`, actualContentRangeHeader, expectedContentRangeHeader)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user